001project_wildgrowth/backend/public/course-admin.html

10787 lines
505 KiB
HTML
Raw Normal View History

2026-02-11 15:26:03 +08:00
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>课程管理系统 - Wild Growth</title>
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<!-- 版本: 2025-01-17-v5-修复混合内容错误-使用HTTPS -->
<!-- 禁用 favicon 请求 -->
<link rel="icon" href="data:,">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1400px;
margin: 0 auto;
background: white;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
overflow: hidden;
min-height: calc(100vh - 40px);
}
/* 应用布局 */
.app-layout {
display: flex;
min-height: calc(100vh - 40px);
}
/* 左侧导航栏 */
.sidebar {
width: 240px;
background: #f8f9fa;
border-right: 2px solid #e0e0e0;
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.sidebar-header {
padding: 24px 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.sidebar-header h3 {
font-size: 18px;
font-weight: 600;
margin: 0;
}
.sidebar-nav {
flex: 1;
padding: 16px 0;
}
.nav-item {
display: flex;
align-items: center;
padding: 14px 20px;
color: #333;
text-decoration: none;
transition: all 0.3s;
cursor: pointer;
border-left: 3px solid transparent;
}
.nav-item:hover {
background: #e9ecef;
color: #667eea;
}
.nav-item.active {
background: #e7f3ff;
color: #667eea;
border-left-color: #667eea;
font-weight: 600;
}
.nav-icon {
font-size: 20px;
margin-right: 12px;
width: 24px;
text-align: center;
}
.nav-text {
font-size: 14px;
}
/* 右侧内容区 */
.main-content {
flex: 1;
padding: 24px;
overflow-y: auto;
background: white;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
text-align: center;
}
.header h1 {
font-size: 28px;
margin-bottom: 10px;
}
.header p {
opacity: 0.9;
font-size: 14px;
}
.content {
padding: 30px;
}
/* 登录页面样式 */
.login-container {
max-width: 400px;
margin: 60px auto;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
font-weight: 600;
margin-bottom: 8px;
color: #333;
font-size: 14px;
}
.radio-label {
display: inline-flex;
align-items: center;
cursor: pointer;
font-weight: normal;
margin-right: 12px;
}
.radio-label input {
width: auto;
margin-right: 6px;
}
input {
width: 100%;
padding: 12px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 14px;
transition: border-color 0.3s;
}
input:focus {
outline: none;
border-color: #667eea;
}
button {
padding: 14px 24px;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
width: 100%;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-primary.btn-unsaved {
background: #f59e0b;
}
.btn-primary.btn-unsaved:hover {
background: #d97706;
}
.btn-primary.btn-saving {
background: #6b7280;
cursor: not-allowed;
}
.btn-primary.btn-saved {
background: #10b981;
}
.btn-primary.btn-saved:hover {
background: #059669;
}
.btn-primary.btn-error {
background: #ef4444;
}
.btn-primary.btn-error:hover {
background: #dc2626;
}
.btn-secondary {
background: #f5f5f5;
color: #333;
margin-top: 10px;
}
.btn-secondary:hover {
background: #e0e0e0;
}
.btn-danger {
background: #dc3545;
color: white;
}
.btn-danger:hover {
background: #c82333;
}
.btn-info {
background: #667eea;
color: white;
}
.btn-info:hover {
background: #5568d3;
}
/* Prompt卡片样式 */
.prompt-card {
background: white;
border: 2px solid #e0e0e0;
border-radius: 12px;
padding: 16px;
transition: all 0.3s;
}
.prompt-card:hover {
border-color: #667eea;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.1);
}
/* 模态框样式 */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 10000;
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
}
.modal-overlay.hidden {
display: none;
}
.modal-content {
background: white;
border-radius: 16px;
padding: 30px;
max-width: 800px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.modal-header h3 {
margin: 0;
font-size: 20px;
}
.modal-close {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #999;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
}
.modal-close:hover {
color: #333;
}
.mindmap-textarea {
width: 100%;
min-height: 300px;
padding: 12px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
font-size: 13px;
line-height: 1.6;
resize: vertical;
}
.mindmap-textarea:focus {
outline: none;
border-color: #667eea;
}
.format-hint {
background: #f5f5f5;
padding: 12px;
border-radius: 8px;
font-size: 12px;
color: #666;
margin-bottom: 15px;
}
.format-hint code {
background: #e0e0e0;
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
}
.preview-section {
margin-top: 20px;
padding: 15px;
background: #f8f9fa;
border-radius: 8px;
max-height: 300px;
overflow-y: auto;
}
.preview-section h4 {
margin: 0 0 10px 0;
font-size: 14px;
color: #333;
}
.preview-node {
margin-bottom: 15px;
padding: 10px;
background: white;
border-radius: 6px;
border-left: 3px solid #667eea;
}
.preview-node-title {
font-weight: 600;
color: #667eea;
margin-bottom: 8px;
}
.preview-slide {
margin-left: 20px;
margin-bottom: 8px;
padding: 8px;
background: #f8f9fa;
border-radius: 4px;
}
.preview-slide-title {
font-weight: 500;
color: #333;
margin-bottom: 4px;
}
.preview-slide-content {
font-size: 12px;
color: #666;
margin-left: 10px;
}
.modal-actions {
display: flex;
gap: 10px;
margin-top: 20px;
justify-content: flex-end;
}
.code-group {
display: flex;
gap: 10px;
}
.code-group input {
flex: 1;
}
.code-group button {
width: auto;
min-width: 120px;
}
.message {
margin-top: 15px;
padding: 12px;
border-radius: 8px;
display: none;
}
.message.success {
background: #d4edda;
border: 1px solid #c3e6cb;
color: #155724;
}
.message.error {
background: #f8d7da;
border: 1px solid #f5c6cb;
color: #721c24;
}
/* 隐藏类 */
.hidden {
display: none !important;
}
/* 加载状态 */
.loading {
text-align: center;
padding: 40px;
color: #666;
}
.spinner {
border: 3px solid #f3f3f3;
border-top: 3px solid #667eea;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 0 auto 20px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* 课程列表样式 */
.course-list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
}
.course-list-header h2 {
color: #333;
font-size: 24px;
}
.logout-btn {
width: auto;
padding: 10px 20px;
background: #f5f5f5;
color: #333;
font-size: 14px;
}
.course-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
}
.course-card {
background: white;
border: 2px solid #e0e0e0;
border-radius: 12px;
overflow: hidden;
transition: all 0.3s;
display: flex;
flex-direction: column;
}
.course-card-content {
flex: 1;
cursor: pointer;
}
.course-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
border-color: #667eea;
}
.course-card-actions {
padding: 12px;
border-top: 1px solid #e0e0e0;
background: #f9fafb;
display: flex;
flex-direction: column;
gap: 8px;
align-items: stretch;
}
.course-cover {
width: 100%;
height: 200px;
object-fit: cover;
background: #f5f5f5;
}
.course-info {
padding: 16px;
}
.course-title {
font-size: 18px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.course-id-display {
font-size: 11px;
color: #999;
font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
margin-bottom: 8px;
padding: 4px 8px;
background: #f5f5f5;
border-radius: 4px;
display: inline-block;
word-break: break-all;
}
.course-meta {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: #666;
}
.course-type {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
background: #e7f3ff;
color: #0066cc;
}
.course-type.system {
background: #fff3cd;
color: #856404;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #999;
}
.empty-state p {
font-size: 16px;
margin-top: 10px;
}
/* 编辑页面样式 */
.edit-page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
}
.edit-page-header h2 {
color: #333;
font-size: 24px;
}
.back-btn {
width: auto;
padding: 10px 20px;
background: #f5f5f5;
color: #333;
font-size: 14px;
}
.edit-form {
max-width: 800px;
}
textarea {
width: 100%;
padding: 12px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 14px;
transition: border-color 0.3s;
font-family: inherit;
resize: vertical;
}
textarea:focus {
outline: none;
border-color: #667eea;
}
.image-upload-area {
border: 2px dashed #e0e0e0;
border-radius: 8px;
padding: 30px;
text-align: center;
cursor: pointer;
transition: all 0.3s;
background: #fafafa;
}
.image-upload-area:hover {
border-color: #667eea;
background: #f0f4ff;
}
.image-upload-area.dragover {
border-color: #667eea;
background: #e7f3ff;
}
.image-preview {
max-width: 100%;
max-height: 400px;
border-radius: 8px;
margin-top: 20px;
display: none;
}
.image-preview.show {
display: block;
}
.upload-progress {
margin-top: 10px;
display: none;
}
.upload-progress.show {
display: block;
}
.progress-bar {
width: 100%;
height: 8px;
background: #e0e0e0;
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
width: 0%;
transition: width 0.3s;
}
.button-group {
display: flex;
gap: 12px;
margin-top: 30px;
}
.button-group button {
flex: 1;
}
/* 发布状态样式 */
.publish-status-section {
padding: 16px;
background: #f9fafb;
border-radius: 8px;
border: 2px solid #e5e7eb;
}
.status-display {
display: flex;
align-items: center;
margin-bottom: 12px;
}
.status-badge {
display: inline-block;
padding: 6px 12px;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
}
.status-badge.published {
background: #d1fae5;
color: #065f46;
}
.status-badge.draft {
background: #fef3c7;
color: #92400e;
}
.status-badge.test_published {
background: #dbeafe;
color: #1e40af;
}
.btn-status {
padding: 10px 20px;
border: 2px solid;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
}
.btn-publish {
background: #10b981;
color: white;
border-color: #10b981;
}
.btn-publish:hover {
background: #059669;
border-color: #059669;
transform: translateY(-1px);
}
.btn-draft {
background: #f59e0b;
color: white;
border-color: #f59e0b;
}
.btn-draft:hover {
background: #d97706;
border-color: #d97706;
transform: translateY(-1px);
}
.btn-status-toggle {
width: 100%;
padding: 10px 16px;
border: 2px solid;
border-radius: 6px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
white-space: nowrap;
}
.btn-publish {
background: #10b981;
color: white;
border-color: #10b981;
}
.btn-publish:hover {
background: #059669;
border-color: #059669;
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(16, 185, 129, 0.3);
}
.btn-unpublish {
background: #ef4444;
color: white;
border-color: #ef4444;
}
.btn-unpublish:hover {
background: #dc2626;
border-color: #dc2626;
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(239, 68, 68, 0.3);
}
.btn-delete-course {
background: #6b7280;
color: white;
border-color: #6b7280;
}
.btn-delete-course:hover {
background: #4b5563;
border-color: #4b5563;
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(107, 114, 128, 0.3);
}
.btn-restore {
background: #10b981;
color: white;
border-color: #10b981;
}
.btn-restore:hover {
background: #059669;
border-color: #059669;
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(16, 185, 129, 0.3);
}
/* AI生成课程相关样式 */
.page-header {
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 2px solid #e0e0e0;
}
.page-header h2 {
font-size: 24px;
color: #333;
margin: 0 0 12px 0;
}
.ai-outline-chapter {
margin-bottom: 24px;
}
.ai-outline-node {
padding: 12px 16px;
margin-bottom: 12px;
background: white;
border: 1px solid #e0e0e0;
border-radius: 6px;
transition: all 0.2s;
}
.ai-outline-node:hover {
border-color: #667eea;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.1);
}
.ai-outline-nodes {
padding-left: 0;
}
.publish-menu-container {
position: relative;
width: 100%;
}
.publish-menu {
position: absolute;
bottom: 100%;
left: 0;
right: 0;
margin-bottom: 8px;
background: white;
border: 1px solid #e5e7eb;
border-radius: 6px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
z-index: 10;
overflow: hidden;
}
.publish-menu-item {
width: 100%;
padding: 12px 16px;
border: none;
background: white;
color: #374151;
font-size: 14px;
font-weight: 500;
text-align: left;
cursor: pointer;
transition: background-color 0.2s;
border-bottom: 1px solid #f3f4f6;
}
.publish-menu-item:last-child {
border-bottom: none;
}
.publish-menu-item:hover {
background: #f9fafb;
}
.publish-menu-item:active {
background: #f3f4f6;
}
.course-status.deleted {
background: #fee2e2;
color: #991b1b;
}
.required {
color: #e74c3c;
}
/* 课程类型选择器 */
.type-selector {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.type-option {
flex: 1;
min-width: 200px;
cursor: pointer;
border: 2px solid #e0e0e0;
border-radius: 12px;
padding: 16px;
transition: all 0.3s;
background: white;
}
.type-option:hover {
border-color: #667eea;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.15);
}
.type-option.active {
border-color: #667eea;
background: linear-gradient(135deg, #667eea15 0%, #764ba215 100%);
}
.type-option input[type="radio"] {
display: none;
}
.type-label {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 8px;
}
.type-icon {
font-size: 32px;
margin-bottom: 4px;
}
.type-text {
font-size: 16px;
font-weight: 600;
color: #333;
}
.type-desc {
font-size: 12px;
color: #666;
line-height: 1.4;
}
.type-option.active .type-text {
color: #667eea;
}
/* 节点内容编辑页面样式 */
.slide-editor-page {
max-width: 1400px;
}
.slide-editor-container {
display: grid;
grid-template-columns: 300px 1fr;
gap: 24px;
margin-top: 24px;
}
.slide-list-panel {
background: white;
border-radius: 12px;
padding: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
max-height: 80vh;
overflow-y: auto;
}
.slide-list-item {
padding: 0;
margin-bottom: 8px;
border: 2px solid #e0e0e0;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s;
background: white;
display: flex;
flex-direction: row;
align-items: stretch;
}
.slide-order-controls {
width: 50px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #f5f5f5;
border-right: 2px solid #e0e0e0;
border-radius: 6px 0 0 6px;
flex-shrink: 0;
gap: 4px;
padding: 4px;
}
.order-btn {
width: 32px;
height: 32px;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
color: #667eea;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
user-select: none;
}
.order-btn:hover:not(:disabled) {
background: #667eea;
color: white;
border-color: #667eea;
transform: scale(1.1);
}
.order-btn:active:not(:disabled) {
transform: scale(0.95);
}
.order-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
background: #f5f5f5;
color: #999;
}
.slide-item-content {
flex: 1;
padding: 12px;
display: flex;
flex-direction: column;
}
.slide-list-item:hover {
border-color: #667eea;
background: #f0f4ff;
}
.slide-list-item:hover .slide-order-controls {
background: #e0e8ff;
}
.slide-list-item.active {
border-color: #667eea;
background: linear-gradient(135deg, #667eea15 0%, #764ba215 100%);
}
.slide-item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.slide-item-type {
font-size: 12px;
padding: 2px 8px;
border-radius: 4px;
}
.slide-item-type.text,
.slide-item-type.image {
background: #e7f3ff;
color: #0066cc;
}
.slide-item-type.图文 {
background: #fff3cd;
color: #856404;
}
.slide-item-type.空 {
background: #f5f5f5;
color: #999;
}
.slide-item-type.text,
.slide-item-type.image {
background: #e7f3ff;
color: #0066cc;
}
.slide-item-type.图文 {
background: #fff3cd;
color: #856404;
}
.slide-item-type.空 {
background: #f5f5f5;
color: #999;
}
.slide-item-title {
font-size: 14px;
font-weight: 500;
color: #333;
margin-bottom: 4px;
}
.slide-item-order {
font-size: 11px;
color: #999;
}
.slide-editor-panel {
background: white;
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.slide-editor-form {
max-width: 800px;
}
.slide-type-selector {
display: flex;
gap: 12px;
margin-bottom: 24px;
}
.slide-type-btn {
flex: 1;
padding: 12px;
border: 2px solid #e0e0e0;
border-radius: 8px;
background: white;
cursor: pointer;
transition: all 0.3s;
text-align: center;
}
.slide-type-btn:hover {
border-color: #667eea;
}
.slide-type-btn.active {
border-color: #667eea;
background: linear-gradient(135deg, #667eea15 0%, #764ba215 100%);
}
.paragraph-item {
display: flex;
gap: 8px;
margin-bottom: 8px;
align-items: flex-start;
}
.paragraph-item textarea {
flex: 1;
}
.paragraph-item .btn-delete {
padding: 8px 12px;
margin-top: 0;
}
/* 富文本编辑器样式 */
.rich-text-toolbar {
display: flex;
gap: 4px;
padding: 8px;
background: #f5f5f5;
border: 2px solid #e0e0e0;
border-bottom: none;
border-radius: 8px 8px 0 0;
flex-wrap: wrap;
}
.toolbar-btn {
padding: 6px 12px;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.toolbar-btn:hover {
background: #f0f0f0;
border-color: #667eea;
}
.toolbar-btn:active {
background: #e0e0e0;
}
/* 按钮激活状态(当选中文字已应用该样式时) */
.toolbar-btn.active {
background: #667eea;
color: white;
border-color: #667eea;
font-weight: 600;
}
.toolbar-btn.active:hover {
background: #5568d3;
}
/* 按钮点击动画反馈 */
.toolbar-btn.clicked {
animation: buttonClick 0.3s ease;
}
@keyframes buttonClick {
0% { transform: scale(1); }
50% { transform: scale(0.95); }
100% { transform: scale(1); }
}
.toolbar-separator {
width: 1px;
background: #ddd;
margin: 0 4px;
}
.rich-text-editor {
min-height: 300px;
max-height: 500px;
overflow-y: auto;
padding: 16px;
border: 2px solid #e0e0e0;
border-radius: 0 0 8px 8px;
background: white;
font-size: 14px;
line-height: 1.6;
outline: none;
}
.rich-text-editor:focus {
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.rich-text-editor:empty:before {
content: attr(placeholder);
color: #999;
font-style: italic;
}
.rich-text-editor p {
margin: 8px 0;
}
.rich-text-editor p:first-child {
margin-top: 0;
}
.rich-text-editor p:last-child {
margin-bottom: 0;
}
.rich-text-editor ul, .rich-text-editor ol {
margin: 8px 0;
padding-left: 24px;
}
/* ============================================
文字样式预览(新增,只影响编辑器显示)
============================================ */
.rich-text-editor b {
font-weight: bold;
}
.rich-text-editor color[type='vital'] {
color: #2266FF;
font-weight: bold;
}
.rich-text-editor color[type='iris'] {
color: #8A4FFF;
font-weight: bold;
}
.rich-text-editor color[type='neon'] {
color: #FF4081;
font-weight: bold;
}
.rich-text-editor quote {
font-style: italic;
border-left: 3px solid #2266FF;
padding-left: 16px;
margin: 8px 0;
display: block;
}
.rich-text-editor span.highlight {
color: #2266FF;
font-weight: bold;
}
.editor-hint {
font-size: 12px;
color: #999;
margin-top: 8px;
padding-left: 4px;
}
/* 章节节点管理页面样式 */
.structure-page {
max-width: 1200px;
}
.structure-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.structure-tree {
background: white;
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.chapter-item {
margin-bottom: 16px;
border: 2px solid #e0e0e0;
border-radius: 8px;
padding: 16px;
background: #fafafa;
}
.chapter-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.chapter-title {
font-size: 16px;
font-weight: 600;
color: #333;
flex: 1;
}
.chapter-actions {
display: flex;
gap: 8px;
}
.node-item {
margin-left: 24px;
margin-bottom: 8px;
padding: 12px;
background: white;
border: 1px solid #e0e0e0;
border-radius: 6px;
display: flex;
justify-content: space-between;
align-items: center;
}
.node-info {
flex: 1;
}
.node-title {
font-size: 14px;
font-weight: 500;
color: #333;
margin-bottom: 4px;
}
.node-meta {
font-size: 12px;
color: #999;
}
.node-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
.node-actions .btn-small {
min-width: 70px;
}
.btn-content {
background: #9b59b6 !important;
color: white !important;
font-size: 12px;
padding: 6px 14px;
font-weight: 600;
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s;
white-space: nowrap;
}
.btn-content:hover {
background: #8e44ad !important;
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(155, 89, 182, 0.3);
}
/* 拖拽相关样式 */
.chapter-item.dragging,
.node-item.dragging {
opacity: 0.5;
border-color: #667eea;
}
.chapter-item.drag-over,
.node-item.drag-over {
border-color: #667eea;
background: #f0f4ff;
}
.drag-handle {
cursor: move;
color: #999;
margin-right: 8px;
font-size: 16px;
}
.drag-handle:hover {
color: #667eea;
}
.btn-small {
padding: 6px 12px;
font-size: 12px;
border-radius: 4px;
border: none;
cursor: pointer;
transition: all 0.3s;
}
.btn-edit {
background: #667eea;
color: white;
}
.btn-edit:hover {
background: #5568d3;
}
.btn-delete {
background: #e74c3c;
color: white;
}
.btn-delete:hover {
background: #c0392b;
}
.btn-add {
background: #27ae60;
color: white;
}
.btn-add:hover {
background: #229954;
}
.nodes-without-chapter {
margin-top: 24px;
padding-top: 24px;
border-top: 2px dashed #e0e0e0;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #666;
margin-bottom: 12px;
}
/* 登录方式切换 */
.login-tab {
padding: 8px 20px;
margin: 0 5px;
border: 2px solid #e0e0e0;
background: white;
color: #666;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
transition: all 0.3s;
}
.login-tab:hover {
border-color: #667eea;
color: #667eea;
}
.login-tab.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-color: transparent;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>📚 课程管理系统</h1>
<p>Wild Growth - 管理课程基本信息</p>
</div>
<div class="content">
<!-- 登录页面 -->
<div id="loginPage" class="login-container">
<h2 style="margin-bottom: 20px; text-align: center; color: #333;">管理员登录</h2>
<!-- 登录方式切换标签 -->
<div style="margin-bottom: 25px; text-align: center; display: flex; justify-content: center; gap: 10px;">
<button id="passwordLoginTab" class="login-tab active" type="button" onclick="switchLoginMode('password')" style="display: inline-block; padding: 10px 24px; border: 2px solid #667eea; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; border-radius: 8px; cursor: pointer; font-size: 14px; font-weight: 600;">密码登录</button>
<button id="codeLoginTab" class="login-tab" type="button" onclick="switchLoginMode('code')" style="display: inline-block; padding: 10px 24px; border: 2px solid #e0e0e0; background: white; color: #666; border-radius: 8px; cursor: pointer; font-size: 14px; font-weight: 600;">验证码登录</button>
</div>
<!-- 密码登录表单(默认显示) -->
<div id="passwordLoginForm">
<div class="form-group">
<label for="adminPassword" style="display: block; font-weight: 600; margin-bottom: 8px; color: #333; font-size: 14px;">管理员密码</label>
<input type="password" id="adminPassword" placeholder="请输入密码123456" autocomplete="current-password" style="width: 100%; padding: 12px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px; box-sizing: border-box;">
</div>
<button class="btn-primary" type="button" id="adminLoginBtn" style="width: 100%; padding: 14px 24px; margin-top: 10px; border: none; border-radius: 8px; font-size: 16px; font-weight: 600; cursor: pointer; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white;">登录</button>
</div>
<!-- 验证码登录表单(默认隐藏) -->
<div id="codeLoginForm" style="display: none !important;">
<div class="form-group">
<label for="phone">手机号</label>
<input type="tel" id="phone" placeholder="请输入手机号" maxlength="11" autocomplete="tel">
</div>
<div class="form-group">
<label for="code">验证码</label>
<div class="code-group">
<input type="text" id="code" placeholder="请输入验证码" maxlength="6" autocomplete="one-time-code">
<button class="btn-secondary" id="sendCodeBtn" type="button" onclick="sendCode()">获取验证码</button>
</div>
</div>
<button class="btn-primary" type="button" onclick="login()">登录</button>
</div>
<div id="loginMessage" class="message"></div>
</div>
<!-- 主应用页面(登录后显示) -->
<div id="mainApp" class="hidden">
<div class="app-layout">
<!-- 左侧导航栏 -->
<div class="sidebar">
<div class="sidebar-header">
<h3>课程管理</h3>
</div>
<nav class="sidebar-nav">
<a href="javascript:void(0)" class="nav-item active" onclick="switchPage('courses')" id="navCourses">
<span class="nav-icon">📚</span>
<span class="nav-text">课程管理</span>
</a>
<a href="javascript:void(0)" class="nav-item" onclick="switchPage('deleted')" id="navDeleted">
<span class="nav-icon">🗑️</span>
<span class="nav-text">删除课程管理</span>
</a>
<a href="javascript:void(0)" class="nav-item" onclick="switchPage('system-notes')" id="navSystemNotes">
<span class="nav-icon">📝</span>
<span class="nav-text">系统笔记</span>
</a>
<a href="javascript:void(0)" class="nav-item" onclick="switchPage('ai-create-course')" id="navAICreateCourse">
<span class="nav-icon"></span>
<span class="nav-text">AI 创建课程</span>
</a>
<a href="javascript:void(0)" class="nav-item" onclick="switchPage('batch-create')" id="navBatchCreate">
<span class="nav-icon">🚀</span>
<span class="nav-text">批量生成</span>
</a>
<a href="javascript:void(0)" class="nav-item" onclick="switchPage('call-records')" id="navCallRecords">
<span class="nav-icon">📋</span>
<span class="nav-text">调用记录</span>
</a>
<a href="javascript:void(0)" class="nav-item" onclick="switchPage('prompt-v3')" id="navPromptV3">
<span class="nav-icon">📝</span>
<span class="nav-text">Prompt 管理 V3.0</span>
</a>
<a href="operational-banners.html" class="nav-item" target="_blank" rel="noopener">
<span class="nav-icon">📌</span>
<span class="nav-text">运营位管理</span>
</a>
</nav>
</div>
<!-- 右侧内容区 -->
<div class="main-content">
<div id="loadingIndicator" style="display: none; text-align: center; padding: 60px;">
<div class="spinner"></div>
<p style="margin-top: 20px; color: #666;">加载中...</p>
</div>
<div id="appContent"></div>
</div>
</div>
</div>
</div>
</div>
<script>
// API 配置(自动检测环境,不写死域名,换域无需改代码)
// 本地localhost:3000否则使用当前访问的 origin与 Nginx 同域即可)
let API_BASE;
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
API_BASE = 'http://localhost:3000';
} else {
API_BASE = window.location.origin; // 与管理后台同域,换域名后自动适配
}
console.log('✅ API_BASE:', API_BASE, '(按当前访问域名)');
// ✅ 工具函数处理图片URL如果是相对路径自动加上API_BASE前缀
function getImageUrl(imageUrl) {
if (!imageUrl) return '';
// 如果已经是完整URL以http://或https://开头),直接返回
if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {
return imageUrl;
}
// 如果是相对路径加上API_BASE前缀
if (imageUrl.startsWith('/')) {
return `${API_BASE}${imageUrl}`;
} else {
return `${API_BASE}/${imageUrl}`;
}
}
// ✅ 调试添加日志以确认图片URL处理是否正确
function debugImageUrl(originalUrl, processedUrl, context) {
if (originalUrl) {
console.log(`[图片URL处理] ${context}:`, {
原始URL: originalUrl,
处理后URL: processedUrl,
原始是否完整URL: originalUrl.startsWith('http://') || originalUrl.startsWith('https://'),
处理后是否完整URL: processedUrl.startsWith('http://') || processedUrl.startsWith('https://')
});
}
}
// Token 管理
function getToken() {
return localStorage.getItem('admin_token');
}
function setToken(token) {
localStorage.setItem('admin_token', token);
}
function removeToken() {
localStorage.removeItem('admin_token');
}
// 显示消息
function showMessage(elementId, message, type = 'success') {
const element = document.getElementById(elementId);
if (!element) return;
element.className = `message ${type}`;
element.textContent = message;
element.style.display = 'block';
// 自动滚动到消息位置
element.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
setTimeout(() => {
element.style.display = 'none';
}, 5000);
}
// API 请求封装(带错误处理)
async function apiRequest(url, options = {}) {
// 同域时:若当前页为 HTTPS 而 URL 为 HTTP则改为 HTTPS避免混合内容
if (window.location.protocol === 'https:' && url.startsWith('http://') && url.indexOf(window.location.hostname) !== -1) {
url = url.replace(/^http:\/\//, 'https://');
}
const token = getToken();
const defaultHeaders = {};
// 只有 POST/PUT/PATCH 请求才需要 Content-Type
const method = (options.method || 'GET').toUpperCase();
if (['POST', 'PUT', 'PATCH'].includes(method)) {
defaultHeaders['Content-Type'] = 'application/json';
}
if (token) {
defaultHeaders['Authorization'] = `Bearer ${token}`;
}
const config = {
...options,
headers: {
...defaultHeaders,
...options.headers
}
};
// 创建超时控制器
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000); // 30秒超时
try {
// 对于 GET 请求,使用更简单的配置
const fetchOptions = {
method: config.method || 'GET',
headers: config.headers,
signal: controller.signal
};
// 只有非 GET 请求才添加 body
if (config.body && method !== 'GET') {
fetchOptions.body = config.body;
}
console.log('发送请求:', url, fetchOptions);
const response = await fetch(url, fetchOptions);
clearTimeout(timeoutId);
console.log('收到响应:', {
status: response.status,
statusText: response.statusText,
ok: response.ok,
headers: Object.fromEntries(response.headers.entries())
});
let result;
// 先读取为文本然后尝试解析JSON
const text = await response.text();
console.log('响应文本:', text.substring(0, 500)); // 只显示前500字符
try {
result = JSON.parse(text);
console.log('解析后的JSON:', result);
} catch (e) {
console.error('JSON解析失败:', e);
// 如果响应不是JSON使用文本内容
throw new Error(`服务器响应错误: ${text || response.statusText}`);
}
// Token 过期处理
if (response.status === 401) {
removeToken();
showLoginPage();
throw new Error('登录已过期,请重新登录');
}
return { response, result };
} catch (error) {
clearTimeout(timeoutId);
console.error('API 请求错误详情:', {
name: error.name,
message: error.message,
stack: error.stack
});
if (error.name === 'AbortError') {
throw new Error('请求超时,请检查网络连接');
}
if (error.message.includes('登录已过期')) {
throw error;
}
// 处理连接重置错误
if (error.message.includes('ERR_CONNECTION_RESET') ||
error.message.includes('Failed to fetch') ||
error.message.includes('NetworkError') ||
error.message.includes('Network request failed')) {
throw new Error('网络连接失败,请检查:\n1. 服务器是否正常运行\n2. 网络连接是否正常\n3. 防火墙设置');
}
throw new Error(`网络错误:${error.message}`);
}
}
// 发送验证码
async function sendCode() {
const phoneInput = document.getElementById('phone');
if (!phoneInput) {
console.error('手机号输入框未找到');
return;
}
const phone = phoneInput.value.trim();
if (!phone || !/^1[3-9]\d{9}$/.test(phone)) {
showMessage('loginMessage', '请输入正确的手机号', 'error');
return;
}
const btn = document.getElementById('sendCodeBtn');
if (!btn) {
console.error('发送验证码按钮未找到');
return;
}
btn.disabled = true;
btn.textContent = '发送中...';
try {
const { response, result } = await apiRequest(`${API_BASE}/api/auth/send-code`, {
method: 'POST',
body: JSON.stringify({ phone })
});
if (response.ok) {
showMessage('loginMessage', '验证码已发送,请查收', 'success');
// 倒计时
let countdown = 60;
const timer = setInterval(() => {
btn.textContent = `${countdown}秒后重试`;
countdown--;
if (countdown < 0) {
clearInterval(timer);
btn.disabled = false;
btn.textContent = '获取验证码';
}
}, 1000);
} else {
showMessage('loginMessage', result.error?.message || '发送失败', 'error');
btn.disabled = false;
btn.textContent = '获取验证码';
}
} catch (error) {
console.error('发送验证码错误:', error);
showMessage('loginMessage', error.message, 'error');
if (btn) {
btn.disabled = false;
btn.textContent = '获取验证码';
}
}
}
// 切换登录方式(确保全局可访问)
window.switchLoginMode = function(mode) {
const passwordTab = document.getElementById('passwordLoginTab');
const codeTab = document.getElementById('codeLoginTab');
const passwordForm = document.getElementById('passwordLoginForm');
const codeForm = document.getElementById('codeLoginForm');
if (mode === 'password') {
// 激活密码登录标签
passwordTab.style.background = 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)';
passwordTab.style.color = 'white';
passwordTab.style.borderColor = '#667eea';
codeTab.style.background = 'white';
codeTab.style.color = '#666';
codeTab.style.borderColor = '#e0e0e0';
// 显示密码表单,隐藏验证码表单
passwordForm.style.display = 'block';
codeForm.style.display = 'none';
} else {
// 激活验证码登录标签
codeTab.style.background = 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)';
codeTab.style.color = 'white';
codeTab.style.borderColor = '#667eea';
passwordTab.style.background = 'white';
passwordTab.style.color = '#666';
passwordTab.style.borderColor = '#e0e0e0';
// 显示验证码表单,隐藏密码表单
passwordForm.style.display = 'none';
codeForm.style.display = 'block';
}
}
// 管理员密码登录(确保全局可访问)
window.adminLogin = async function() {
try {
const passwordInput = document.getElementById('adminPassword');
if (!passwordInput) {
console.error('密码输入框未找到');
showMessage('loginMessage', '页面加载错误,请刷新重试', 'error');
return;
}
const password = passwordInput.value.trim();
if (!password) {
showMessage('loginMessage', '请输入密码', 'error');
return;
}
if (password !== '123456') {
showMessage('loginMessage', '密码错误', 'error');
return;
}
const loginBtn = document.querySelector('#passwordLoginForm .btn-primary');
if (!loginBtn) {
console.error('登录按钮未找到');
return;
}
loginBtn.disabled = true;
loginBtn.textContent = '登录中...';
// 调用测试token接口获取token
// 调用测试token接口获取token
const { response, result } = await apiRequest(`${API_BASE}/api/auth/test-token`, {
method: 'GET'
});
if (response.ok && result.success) {
setToken(result.data.token);
showMainApp();
} else {
showMessage('loginMessage', result.error?.message || '登录失败', 'error');
loginBtn.disabled = false;
loginBtn.textContent = '登录';
}
} catch (error) {
console.error('管理员登录错误:', error);
showMessage('loginMessage', error.message || '登录失败,请重试', 'error');
const loginBtn = document.querySelector('#passwordLoginForm .btn-primary');
if (loginBtn) {
loginBtn.disabled = false;
loginBtn.textContent = '登录';
}
}
};
// 验证码登录
async function login() {
const phoneInput = document.getElementById('phone');
const codeInput = document.getElementById('code');
if (!phoneInput || !codeInput) {
console.error('登录输入框未找到');
return;
}
const phone = phoneInput.value.trim();
const code = codeInput.value.trim();
if (!phone || !code) {
showMessage('loginMessage', '请填写手机号和验证码', 'error');
return;
}
const loginBtn = document.querySelector('#codeLoginForm .btn-primary');
if (!loginBtn) {
console.error('登录按钮未找到');
return;
}
loginBtn.disabled = true;
loginBtn.textContent = '登录中...';
try {
const { response, result } = await apiRequest(`${API_BASE}/api/auth/login`, {
method: 'POST',
body: JSON.stringify({ phone, code })
});
if (response.ok && result.success) {
setToken(result.data.token);
showMainApp();
} else {
showMessage('loginMessage', result.error?.message || '登录失败', 'error');
loginBtn.disabled = false;
loginBtn.textContent = '登录';
}
} catch (error) {
console.error('登录错误:', error);
showMessage('loginMessage', error.message, 'error');
if (loginBtn) {
loginBtn.disabled = false;
loginBtn.textContent = '登录';
}
}
}
// 显示主应用
function showMainApp() {
try {
const loginPage = document.getElementById('loginPage');
const mainApp = document.getElementById('mainApp');
if (!loginPage || !mainApp) {
console.error('页面元素未找到');
return;
}
loginPage.classList.add('hidden');
mainApp.classList.remove('hidden');
// 后续会在这里加载课程列表
loadCourseList();
} catch (error) {
console.error('显示主应用错误:', error);
}
}
// 检查登录状态
async function checkAuth() {
try {
const token = getToken();
const loginPage = document.getElementById('loginPage');
const mainApp = document.getElementById('mainApp');
if (!loginPage || !mainApp) {
console.error('页面元素未找到');
return;
}
if (token) {
// ✅ 验证token是否有效支持正常登录token和test-token
// 使用获取课程列表API来验证token这个API需要认证
try {
const { response, result } = await apiRequest(`${API_BASE}/api/courses?includeDrafts=true`, {
method: 'GET'
});
if (response.ok) {
// Token有效显示主应用
showMainApp();
} else if (response.status === 401) {
// Token无效或过期清除并显示登录页
removeToken();
loginPage.classList.remove('hidden');
mainApp.classList.add('hidden');
} else {
// 其他错误如403权限问题也显示主应用可能是权限问题但不影响访问系统笔记管理
showMainApp();
}
} catch (error) {
// 如果请求失败尝试直接使用token向后兼容
console.log('Token验证失败尝试直接使用token:', error.message);
showMainApp();
}
} else {
loginPage.classList.remove('hidden');
mainApp.classList.add('hidden');
}
} catch (error) {
console.error('检查登录状态错误:', error);
const loginPage = document.getElementById('loginPage');
if (loginPage) {
loginPage.classList.remove('hidden');
}
}
}
// 加载课程列表
async function loadCourseList() {
const token = getToken();
if (!token) {
showLoginPage();
return;
}
const loadingIndicator = document.getElementById('loadingIndicator');
const appContent = document.getElementById('appContent');
loadingIndicator.style.display = 'block';
appContent.innerHTML = '';
try {
// 管理后台需要看到所有课程(包括草稿),所以添加 includeDrafts=true 参数
try {
const { response, result } = await apiRequest(`${API_BASE}/api/courses?includeDrafts=true`, {
method: 'GET'
});
console.log('loadCourseList 响应:', { status: response.status, ok: response.ok, result });
if (response.ok && result.success) {
loadingIndicator.style.display = 'none';
renderCourseList(result.data.courses);
} else {
loadingIndicator.style.display = 'none';
console.error('loadCourseList 错误:', result);
appContent.innerHTML = `<div class="message error">加载失败:${result.error?.message || '未知错误'}</div>`;
}
} catch (error) {
loadingIndicator.style.display = 'none';
console.error('loadCourseList 异常:', error);
appContent.innerHTML = `<div class="message error">加载失败:${error.message}</div>`;
}
} catch (error) {
loadingIndicator.style.display = 'none';
if (error.message.includes('登录已过期')) {
return; // 已跳转登录页
}
appContent.innerHTML = `<div class="message error">${error.message}</div>`;
}
}
// 渲染课程列表
function renderCourseList(courses) {
const appContent = document.getElementById('appContent');
if (!courses || courses.length === 0) {
appContent.innerHTML = `
<div class="empty-state">
<p>📭 暂无课程</p>
</div>
`;
return;
}
// 生成占位图片(在函数外部定义,避免模板字符串问题)
const placeholderImage = getPlaceholderImage('无封面');
const html = `
<div class="course-list-header">
<h2>课程列表 (${courses.length})</h2>
<div style="display: flex; gap: 12px;">
<button class="btn-primary" onclick="createNewCourse()" style="background: #10b981; border: none; padding: 10px 20px; border-radius: 6px; color: white; cursor: pointer; font-weight: 500;">+ 新增课程</button>
<button class="logout-btn" onclick="logout()">退出登录</button>
</div>
</div>
<div class="course-grid">
${courses.map(course => {
const coverImage = course.cover_image ? getImageUrl(course.cover_image) : placeholderImage;
// ✅ 调试记录图片URL处理强制输出帮助排查问题
if (course.cover_image) {
console.log(`[图片URL调试] 课程 ${course.id}:`, {
原始URL: course.cover_image,
处理后URL: coverImage,
原始是否完整: course.cover_image.startsWith('http'),
处理后是否完整: coverImage.startsWith('http')
});
debugImageUrl(course.cover_image, coverImage, `课程封面-${course.id}`);
}
// ✅ 优先使用 publish_status如果没有则使用 status最后默认为 draft
const currentStatus = course.publish_status || course.status || 'draft';
const isPublished = currentStatus === 'published';
const isTestPublished = currentStatus === 'test_published';
const isDraft = currentStatus === 'draft';
// 状态显示文本
let statusText = '📝 草稿';
if (isPublished) {
statusText = '✅ 已发布';
} else if (isTestPublished) {
statusText = '🧪 测试发布';
} else {
statusText = '📝 草稿';
}
// 🔍 调试日志(可以在浏览器控制台查看)
console.log('课程状态:', {
courseId: course.id,
courseTitle: course.title,
publish_status: course.publish_status,
status: course.status,
currentStatus: currentStatus,
isPublished: isPublished,
isTestPublished: isTestPublished,
isDraft: isDraft
});
return `
<div class="course-card">
<div class="course-card-content" onclick="editCourse('${course.id}')">
<img src="${coverImage}"
alt="${escapeHtml(course.title)}"
class="course-cover"
data-original-url="${course.cover_image || ''}"
data-processed-url="${coverImage}"
onerror="console.error('[图片加载失败]', '当前src:', this.src, '原始URL:', this.dataset.originalUrl, '处理后URL:', this.dataset.processedUrl); this.onerror=null; this.src='${placeholderImage}'">
<div class="course-info">
<div class="course-title">${escapeHtml(course.title)}</div>
<div class="course-id-display">ID: ${escapeHtml(course.id)}</div>
<div class="course-meta">
<span class="course-type ${course.type}">${course.type === 'system' ? '体系课' : '小节课'}</span>
<span class="course-status ${currentStatus}">${statusText}</span>
<span>${course.total_nodes || 0} 个节点</span>
${course.min_app_version ? `<span style="color: #667eea; font-size: 12px;">📱 ≥${escapeHtml(course.min_app_version)}</span>` : ''}
</div>
</div>
</div>
<div class="course-card-actions" onclick="event.stopPropagation();">
${isDraft ? `
<!-- 草稿状态:显示发布按钮,点击后弹出菜单 -->
<div class="publish-menu-container">
<button class="btn-status-toggle btn-publish"
onclick="showPublishMenu('${course.id}', event)"
title="发布课程">
发布
</button>
<div class="publish-menu" id="publish-menu-${course.id}" style="display: none;">
<button class="publish-menu-item"
onclick="togglePublishStatus('${course.id}', 'published', event)">
✅ 正式发布
</button>
<button class="publish-menu-item"
onclick="togglePublishStatus('${course.id}', 'test_published', event)">
🧪 测试发布
</button>
</div>
</div>
` : ''}
${(isPublished || isTestPublished) ? `
<!-- 已发布/测试发布状态:只显示取消发布按钮 -->
<button class="btn-status-toggle btn-unpublish"
onclick="togglePublishStatus('${course.id}', 'draft', event)"
title="取消发布,转为草稿">
取消发布
</button>
` : ''}
<button class="btn-status-toggle btn-delete-course"
onclick="deleteCourseFromList('${course.id}', event)"
title="删除课程">
删除课程
</button>
</div>
</div>
`;
}).join('')}
</div>
`;
appContent.innerHTML = html;
}
// 删除课程(全局函数,带二次确认)
window.deleteCourseFromList = async function(courseId, event) {
if (event) {
event.stopPropagation();
}
if (!confirm('确定要删除这个课程吗?删除后课程将移至删除课程管理,可以恢复。')) {
return;
}
// 二次确认
if (!confirm('请再次确认:确定要删除这个课程吗?')) {
return;
}
try {
const { response, result } = await apiRequest(`${API_BASE}/api/courses/${courseId}`, {
method: 'DELETE'
});
if (response.ok && result.success) {
alert('课程已删除!');
// 重新加载课程列表
await loadCourseList();
} else {
alert('删除失败:' + (result.error?.message || '未知错误'));
}
} catch (error) {
alert('删除失败:' + error.message);
}
};
// 创建新课程(全局函数)
window.createNewCourse = async function() {
const title = prompt('请输入课程标题:');
if (!title || !title.trim()) {
return;
}
const type = confirm('选择课程类型:\n确定 = 体系课\n取消 = 小节课') ? 'system' : 'single';
try {
const { response, result } = await apiRequest(`${API_BASE}/api/courses`, {
method: 'POST',
body: JSON.stringify({
title: title.trim(),
type: type,
status: 'draft' // 新建课程默认为草稿
})
});
if (response.ok && result.success) {
alert('课程创建成功!');
// 重新加载课程列表
await loadCourseList();
} else {
alert('创建失败:' + (result.error?.message || '未知错误'));
}
} catch (error) {
alert('创建失败:' + error.message);
}
};
// 显示发布菜单(全局函数)
window.showPublishMenu = function(courseId, event) {
if (event) {
event.stopPropagation();
}
// 关闭其他所有菜单
document.querySelectorAll('.publish-menu').forEach(menu => {
if (menu.id !== `publish-menu-${courseId}`) {
menu.style.display = 'none';
}
});
// 切换当前菜单显示状态
const menu = document.getElementById(`publish-menu-${courseId}`);
if (menu) {
const isVisible = menu.style.display === 'block';
menu.style.display = isVisible ? 'none' : 'block';
}
};
// 点击外部关闭所有菜单
document.addEventListener('click', function(event) {
if (!event.target.closest('.publish-menu-container')) {
document.querySelectorAll('.publish-menu').forEach(menu => {
menu.style.display = 'none';
});
}
});
// 切换发布状态(全局函数,带二次确认)
window.togglePublishStatus = async function(courseId, newStatus, event) {
if (event) {
event.stopPropagation();
}
// 关闭菜单
const menu = document.getElementById(`publish-menu-${courseId}`);
if (menu) {
menu.style.display = 'none';
}
// 根据新状态生成确认消息
let confirmMessage = '';
let secondConfirmMessage = '';
let successMessage = '';
if (newStatus === 'published') {
confirmMessage = '确定要正式发布这个课程吗?发布后课程将对所有用户可见。';
secondConfirmMessage = '请再次确认:确定要正式发布这个课程吗?';
successMessage = '课程已正式发布!';
} else if (newStatus === 'test_published') {
confirmMessage = '确定要测试发布这个课程吗?测试发布后课程仅对开发版本可见,审核版本看不到。';
secondConfirmMessage = '请再次确认:确定要测试发布这个课程吗?';
successMessage = '课程已测试发布!';
} else {
confirmMessage = '确定要取消发布这个课程吗?取消后课程将变为草稿状态,用户将无法看到。';
secondConfirmMessage = '请再次确认:确定要取消发布这个课程吗?';
successMessage = '课程已取消发布,转为草稿状态!';
}
if (!confirm(confirmMessage)) {
return;
}
// 二次确认
if (!confirm(secondConfirmMessage)) {
return;
}
try {
const { response, result } = await apiRequest(`${API_BASE}/api/courses/${courseId}/publish`, {
method: 'PATCH',
body: JSON.stringify({ status: newStatus })
});
if (response.ok && result.success) {
alert(successMessage);
// 重新加载课程列表
await loadCourseList();
} else {
alert('操作失败:' + (result.error?.message || '未知错误'));
}
} catch (error) {
alert('操作失败:' + error.message);
}
};
// 当前页面状态
let currentPage = 'courses'; // 'courses' | 'deleted'
// 切换页面(全局函数)
window.switchPage = function(page) {
currentPage = page;
// 更新导航栏状态
document.querySelectorAll('.nav-item').forEach(item => {
item.classList.remove('active');
});
if (page === 'courses') {
document.getElementById('navCourses')?.classList.add('active');
loadCourseList();
} else if (page === 'deleted') {
document.getElementById('navDeleted')?.classList.add('active');
loadDeletedCourses();
} else if (page === 'system-notes') {
document.getElementById('navSystemNotes')?.classList.add('active');
loadSystemNotesPage();
} else if (page === 'ai-create-course') {
document.getElementById('navAICreateCourse')?.classList.add('active');
loadAICreateCoursePage();
} else if (page === 'batch-create') {
document.getElementById('navBatchCreate')?.classList.add('active');
loadBatchCreatePage();
} else if (page === 'call-records') {
document.getElementById('navCallRecords')?.classList.add('active');
loadCallRecordsPage();
} else if (page === 'prompt-v3') {
document.getElementById('navPromptV3')?.classList.add('active');
loadPromptV3Page();
}
};
// 加载调用记录页任务ID、课程ID、课程名称、创建/更新时间、状态、内容)
async function loadCallRecordsPage() {
const loadingIndicator = document.getElementById('loadingIndicator');
const appContent = document.getElementById('appContent');
loadingIndicator.style.display = 'block';
appContent.innerHTML = '';
try {
const url = `${API_BASE}/api/admin/generation-tasks?page=1&pageSize=50`;
const { response, result } = await apiRequest(url, { method: 'GET' });
loadingIndicator.style.display = 'none';
if (response.ok && result.success) {
renderCallRecordsList(result.data);
} else {
appContent.innerHTML = '<div class="message error">加载调用记录失败:' + (result.error?.message || result.message || '未知错误') + '</div>';
}
} catch (err) {
loadingIndicator.style.display = 'none';
if (err.message && err.message.includes('登录已过期')) return;
appContent.innerHTML = '<div class="message error">' + (err.message || '加载失败') + '</div>';
}
}
function renderCallRecordsList(data) {
const appContent = document.getElementById('appContent');
const list = data.list || [];
const pagination = data.pagination || {};
const total = pagination.total ?? list.length;
const tableRows = list.map(function(row) {
const createdAt = row.createdAt ? new Date(row.createdAt).toLocaleString('zh-CN') : '-';
const updatedAt = row.updatedAt ? new Date(row.updatedAt).toLocaleString('zh-CN') : '-';
return '<tr>' +
'<td style="font-size:11px;word-break:break-all;">' + escapeHtml(row.taskId) + '</td>' +
'<td style="font-size:11px;word-break:break-all;">' + escapeHtml(row.courseId) + '</td>' +
'<td>' + escapeHtml(row.courseTitle || '-') + '</td>' +
'<td style="white-space:nowrap;">' + createdAt + '</td>' +
'<td style="white-space:nowrap;">' + updatedAt + '</td>' +
'<td><span class="course-status ' + (row.status === 'completed' ? '' : 'draft') + '">' + escapeHtml(row.status) + '</span></td>' +
'<td><button type="button" class="btn-secondary" style="padding:6px 12px;font-size:12px;" data-task-id="' + escapeHtml(row.taskId) + '" onclick="showCallRecordDetailModal(this.getAttribute(\'data-task-id\'))">详情</button></td>' +
'</tr>';
}).join('');
appContent.innerHTML =
'<div class="course-list-header"><h2>调用记录</h2><p style="color:#666;margin-top:8px;">任务ID、课程ID、课程名称、创建时间、更新时间、状态点击「详情」查看完整创建过程、输入给AI的、AI返回的内容</p></div>' +
'<table class="course-table" style="width:100%;border-collapse:collapse;margin-top:16px;">' +
'<thead><tr><th>任务ID</th><th>课程ID</th><th>课程名称</th><th>创建时间</th><th>更新时间</th><th>状态</th><th>操作</th></tr></thead>' +
'<tbody>' + tableRows + '</tbody></table>' +
(total > list.length ? '<p style="margin-top:12px;color:#666;">共 ' + total + ' 条,当前页 ' + list.length + ' 条</p>' : '<p style="margin-top:12px;color:#666;">共 ' + total + ' 条</p>');
}
window.showCallRecordDetailModal = async function(taskId) {
if (!taskId) return;
var overlay = document.getElementById('callRecordDetailOverlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.id = 'callRecordDetailOverlay';
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:9999;display:none;align-items:center;justify-content:center;padding:20px;box-sizing:border-box;';
overlay.onclick = function(e) { if (e.target === overlay) closeCallRecordDetailModal(); };
var box = document.createElement('div');
box.id = 'callRecordDetailBox';
box.style.cssText = 'background:#fff;border-radius:12px;max-width:900px;width:100%;max-height:90vh;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 8px 32px rgba(0,0,0,0.2);';
box.onclick = function(e) { e.stopPropagation(); };
var head = document.createElement('div');
head.style.cssText = 'padding:16px 20px;border-bottom:1px solid #eee;display:flex;justify-content:space-between;align-items:center;flex-shrink:0;';
head.innerHTML = '<h3 style="margin:0;font-size:18px;">任务详情</h3><button type="button" class="btn-secondary" onclick="closeCallRecordDetailModal()" style="padding:6px 14px;">关闭</button>';
var body = document.createElement('div');
body.id = 'callRecordDetailBody';
body.style.cssText = 'padding:20px;overflow:auto;flex:1;font-size:13px;';
body.innerHTML = '<p style="color:#666;">加载中…</p>';
box.appendChild(head);
box.appendChild(body);
overlay.appendChild(box);
document.body.appendChild(overlay);
}
document.getElementById('callRecordDetailBody').innerHTML = '<p style="color:#666;">加载中…</p>';
overlay.style.display = 'flex';
try {
var res = await fetch(API_BASE + '/api/admin/generation-tasks/' + encodeURIComponent(taskId), { method: 'GET', headers: getToken() ? { 'Authorization': 'Bearer ' + getToken() } : {} });
var result = await res.json();
if (!res.ok || !result.success) {
document.getElementById('callRecordDetailBody').innerHTML = '<p class="message error">加载失败:' + (result.error?.message || result.message || res.status) + '</p>';
return;
}
var d = result.data;
var html = '<div style="margin-bottom:20px;"><h4 style="margin:0 0 8px 0;color:#333;">创建过程</h4><table style="width:100%;border-collapse:collapse;font-size:13px;"><tr><td style="padding:6px 0;color:#666;width:120px;">任务ID</td><td style="word-break:break-all;">' + escapeHtml(d.taskId) + '</td></tr><tr><td style="padding:6px 0;color:#666;">课程ID</td><td style="word-break:break-all;">' + escapeHtml(d.courseId) + '</td></tr><tr><td style="padding:6px 0;color:#666;">课程名称</td><td>' + escapeHtml(d.courseTitle || '-') + '</td></tr><tr><td style="padding:6px 0;color:#666;">创建时间</td><td>' + escapeHtml(d.createdAt ? new Date(d.createdAt).toLocaleString('zh-CN') : '-') + '</td></tr><tr><td style="padding:6px 0;color:#666;">更新时间</td><td>' + escapeHtml(d.updatedAt ? new Date(d.updatedAt).toLocaleString('zh-CN') : '-') + '</td></tr><tr><td style="padding:6px 0;color:#666;">状态</td><td>' + escapeHtml(d.status) + '</td></tr><tr><td style="padding:6px 0;color:#666;">进度</td><td>' + (d.progress != null ? d.progress + '%' : '-') + '</td></tr><tr><td style="padding:6px 0;color:#666;">当前步骤</td><td>' + escapeHtml(d.currentStep || '-') + '</td></tr><tr><td style="padding:6px 0;color:#666;">来源类型</td><td>' + escapeHtml(d.sourceType || '-') + '</td></tr><tr><td style="padding:6px 0;color:#666;">讲解类型</td><td>' + escapeHtml(d.persona || '-') + '</td></tr><tr><td style="padding:6px 0;color:#666;">使用模型</td><td>' + escapeHtml(d.modelId || '-') + '</td></tr>' + (d.errorMessage ? '<tr><td style="padding:6px 0;color:#666;">错误信息</td><td style="color:#c00;">' + escapeHtml(d.errorMessage) + '</td></tr>' : '') + '</table></div>';
html += '<div style="margin-bottom:20px;"><h4 style="margin:0 0 8px 0;color:#333;">提示词</h4><pre style="background:#f5f5f5;padding:12px;border-radius:8px;max-height:280px;overflow:auto;font-size:12px;margin:0;white-space:pre-wrap;word-break:break-word;">' + escapeHtml(d.prompt || '(未获取到)') + '</pre></div>';
html += '<div style="margin-bottom:20px;"><h4 style="margin:0 0 8px 0;color:#333;">用户输入(主题/原文)</h4><pre style="background:#f5f5f5;padding:12px;border-radius:8px;max-height:240px;overflow:auto;font-size:12px;margin:0;white-space:pre-wrap;word-break:break-word;">' + escapeHtml(d.sourceText || '(无)') + '</pre></div>';
// AI 返回内容:优先显示 outline成功解析的 JSON否则显示 aiRawResponse原始返回
var aiContent = d.outline ? JSON.stringify(d.outline, null, 2) : (d.aiRawResponse || '(暂无,可能为旧任务或尚未写入)');
html += '<div style="margin-bottom:20px;"><h4 style="margin:0 0 8px 0;color:#333;">AI 返回的内容' + (d.outline ? '' : ' <span style="color:#c00;font-size:12px;font-weight:normal;">(原始返回,未能解析为 JSON)</span>') + '</h4><pre style="background:#f5f5f5;padding:12px;border-radius:8px;max-height:320px;overflow:auto;font-size:12px;margin:0;white-space:pre-wrap;word-break:break-word;">' + escapeHtml(aiContent) + '</pre></div>';
// 续旧课链路信息
html += '<div style="margin-bottom:20px;"><h4 style="margin:0 0 8px 0;color:#333;">续旧课信息</h4><table style="width:100%;border-collapse:collapse;font-size:13px;"><tr><td style="padding:6px 0;color:#666;width:120px;">父课程ID</td><td style="word-break:break-all;">' + escapeHtml(d.parentCourseId || '(无,非续旧课)') + '</td></tr><tr><td style="padding:6px 0;color:#666;">知识点摘要</td><td>' + (d.accumulatedSummary ? '<pre style="background:#f5f5f5;padding:8px;border-radius:6px;max-height:200px;overflow:auto;font-size:12px;margin:0;white-space:pre-wrap;word-break:break-word;">' + escapeHtml(d.accumulatedSummary) + '</pre>' : '<span style="color:#999;">(暂无摘要)</span>') + '</td></tr></table></div>';
// AI 调用记录:成功 / 失败分组展示
if (d.aiCallLogs && d.aiCallLogs.length > 0) {
var successLogs = d.aiCallLogs.filter(function(log) { return log.status === 'success'; });
var failedLogs = d.aiCallLogs.filter(function(log) { return log.status === 'failed'; });
// 成功的调用记录
html += '<div style="margin-bottom:20px;"><h4 style="margin:0 0 8px 0;color:#333;">AI 调用成功 <span style="color:#090;font-size:12px;font-weight:normal;">(' + successLogs.length + ' 条)</span></h4>';
if (successLogs.length > 0) {
html += '<div style="background:#f0fff0;border:1px solid #9c9;border-radius:8px;padding:12px;max-height:200px;overflow:auto;">';
successLogs.forEach(function(log) {
html += '<div style="padding:6px 0;border-bottom:1px solid #cec;font-size:12px;display:flex;justify-content:space-between;align-items:center;">';
html += '<div>';
html += '<span style="color:#666;">分块 ' + (log.chunkIndex != null ? log.chunkIndex : '-') + '</span>';
html += ' | <span style="color:#090;">成功</span>';
html += ' | <span style="color:#999;">' + (log.durationMs ? (log.durationMs / 1000).toFixed(1) + 's' : '-') + '</span>';
html += ' | <span style="color:#999;">' + new Date(log.createdAt).toLocaleString('zh-CN') + '</span>';
html += '</div>';
if (log.hasResponse) {
html += '<button onclick="showAiCallLogDetail(\'' + log.id + '\')" style="padding:2px 8px;font-size:11px;background:#fff;border:1px solid #9c9;border-radius:4px;color:#090;cursor:pointer;">查看详情</button>';
}
html += '</div>';
});
html += '</div>';
} else {
html += '<p style="color:#999;font-size:12px;margin:0;">(无成功记录)</p>';
}
html += '</div>';
// 失败的调用记录
html += '<div style="margin-bottom:20px;"><h4 style="margin:0 0 8px 0;color:#333;">AI 调用失败 <span style="color:#c00;font-size:12px;font-weight:normal;">(' + failedLogs.length + ' 条)</span></h4>';
if (failedLogs.length > 0) {
html += '<div style="background:#fff5f5;border:1px solid #c99;border-radius:8px;padding:12px;max-height:300px;overflow:auto;">';
failedLogs.forEach(function(log) {
html += '<div style="padding:8px 0;border-bottom:1px solid #ecc;font-size:12px;">';
html += '<div style="display:flex;justify-content:space-between;align-items:center;">';
html += '<div><span style="color:#666;">分块 ' + (log.chunkIndex != null ? log.chunkIndex : '-') + '</span>';
html += ' | <span style="color:#c00;">失败</span>';
html += ' | <span style="color:#999;">' + (log.durationMs ? (log.durationMs / 1000).toFixed(1) + 's' : '-') + '</span>';
html += ' | <span style="color:#999;">' + new Date(log.createdAt).toLocaleString('zh-CN') + '</span></div>';
if (log.hasResponse) {
html += '<button onclick="showAiCallLogDetail(\'' + log.id + '\')" style="padding:2px 8px;font-size:11px;background:#fff;border:1px solid #c99;border-radius:4px;color:#c00;cursor:pointer;">查看详情</button>';
}
html += '</div>';
if (log.errorMessage) {
html += '<div style="margin-top:4px;padding:6px;background:#fee;border-radius:4px;color:#900;word-break:break-word;">' + escapeHtml(log.errorMessage) + '</div>';
}
html += '</div>';
});
html += '</div>';
} else {
html += '<p style="color:#999;font-size:12px;margin:0;">(无失败记录)</p>';
}
html += '</div>';
} else {
html += '<div style="margin-bottom:20px;"><h4 style="margin:0 0 8px 0;color:#333;">AI 调用记录</h4><p style="color:#999;font-size:12px;margin:0;">(暂无调用记录,可能为旧任务)</p></div>';
}
document.getElementById('callRecordDetailBody').innerHTML = html;
} catch (err) {
document.getElementById('callRecordDetailBody').innerHTML = '<p class="message error">' + escapeHtml(err.message || '请求失败') + '</p>';
}
};
window.closeCallRecordDetailModal = function() {
var overlay = document.getElementById('callRecordDetailOverlay');
if (overlay) overlay.style.display = 'none';
};
// 显示 AI 调用日志详情(原始返回内容)
window.showAiCallLogDetail = async function(logId) {
// 创建弹窗
var overlay = document.getElementById('aiCallLogDetailOverlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.id = 'aiCallLogDetailOverlay';
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:10001;';
overlay.onclick = function(e) { if (e.target === overlay) closeAiCallLogDetailModal(); };
var box = document.createElement('div');
box.style.cssText = 'background:#fff;border-radius:12px;padding:24px;max-width:900px;width:90%;max-height:85vh;overflow:auto;position:relative;';
box.innerHTML = '<button onclick="closeAiCallLogDetailModal()" style="position:absolute;top:12px;right:12px;background:none;border:none;font-size:20px;cursor:pointer;color:#999;">×</button><h3 style="margin:0 0 16px 0;font-size:18px;">AI 调用日志详情</h3><div id="aiCallLogDetailBody"></div>';
overlay.appendChild(box);
document.body.appendChild(overlay);
}
document.getElementById('aiCallLogDetailBody').innerHTML = '<p style="color:#666;">加载中…</p>';
overlay.style.display = 'flex';
try {
var res = await fetch(API_BASE + '/api/admin/generation-tasks/ai-call-logs/' + encodeURIComponent(logId), { method: 'GET', headers: getToken() ? { 'Authorization': 'Bearer ' + getToken() } : {} });
var result = await res.json();
if (!res.ok || !result.success) {
document.getElementById('aiCallLogDetailBody').innerHTML = '<p class="message error">加载失败:' + (result.error?.message || result.message || res.status) + '</p>';
return;
}
var d = result.data;
var html = '<table style="width:100%;border-collapse:collapse;font-size:13px;margin-bottom:16px;">';
html += '<tr><td style="padding:6px 0;color:#666;width:100px;">日志 ID</td><td style="word-break:break-all;">' + escapeHtml(d.id) + '</td></tr>';
html += '<tr><td style="padding:6px 0;color:#666;">分块编号</td><td>' + (d.chunkIndex != null ? d.chunkIndex : '-') + '</td></tr>';
html += '<tr><td style="padding:6px 0;color:#666;">状态</td><td style="color:' + (d.status === 'success' ? '#090' : '#c00') + ';">' + (d.status === 'success' ? '成功' : '失败') + '</td></tr>';
html += '<tr><td style="padding:6px 0;color:#666;">耗时</td><td>' + (d.durationMs ? (d.durationMs / 1000).toFixed(1) + ' 秒' : '-') + '</td></tr>';
html += '<tr><td style="padding:6px 0;color:#666;">时间</td><td>' + new Date(d.createdAt).toLocaleString('zh-CN') + '</td></tr>';
if (d.errorMessage) {
html += '<tr><td style="padding:6px 0;color:#666;">错误信息</td><td style="color:#c00;">' + escapeHtml(d.errorMessage) + '</td></tr>';
}
html += '</table>';
// 发送给 AI 的内容(提示词 + 用户输入)
html += '<div style="margin-bottom:16px;"><h4 style="margin:0 0 8px 0;color:#333;">发送给 AI 的内容</h4>';
if (d.promptFull) {
html += '<pre style="background:#e8f4f8;padding:12px;border-radius:8px;max-height:300px;overflow:auto;font-size:12px;margin:0;white-space:pre-wrap;word-break:break-word;border:1px solid #b8d4e3;">' + escapeHtml(d.promptFull) + '</pre>';
} else if (d.promptPreview) {
html += '<pre style="background:#e8f4f8;padding:12px;border-radius:8px;max-height:300px;overflow:auto;font-size:12px;margin:0;white-space:pre-wrap;word-break:break-word;border:1px solid #b8d4e3;">' + escapeHtml(d.promptPreview) + ' <span style="color:#999;">(预览)</span></pre>';
} else {
html += '<p style="color:#999;font-size:12px;margin:0;">(无提示词内容)</p>';
}
html += '</div>';
// AI 原始返回内容
html += '<div style="margin-bottom:16px;"><h4 style="margin:0 0 8px 0;color:#333;">AI 原始返回内容</h4>';
if (d.responseFull) {
html += '<pre style="background:#f5f5f5;padding:12px;border-radius:8px;max-height:400px;overflow:auto;font-size:12px;margin:0;white-space:pre-wrap;word-break:break-word;">' + escapeHtml(d.responseFull) + '</pre>';
} else if (d.responsePreview) {
html += '<pre style="background:#f5f5f5;padding:12px;border-radius:8px;max-height:400px;overflow:auto;font-size:12px;margin:0;white-space:pre-wrap;word-break:break-word;">' + escapeHtml(d.responsePreview) + ' <span style="color:#999;">(预览)</span></pre>';
} else {
html += '<p style="color:#999;font-size:12px;margin:0;">(无返回内容)</p>';
}
html += '</div>';
document.getElementById('aiCallLogDetailBody').innerHTML = html;
} catch (err) {
document.getElementById('aiCallLogDetailBody').innerHTML = '<p class="message error">' + escapeHtml(err.message || '请求失败') + '</p>';
}
};
window.closeAiCallLogDetailModal = function() {
var overlay = document.getElementById('aiCallLogDetailOverlay');
if (overlay) overlay.style.display = 'none';
};
// 加载已删除的课程列表
async function loadDeletedCourses() {
const token = getToken();
if (!token) {
showLoginPage();
return;
}
const loadingIndicator = document.getElementById('loadingIndicator');
const appContent = document.getElementById('appContent');
loadingIndicator.style.display = 'block';
appContent.innerHTML = '';
try {
const { response, result } = await apiRequest(`${API_BASE}/api/courses/deleted`, {
method: 'GET'
});
if (response.ok && result.success) {
loadingIndicator.style.display = 'none';
renderDeletedCourseList(result.data.courses);
} else {
loadingIndicator.style.display = 'none';
appContent.innerHTML = `<div class="message error">加载失败:${result.error?.message || '未知错误'}</div>`;
}
} catch (error) {
loadingIndicator.style.display = 'none';
if (error.message.includes('登录已过期')) {
return; // 已跳转登录页
}
appContent.innerHTML = `<div class="message error">${error.message}</div>`;
}
}
// 渲染已删除的课程列表
function renderDeletedCourseList(courses) {
const appContent = document.getElementById('appContent');
if (!courses || courses.length === 0) {
appContent.innerHTML = `
<div class="empty-state">
<p>📭 暂无已删除的课程</p>
</div>
`;
return;
}
const placeholderImage = getPlaceholderImage('无封面');
const html = `
<div class="course-list-header">
<h2>已删除的课程 (${courses.length})</h2>
<button class="back-btn" onclick="switchPage('courses')">← 返回课程管理</button>
</div>
<div class="course-grid">
${courses.map(course => {
const coverImage = course.cover_image ? getImageUrl(course.cover_image) : placeholderImage;
const deletedDate = course.deleted_at ? new Date(course.deleted_at).toLocaleString('zh-CN') : '未知时间';
return `
<div class="course-card">
<div class="course-card-content" onclick="viewDeletedCourse('${course.id}')">
<img src="${coverImage}"
alt="${escapeHtml(course.title)}"
class="course-cover"
data-original-url="${course.cover_image || ''}"
data-processed-url="${coverImage}"
onerror="console.error('[图片加载失败]', '当前src:', this.src, '原始URL:', this.dataset.originalUrl, '处理后URL:', this.dataset.processedUrl); this.onerror=null; this.src='${placeholderImage}'">
<div class="course-info">
<div class="course-title">${escapeHtml(course.title)}</div>
<div class="course-id-display">ID: ${escapeHtml(course.id)}</div>
<div class="course-meta">
<span class="course-type ${course.type}">${course.type === 'system' ? '体系课' : '小节课'}</span>
<span class="course-status deleted">🗑️ 已删除</span>
<span>${course.total_nodes || 0} 个节点</span>
</div>
<div style="font-size: 11px; color: #999; margin-top: 8px;">
删除时间: ${deletedDate}
</div>
</div>
</div>
<div class="course-card-actions" onclick="event.stopPropagation();">
<button class="btn-status-toggle btn-restore"
onclick="restoreDeletedCourse('${course.id}', event)"
title="恢复课程">
恢复课程
</button>
</div>
</div>
`;
}).join('')}
</div>
`;
appContent.innerHTML = html;
}
// 查看已删除的课程详情(只读)
function viewDeletedCourse(courseId) {
alert('已删除的课程只能查看,不能编辑。如需编辑,请先恢复课程。');
}
// 恢复已删除的课程(全局函数)
window.restoreDeletedCourse = async function(courseId, event) {
if (event) {
event.stopPropagation();
}
if (!confirm('确定要恢复这个课程吗?恢复后课程将重新出现在课程管理列表中。')) {
return;
}
// 二次确认
if (!confirm('请再次确认:确定要恢复这个课程吗?')) {
return;
}
try {
const { response, result } = await apiRequest(`${API_BASE}/api/courses/${courseId}/restore`, {
method: 'PATCH'
});
if (response.ok && result.success) {
alert('课程已恢复!');
// 重新加载已删除课程列表
await loadDeletedCourses();
} else {
alert('恢复失败:' + (result.error?.message || '未知错误'));
}
} catch (error) {
alert('恢复失败:' + error.message);
}
};
// 退出登录
function logout() {
removeToken();
showLoginPage();
}
// 显示登录页
function showLoginPage() {
document.getElementById('loginPage').classList.remove('hidden');
document.getElementById('mainApp').classList.add('hidden');
}
// 当前编辑的课程ID
let currentCourseId = null;
let currentCourse = null;
// 编辑课程
async function editCourse(courseId) {
currentCourseId = courseId;
const loadingIndicator = document.getElementById('loadingIndicator');
const appContent = document.getElementById('appContent');
loadingIndicator.style.display = 'block';
appContent.innerHTML = '';
try {
// 获取课程列表以找到当前课程(包含草稿状态,管理后台需要能看到所有课程)
const { response, result } = await apiRequest(`${API_BASE}/api/courses?includeDrafts=true`, {
method: 'GET'
});
if (response.ok && result.success) {
const course = result.data.courses.find(c => c.id === courseId);
if (course) {
currentCourse = course;
loadingIndicator.style.display = 'none';
renderEditPage(course);
} else {
loadingIndicator.style.display = 'none';
appContent.innerHTML = `<div class="message error">课程不存在</div>`;
}
} else {
loadingIndicator.style.display = 'none';
appContent.innerHTML = `<div class="message error">加载失败:${result.error?.message || '未知错误'}</div>`;
}
} catch (error) {
loadingIndicator.style.display = 'none';
if (error.message.includes('登录已过期')) {
return; // 已跳转登录页
}
appContent.innerHTML = `<div class="message error">${error.message}</div>`;
}
}
// 渲染编辑页面
function renderEditPage(course) {
const appContent = document.getElementById('appContent');
// 确保type有默认值
if (!course.type || (course.type !== 'system' && course.type !== 'single')) {
course.type = 'system'; // 默认值
console.warn('课程type无效使用默认值system:', course);
}
// 调试日志
console.log('渲染编辑页面,课程数据:', {
id: course.id,
title: course.title,
type: course.type,
is_portrait: course.is_portrait
});
// 生成占位图片
const placeholderImage = getPlaceholderImage('暂无封面');
const errorPlaceholderImage = getPlaceholderImage('加载失败');
const coverImage = course.cover_image ? getImageUrl(course.cover_image) : placeholderImage;
const html = `
<div class="edit-page-header">
<h2>编辑课程</h2>
<button class="back-btn" onclick="loadCourseList()">← 返回列表</button>
</div>
<div class="edit-form">
<div class="form-group">
<label>课程ID不可修改</label>
<div class="course-id-display">${escapeHtml(course.id)}</div>
</div>
<div class="form-group">
<label for="editTitle">课程标题 <span class="required">*</span></label>
<input type="text" id="editTitle" value="${escapeHtml(course.title)}" placeholder="请输入课程标题">
</div>
<div class="form-group">
<label for="editSubtitle">副标题</label>
<input type="text" id="editSubtitle" value="${escapeHtml(course.subtitle || '')}" placeholder="请输入副标题(可选)">
</div>
<div class="form-group">
<label for="editDescription">课程描述</label>
<textarea id="editDescription" rows="4" placeholder="请输入课程描述(可选)">${escapeHtml(course.description || '')}</textarea>
</div>
<div class="form-group">
<label>课程类型 <span class="required">*</span></label>
<div class="type-selector" id="typeSelector">
<label class="type-option ${course.type === 'system' ? 'active' : ''}" data-type="system">
<input type="radio" name="courseType" value="system" ${course.type === 'system' ? 'checked' : ''}>
<span class="type-label">
<span class="type-icon">📚</span>
<span class="type-text">体系课</span>
<span class="type-desc">有章节结构,多个节点,按顺序学习</span>
</span>
</label>
<label class="type-option ${course.type === 'single' ? 'active' : ''}" data-type="single">
<input type="radio" name="courseType" value="single" ${course.type === 'single' ? 'checked' : ''}>
<span class="type-label">
<span class="type-icon">🎯</span>
<span class="type-text">小节课</span>
<span class="type-desc">单节点,直接进入学习</span>
</span>
</label>
</div>
<div style="font-size: 12px; color: #999; margin-top: 8px;">
当前类型: <strong>${course.type || 'system'}</strong>
</div>
</div>
<div class="form-group">
<label for="editMinAppVersion">最低App版本号</label>
<input type="text" id="editMinAppVersion" value="${escapeHtml(course.min_app_version || '')}" placeholder="如1.1.0(留空表示所有版本可见)">
<div style="font-size: 12px; color: #999; margin-top: 8px;">
说明:设置后,只有版本号 >= 此值的 App 才能看到此课程。留空则所有版本可见。
</div>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="editIsPortrait" ${course.is_portrait ? 'checked' : ''} style="margin-right: 8px;">
竖屏课程
</label>
<div style="font-size: 12px; color: #999; margin-top: 8px;">
说明勾选后此课程在App中将以竖屏模式显示。
</div>
</div>
<div class="form-group">
<label>封面主题色</label>
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-top:8px;">
${['#2266FF','#58CC02','#8A4FFF','#FF4B4B','#FFC800','#FF9600'].map(c => `
<div onclick="selectThemeColor('${c}')" style="width:40px;height:40px;border-radius:10px;background:${c};cursor:pointer;border:3px solid ${(course.theme_color||'').toUpperCase()===c ? '#333' : 'transparent'};transition:all .2s;box-shadow:0 2px 6px ${c}44;" class="theme-color-swatch" data-color="${c}"></div>
`).join('')}
<input type="text" id="editThemeColor" value="${escapeHtml(course.theme_color || '')}" placeholder="#HEX" style="width:90px;font-size:14px;padding:8px;border:1px solid #ddd;border-radius:8px;text-align:center;" onchange="selectThemeColor(this.value)">
<div id="themeColorPreview" style="width:40px;height:40px;border-radius:10px;background:${course.theme_color || '#ccc'};border:1px solid #ddd;"></div>
</div>
<div style="font-size:12px;color:#999;margin-top:6px;">选择预设颜色或输入自定义 HEX 值,即 App 中课程卡片的背景色</div>
</div>
<div class="form-group">
<label>封面图片</label>
<div class="image-upload-area" id="uploadArea" onclick="document.getElementById('fileInput').click()">
<p>点击或拖拽图片到此处上传</p>
<p style="font-size: 12px; color: #999; margin-top: 8px;">支持 JPG、PNG、WebP最大 2MB</p>
<img id="coverPreview" class="image-preview" src="${coverImage}" alt="封面预览" onerror="this.onerror=null; this.src='${errorPlaceholderImage}'">
<div id="uploadProgress" class="upload-progress">
<div class="progress-bar">
<div class="progress-fill" id="progressFill"></div>
</div>
<p style="font-size: 12px; color: #666; margin-top: 5px;">上传中...</p>
</div>
</div>
<input type="file" id="fileInput" accept="image/jpeg,image/png,image/webp" style="display: none;" onchange="handleFileSelect(event)">
</div>
<div class="form-group" style="background:#f8f9fa;padding:16px;border-radius:8px;border:1px solid #e9ecef;">
<label style="font-weight:600;color:#333;">🔗 续旧课链路信息</label>
<div style="margin-top:8px;font-size:13px;">
<div style="margin-bottom:6px;">
<span style="color:#666;">父课程ID</span>
<span style="word-break:break-all;">${escapeHtml(course.parent_course_id || course.parentCourseId || '(无,非续旧课)')}</span>
</div>
<div>
<span style="color:#666;">知识点摘要:</span>
${(course.accumulated_summary || course.accumulatedSummary) ? '<pre style="background:#fff;padding:8px;border-radius:6px;border:1px solid #ddd;max-height:200px;overflow:auto;font-size:12px;margin:4px 0 0 0;white-space:pre-wrap;word-break:break-word;">' + escapeHtml(course.accumulated_summary || course.accumulatedSummary) + '</pre>' : '<span style="color:#999;">(暂无摘要)</span>'}
</div>
</div>
</div>
<div class="button-group">
<button class="btn-secondary" onclick="loadCourseList()">取消</button>
<button class="btn-info" onclick="manageCourseStructure('${course.id}')" style="background: #667eea; color: white;">${course.type === 'system' ? '📚 管理章节节点' : '📚 管理节点'}</button>
<button class="btn-info" onclick="showImportMindMapModal('${course.id}')" style="background: #10b981; color: white;">📥 导入脑图</button>
<button class="btn-primary" onclick="saveCourse()">保存</button>
</div>
<div id="saveMessage" class="message" style="margin-top: 20px;"></div>
</div>
`;
appContent.innerHTML = html;
// 主题色选择函数
window.selectThemeColor = function(color) {
if (!color) return;
color = color.trim();
if (!color.startsWith('#')) color = '#' + color;
document.getElementById('editThemeColor').value = color;
document.getElementById('themeColorPreview').style.background = color;
document.querySelectorAll('.theme-color-swatch').forEach(el => {
el.style.borderColor = el.dataset.color.toUpperCase() === color.toUpperCase() ? '#333' : 'transparent';
});
};
// 设置拖拽上传
setupDragAndDrop();
// 设置类型选择器交互
setupTypeSelector();
}
// 设置类型选择器交互
function setupTypeSelector() {
const typeOptions = document.querySelectorAll('.type-option');
// 初始化根据checked状态设置active类
typeOptions.forEach(option => {
const radio = option.querySelector('input[type="radio"]');
if (radio) {
// 先清除所有active类
option.classList.remove('active');
// 如果radio被选中添加active类
if (radio.checked) {
option.classList.add('active');
console.log('类型选择器初始化,选中:', radio.value);
}
}
});
// 点击交互
typeOptions.forEach(option => {
option.addEventListener('click', function(e) {
// 如果点击的是input不处理让浏览器默认行为处理
if (e.target.tagName === 'INPUT') return;
// 找到这个option内的radio按钮并选中
const radio = this.querySelector('input[type="radio"]');
if (radio) {
radio.checked = true;
// 更新active状态
typeOptions.forEach(opt => opt.classList.remove('active'));
this.classList.add('active');
}
});
// 监听radio的change事件确保同步
const radio = option.querySelector('input[type="radio"]');
if (radio) {
radio.addEventListener('change', function() {
typeOptions.forEach(opt => opt.classList.remove('active'));
if (this.checked) {
option.classList.add('active');
}
});
}
});
}
// 设置拖拽上传
function setupDragAndDrop() {
const uploadArea = document.getElementById('uploadArea');
const fileInput = document.getElementById('fileInput');
uploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
uploadArea.classList.add('dragover');
});
uploadArea.addEventListener('dragleave', () => {
uploadArea.classList.remove('dragover');
});
uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
uploadArea.classList.remove('dragover');
const files = e.dataTransfer.files;
if (files.length > 0) {
fileInput.files = files;
handleFileSelect({ target: { files: files } });
}
});
}
// 处理文件选择
function handleFileSelect(event) {
const file = event.target.files[0];
if (!file) return;
// 验证文件类型
const validTypes = ['image/jpeg', 'image/png', 'image/webp'];
if (!validTypes.includes(file.type)) {
showMessage('saveMessage', '只支持 JPG、PNG、WebP 格式', 'error');
return;
}
// 验证文件大小2MB
if (file.size > 2 * 1024 * 1024) {
showMessage('saveMessage', '文件大小不能超过 2MB', 'error');
return;
}
// 显示预览
const reader = new FileReader();
reader.onload = (e) => {
const preview = document.getElementById('coverPreview');
preview.src = e.target.result;
preview.classList.add('show');
};
reader.readAsDataURL(file);
// 上传文件
uploadImage(file);
}
// 上传图片
async function uploadImage(file) {
const token = getToken();
const progressDiv = document.getElementById('uploadProgress');
const progressFill = document.getElementById('progressFill');
progressDiv.classList.add('show');
progressFill.style.width = '0%';
const formData = new FormData();
formData.append('image', file);
try {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percent = (e.loaded / e.total) * 100;
progressFill.style.width = percent + '%';
}
});
xhr.addEventListener('load', async () => {
try {
if (xhr.status === 200) {
const result = JSON.parse(xhr.responseText);
if (result.success) {
// 图片上传成功,更新封面
const imageUrl = `${API_BASE}/${result.data.imageUrl}`;
try {
await updateCourseCover(imageUrl);
progressDiv.classList.remove('show');
showMessage('saveMessage', '封面上传成功', 'success');
} catch (coverError) {
progressDiv.classList.remove('show');
showMessage('saveMessage', '封面上传成功,但更新封面信息失败:' + coverError.message, 'error');
}
} else {
progressDiv.classList.remove('show');
showMessage('saveMessage', result.error?.message || '上传失败', 'error');
}
} else {
progressDiv.classList.remove('show');
let errorMsg = '上传失败';
try {
const result = JSON.parse(xhr.responseText);
errorMsg = result.error?.message || errorMsg;
} catch (e) {
errorMsg = `上传失败 (状态码: ${xhr.status})`;
}
showMessage('saveMessage', errorMsg, 'error');
}
} catch (error) {
progressDiv.classList.remove('show');
showMessage('saveMessage', '处理响应失败:' + error.message, 'error');
}
});
xhr.addEventListener('error', () => {
progressDiv.classList.remove('show');
showMessage('saveMessage', '网络错误', 'error');
});
xhr.open('POST', `${API_BASE}/api/upload/image`);
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
xhr.send(formData);
} catch (error) {
progressDiv.classList.remove('show');
showMessage('saveMessage', '上传失败:' + error.message, 'error');
}
}
// 更新课程封面
async function updateCourseCover(coverImage) {
if (!currentCourseId) return;
try {
const { response, result } = await apiRequest(
`${API_BASE}/api/courses/${currentCourseId}/cover`,
{
method: 'PATCH',
body: JSON.stringify({ coverImage })
}
);
if (response.ok && result.success) {
// 更新当前课程对象
if (currentCourse) {
currentCourse.cover_image = coverImage;
}
}
} catch (error) {
console.error('更新封面失败:', error);
throw error; // 重新抛出错误,让调用者处理
}
}
// 保存课程
async function saveCourse() {
console.log('=== saveCourse 函数被调用 ===');
if (!currentCourseId) {
console.log('❌ currentCourseId 为空');
return;
}
console.log('currentCourseId:', currentCourseId);
const title = document.getElementById('editTitle').value.trim();
const subtitle = document.getElementById('editSubtitle').value.trim();
const description = document.getElementById('editDescription').value.trim();
// 获取课程类型
const typeRadio = document.querySelector('input[name="courseType"]:checked');
const type = typeRadio ? typeRadio.value : null;
// 获取最低App版本号
const minAppVersionInput = document.getElementById('editMinAppVersion');
const minAppVersionValue = minAppVersionInput ? minAppVersionInput.value.trim() : '';
const minAppVersion = minAppVersionValue === '' ? null : minAppVersionValue;
// 获取竖屏课程选项
const isPortraitCheckbox = document.getElementById('editIsPortrait');
const isPortrait = isPortraitCheckbox ? isPortraitCheckbox.checked : false;
console.log('表单数据:', { title, subtitle, description, type, minAppVersion, minAppVersionValue, isPortrait });
if (!title) {
console.log('❌ 标题为空');
showMessage('saveMessage', '请输入课程标题', 'error');
return;
}
if (!type || (type !== 'system' && type !== 'single')) {
console.log('❌ 类型无效:', type);
showMessage('saveMessage', '请选择课程类型', 'error');
return;
}
const saveBtn = document.querySelector('.button-group .btn-primary');
if (!saveBtn) {
console.log('❌ 保存按钮未找到');
return;
}
saveBtn.disabled = true;
saveBtn.textContent = '保存中...';
try {
console.log('=== 开始保存流程 ===');
const updateData = { title, type };
if (subtitle !== undefined) updateData.subtitle = subtitle || null;
if (description !== undefined) updateData.description = description || null;
// ✅ 修复:正确处理 minAppVersion空字符串转为 null非空字符串保留
updateData.minAppVersion = minAppVersion;
// ✅ 新增:竖屏课程选项
updateData.isPortrait = isPortrait;
// ✅ 新增:主题色
const themeColorInput = document.getElementById('editThemeColor');
if (themeColorInput && themeColorInput.value.trim()) {
updateData.themeColor = themeColorInput.value.trim();
}
console.log('=== 准备发送保存请求 ===');
console.log('updateData:', JSON.stringify(updateData, null, 2));
console.log('currentCourseId:', currentCourseId);
console.log('用户选择的type:', type);
const { response, result } = await apiRequest(`${API_BASE}/api/courses/${currentCourseId}`, {
method: 'PATCH',
body: JSON.stringify(updateData)
});
// 详细调试日志
console.log('=== 保存请求响应 ===');
console.log('响应状态:', response.status, response.ok);
console.log('完整响应数据:', JSON.stringify(result, null, 2));
console.log('result.data:', result.data);
console.log('result.data.course:', result.data?.course);
console.log('result.data.course.type:', result.data?.course?.type);
console.log('用户选择的type:', type);
if (response.ok && result.success) {
// 更新当前课程对象 - 优先使用后端返回的数据
if (result.data && result.data.course) {
if (!currentCourse) {
currentCourse = {};
}
// 使用后端返回的完整数据确保type字段正确
currentCourse.id = result.data.course.id || currentCourseId;
currentCourse.title = result.data.course.title || title;
currentCourse.subtitle = result.data.course.subtitle !== undefined ? result.data.course.subtitle : subtitle;
currentCourse.description = result.data.course.description !== undefined ? result.data.course.description : description;
// 关键优先使用后端返回的type如果没有则使用用户选择的type
const backendType = result.data.course.type;
currentCourse.type = (backendType !== undefined && backendType !== null) ? backendType : type;
currentCourse.cover_image = result.data.course.cover_image || currentCourse.cover_image;
// ✅ 新增:更新发布状态
currentCourse.status = result.data.course.status || currentCourse.status || 'published';
currentCourse.publish_status = currentCourse.status;
// ✅ 新增更新最低App版本号
currentCourse.min_app_version = result.data.course.min_app_version !== undefined ? result.data.course.min_app_version : minAppVersion;
// ✅ 新增:更新竖屏课程选项
currentCourse.is_portrait = result.data.course.is_portrait !== undefined ? result.data.course.is_portrait : isPortrait;
// 调试日志
console.log('=== 更新currentCourse ===');
console.log('更新后的currentCourse:', JSON.stringify(currentCourse, null, 2));
console.log('currentCourse.type:', currentCourse.type);
console.log('currentCourse.is_portrait:', currentCourse.is_portrait);
console.log('后端返回的is_portrait:', result.data.course.is_portrait);
console.log('用户选择的isPortrait:', isPortrait);
console.log('后端返回的type:', backendType);
console.log('用户选择的type:', type);
} else {
// 如果后端没有返回course数据至少更新type
if (!currentCourse) {
currentCourse = {};
}
currentCourse.type = type;
console.log('后端未返回course数据使用用户选择的type:', type);
}
showMessage('saveMessage', '保存成功!', 'success');
// 重新渲染编辑页面以更新类型标签显示
if (currentCourse) {
console.log('准备重新渲染currentCourse.type:', currentCourse.type);
setTimeout(() => {
console.log('=== 开始重新渲染编辑页面 ===');
console.log('currentCourse完整数据:', JSON.stringify(currentCourse, null, 2));
console.log('currentCourse.type值:', currentCourse.type, '类型:', typeof currentCourse.type);
renderEditPage(currentCourse);
// 验证渲染后的状态
setTimeout(() => {
const systemRadio = document.querySelector('input[name="courseType"][value="system"]');
const singleRadio = document.querySelector('input[name="courseType"][value="single"]');
const systemOption = document.querySelector('.type-option[data-type="system"]');
const singleOption = document.querySelector('.type-option[data-type="single"]');
console.log('=== 渲染后验证 ===');
console.log('system radio checked:', systemRadio?.checked);
console.log('single radio checked:', singleRadio?.checked);
console.log('system option active:', systemOption?.classList.contains('active'));
console.log('single option active:', singleOption?.classList.contains('active'));
console.log('currentCourse.type:', currentCourse.type);
// 显示保存成功消息
showMessage('saveMessage', `保存成功!类型已更新为: ${currentCourse.type === 'system' ? '体系课' : '小节课'}`, 'success');
}, 200);
}, 300);
} else {
// 如果currentCourse不存在跳转回列表
setTimeout(() => {
loadCourseList();
}, 1500);
}
} else {
showMessage('saveMessage', result.error?.message || '保存失败', 'error');
saveBtn.disabled = false;
saveBtn.textContent = '保存';
}
} catch (error) {
console.error('=== 保存失败 ===');
console.error('错误详情:', error);
console.error('错误消息:', error.message);
console.error('错误堆栈:', error.stack);
if (error.message.includes('登录已过期')) {
return; // 已跳转登录页
}
showMessage('saveMessage', error.message, 'error');
saveBtn.disabled = false;
saveBtn.textContent = '保存';
}
}
// HTML 转义
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// ============================================
// 文字样式编辑器工具函数(新增,不影响现有逻辑)
// ============================================
/**
* 检测HTML内容是否包含自定义标签
* @param {string} html - HTML内容
* @returns {boolean} 是否包含自定义标签
*/
function containsCustomTags(html) {
if (!html || typeof html !== 'string') return false;
// 检测我们支持的自定义标签
const customTagPattern = /<(b|color|quote|span\s+class=['"]highlight['"])[>\/\s]/i;
return customTagPattern.test(html);
}
/**
* 清理HTML内容只保留我们需要的标签
* @param {string} html - 原始HTML内容
* @returns {string} 清理后的HTML内容
*/
function cleanHtmlContent(html) {
if (!html || typeof html !== 'string') return '';
// 创建临时容器
const tempDiv = document.createElement('div');
tempDiv.innerHTML = html;
// 递归清理函数
function cleanNode(node) {
if (node.nodeType === Node.TEXT_NODE) {
return node.textContent;
}
if (node.nodeType === Node.ELEMENT_NODE) {
const tagName = node.tagName.toLowerCase();
// 允许的标签列表
const allowedTags = ['b', 'color', 'quote', 'span'];
if (allowedTags.includes(tagName)) {
// ✅ 关键修复:先提取子节点内容
const childContent = Array.from(node.childNodes).map(cleanNode).join('');
// ✅ 如果子节点内容为空,不保留标签(避免保存空标签)
if (!childContent || childContent.trim().length === 0) {
return '';
}
// 检查属性
if (tagName === 'color') {
const type = node.getAttribute('type');
if (!type || !['vital', 'iris', 'neon'].includes(type)) {
// 无效的color标签只保留内容
return childContent;
}
// 确保使用单引号
const typeValue = node.getAttribute('type');
return `<color type='${typeValue}'>${childContent}</color>`;
} else if (tagName === 'span') {
const className = node.getAttribute('class');
if (className === 'highlight') {
return `<span class='highlight'>${childContent}</span>`;
} else {
// 不是highlight的span只保留内容
return childContent;
}
} else if (tagName === 'quote') {
return `<quote>${childContent}</quote>`;
} else if (tagName === 'b') {
return `<b>${childContent}</b>`;
}
} else {
// 不允许的标签,只保留内容
return Array.from(node.childNodes).map(cleanNode).join('');
}
}
return '';
}
// 清理所有子节点
const cleanedNodes = Array.from(tempDiv.childNodes).map(cleanNode);
const result = cleanedNodes.join('');
// ✅ 最终检查:如果清理后的结果没有文本内容,返回空字符串
const finalCheck = document.createElement('div');
finalCheck.innerHTML = result;
if (!finalCheck.textContent || finalCheck.textContent.trim().length === 0) {
return '';
}
// 统一处理将双引号转换为单引号iOS App要求
return result
.replace(/type="([^"]+)"/g, "type='$1'")
.replace(/class="([^"]+)"/g, "class='$1'");
}
/**
* 安全处理HTML只允许特定标签防止XSS
* @param {string} html - HTML内容
* @returns {string} 安全处理后的HTML内容
*/
function sanitizeHtml(html) {
if (!html || typeof html !== 'string') return '';
// 先清理,只保留允许的标签
let cleaned = cleanHtmlContent(html);
// 转义其他可能的危险字符
// 但保留我们已经清理过的标签
const tempDiv = document.createElement('div');
tempDiv.textContent = cleaned;
const escaped = tempDiv.innerHTML;
// 恢复我们的自定义标签因为textContent会转义它们
// 这是一个安全的做法,因为我们已经清理过了
return cleaned;
}
/**
* 检测HTML内容是否包含指定的样式标签
* @param {string} html - HTML内容
* @param {string} tagName - 标签名('b', 'color', 'quote', 'span'
* @param {object} attributes - 属性对象(如 {type: 'vital'} 或 {class: 'highlight'}
* @returns {boolean} 是否包含该样式
*/
function hasStyle(html, tagName, attributes = {}) {
if (!html || typeof html !== 'string') return false;
if (tagName === 'b') {
return /<b[>\/\s].*?<\/b>/i.test(html);
} else if (tagName === 'color') {
const type = attributes.type;
if (!type) return /<color[^>]*>/i.test(html);
// 检测特定类型的color标签支持单引号和双引号
const pattern = new RegExp(`<color\\s+type=['"]${type}['"][^>]*>`, 'i');
return pattern.test(html);
} else if (tagName === 'quote') {
return /<quote[>\/\s].*?<\/quote>/i.test(html);
} else if (tagName === 'span') {
if (attributes.class === 'highlight') {
return /<span\s+class=['"]highlight['"][^>]*>/i.test(html);
}
return false;
}
return false;
}
/**
* 切换样式标签(如果已存在则移除,如果不存在则添加)
* @param {string} html - 原始HTML内容
* @param {string} tagName - 标签名
* @param {object} attributes - 属性对象
* @returns {string} 处理后的HTML内容
*/
function toggleStyle(html, tagName, attributes = {}) {
if (!html || typeof html !== 'string') return html;
const hasStyleTag = hasStyle(html, tagName, attributes);
if (hasStyleTag) {
// 移除标签
return removeStyle(html, tagName, attributes);
} else {
// 添加标签这个功能在applyTextStyle中实现这里只做检测
return html;
}
}
/**
* 移除样式标签
* @param {string} html - HTML内容
* @param {string} tagName - 标签名
* @param {object} attributes - 属性对象
* @returns {string} 移除标签后的HTML内容
*/
function removeStyle(html, tagName, attributes = {}) {
if (!html || typeof html !== 'string') return html;
if (tagName === 'b') {
// 移除 <b>...</b>,保留内容
return html.replace(/<b[^>]*>(.*?)<\/b>/gi, '$1');
} else if (tagName === 'color') {
const type = attributes.type;
if (type) {
// 移除特定类型的color标签
const pattern = new RegExp(`<color\\s+type=['"]${type}['"][^>]*>(.*?)<\\/color>`, 'gi');
return html.replace(pattern, '$1');
} else {
// 移除所有color标签处理嵌套情况
let result = html;
let prevResult = '';
// 循环移除所有嵌套的color标签
while (result !== prevResult) {
prevResult = result;
// 匹配 <color ...>...</color>,包括嵌套的情况
result = result.replace(/<color[^>]*>(.*?)<\/color>/gis, '$1');
}
return result;
}
} else if (tagName === 'quote') {
// 移除 <quote>...</quote>,保留内容
return html.replace(/<quote[^>]*>(.*?)<\/quote>/gi, '$1');
} else if (tagName === 'span' && attributes.class === 'highlight') {
// 移除 <span class='highlight'>...</span>,保留内容
// 使用非贪婪匹配,并处理嵌套情况
let result = html;
// 循环移除所有嵌套的highlight标签
let prevResult = '';
while (result !== prevResult) {
prevResult = result;
result = result.replace(/<span\s+class=['"]highlight['"][^>]*>(.*?)<\/span>/gi, '$1');
}
return result;
}
return html;
}
// 根据内容推断幻灯片类型标签(用于显示)
function getSlideTypeLabel(slide) {
const content = slide.content || {};
const hasImage = content.imageUrl && content.imageUrl.trim();
const hasText = (content.paragraphs && content.paragraphs.length > 0) || (content.title && content.title.trim());
if (hasImage && hasText) {
return '图文';
} else if (hasImage) {
return '图片';
} else if (hasText) {
return '文本';
} else {
return '空';
}
}
// 管理课程结构(章节和节点)
async function manageCourseStructure(courseId) {
currentCourseId = courseId;
const loadingIndicator = document.getElementById('loadingIndicator');
const appContent = document.getElementById('appContent');
loadingIndicator.style.display = 'block';
appContent.innerHTML = '';
try {
// 获取课程结构
const { response, result } = await apiRequest(`${API_BASE}/api/courses/${courseId}/structure`, {
method: 'GET'
});
if (response.ok && result.success) {
loadingIndicator.style.display = 'none';
renderStructurePage(result.data);
} else {
loadingIndicator.style.display = 'none';
appContent.innerHTML = `<div class="message error">加载失败:${result.error?.message || '未知错误'}</div>`;
}
} catch (error) {
loadingIndicator.style.display = 'none';
if (error.message.includes('登录已过期')) {
return; // 已跳转登录页
}
appContent.innerHTML = `<div class="message error">${error.message}</div>`;
}
}
// 渲染章节节点管理页面
function renderStructurePage(data) {
const appContent = document.getElementById('appContent');
const isSingleCourse = data.courseType === 'single'; // ✅ 判断是否为小节课
const html = `
<div class="structure-page">
<div class="structure-header">
<h2>${isSingleCourse ? '管理节点' : '管理章节节点'} - ${escapeHtml(data.courseTitle)}</h2>
<div style="display: flex; gap: 12px; align-items: center;">
${isSingleCourse ? `
<button class="btn-info" onclick="showExportCourseModal('${data.courseId}', '${escapeHtml(data.courseTitle)}')" style="background: #8b5cf6; color: white; padding: 8px 16px; border-radius: 6px; border: none; cursor: pointer; font-weight: 500;">📤 导出课程</button>
` : ''}
<button class="back-btn" onclick="editCourse('${data.courseId}')">← 返回编辑</button>
</div>
</div>
<div class="structure-tree">
${!isSingleCourse ? data.chapters.map((chapter, chapterIndex) => `
<div class="chapter-item" data-chapter-id="${chapter.id}" data-chapter-order="${chapter.order}" draggable="true">
<div class="chapter-header">
<span class="drag-handle"></span>
<span class="chapter-title">📁 ${escapeHtml(chapter.title)}</span>
<div class="chapter-actions">
<button class="btn-small btn-edit" onclick="editChapter('${chapter.id}', '${escapeHtml(chapter.title)}')">编辑</button>
<button class="btn-small btn-info" onclick="showImportMindMapModal('${data.courseId}', '${chapter.id}')" style="background: #10b981; color: white;">📥 导入脑图</button>
<button class="btn-small btn-info" onclick="showExportChapterModal('${data.courseId}', '${chapter.id}', '${escapeHtml(chapter.title)}')" style="background: #8b5cf6; color: white;">📤 导出章节</button>
<button class="btn-small btn-delete" onclick="deleteChapter('${chapter.id}')">删除</button>
</div>
</div>
${chapter.nodes.map((node, nodeIndex) => `
<div class="node-item" data-node-id="${node.id}" data-node-order="${node.order}" data-chapter-id="${chapter.id}" draggable="true">
<span class="drag-handle"></span>
<div class="node-info">
<div class="node-title">📄 ${escapeHtml(node.title)}</div>
<div class="node-meta">
${node.subtitle ? escapeHtml(node.subtitle) + ' • ' : ''}
${node.duration ? node.duration + '分钟 • ' : ''}
顺序: ${node.order}
</div>
</div>
<div class="node-actions">
<button class="btn-small btn-edit" onclick="editNode('${node.id}', '${chapter.id}')">编辑</button>
<button class="btn-small btn-content" onclick="editNodeContent('${node.id}', '${escapeHtml(node.title)}')">📝 编辑内容</button>
<button class="btn-small btn-delete" onclick="deleteNode('${node.id}')">删除</button>
</div>
</div>
`).join('')}
<button class="btn-small btn-add" onclick="addNode('${chapter.id}')" style="margin-left: 24px; margin-top: 8px;">+ 添加节点</button>
</div>
`).join('') : ''}
${!isSingleCourse ? `
<div style="margin-top: 24px;">
<button class="btn-small btn-add" onclick="addChapter()">+ 添加章节</button>
</div>
` : ''}
${(isSingleCourse || data.nodesWithoutChapter.length > 0) ? `
<div class="nodes-without-chapter">
<div class="section-title">${isSingleCourse ? '节点列表' : '无章节的节点'}</div>
${data.nodesWithoutChapter.map((node, nodeIndex) => `
<div class="node-item" data-node-id="${node.id}" data-node-order="${node.order}" data-chapter-id="null" draggable="true">
<span class="drag-handle"></span>
<div class="node-info">
<div class="node-title">📄 ${escapeHtml(node.title)}</div>
<div class="node-meta">
${node.subtitle ? escapeHtml(node.subtitle) + ' • ' : ''}
${node.duration ? node.duration + '分钟 • ' : ''}
顺序: ${node.order}
</div>
</div>
<div class="node-actions">
<button class="btn-small btn-edit" onclick="editNode('${node.id}', null)">编辑</button>
<button class="btn-small btn-content" onclick="editNodeContent('${node.id}', '${escapeHtml(node.title)}')">📝 编辑内容</button>
<button class="btn-small btn-delete" onclick="deleteNode('${node.id}')">删除</button>
</div>
</div>
`).join('')}
${isSingleCourse ? `
<button class="btn-small btn-add" onclick="addNode(null)" style="margin-left: 24px; margin-top: 8px;">+ 添加节点</button>
` : ''}
</div>
` : ''}
</div>
</div>
`;
appContent.innerHTML = html;
// 设置拖拽功能
setupDragAndDrop();
}
// 设置拖拽排序
function setupDragAndDrop() {
let draggedElement = null;
let draggedType = null; // 'chapter' or 'node'
// 章节拖拽
const chapterItems = document.querySelectorAll('.chapter-item');
chapterItems.forEach(item => {
item.addEventListener('dragstart', (e) => {
draggedElement = item;
draggedType = 'chapter';
item.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
});
item.addEventListener('dragend', () => {
item.classList.remove('dragging');
document.querySelectorAll('.chapter-item, .node-item').forEach(el => {
el.classList.remove('drag-over');
});
});
item.addEventListener('dragover', (e) => {
if (draggedType === 'chapter' && draggedElement !== item) {
e.preventDefault();
item.classList.add('drag-over');
}
});
item.addEventListener('dragleave', () => {
item.classList.remove('drag-over');
});
item.addEventListener('drop', async (e) => {
e.preventDefault();
item.classList.remove('drag-over');
if (draggedType === 'chapter' && draggedElement !== item) {
const draggedId = draggedElement.dataset.chapterId;
const targetId = item.dataset.chapterId;
// 获取所有章节的新顺序
const chapters = Array.from(document.querySelectorAll('.chapter-item'))
.map((el, index) => ({
id: el.dataset.chapterId,
order: index
}));
// 更新顺序
await updateOrder(chapters, []);
}
});
});
// 节点拖拽
const nodeItems = document.querySelectorAll('.node-item');
nodeItems.forEach(item => {
item.addEventListener('dragstart', (e) => {
draggedElement = item;
draggedType = 'node';
item.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
});
item.addEventListener('dragend', () => {
item.classList.remove('dragging');
document.querySelectorAll('.chapter-item, .node-item').forEach(el => {
el.classList.remove('drag-over');
});
});
item.addEventListener('dragover', (e) => {
if (draggedType === 'node') {
e.preventDefault();
item.classList.add('drag-over');
}
});
item.addEventListener('dragleave', () => {
item.classList.remove('drag-over');
});
item.addEventListener('drop', async (e) => {
e.preventDefault();
item.classList.remove('drag-over');
if (draggedType === 'node' && draggedElement !== item) {
// ✅ 修复先临时更新DOM然后收集所有节点的新状态最后一次性更新
const draggedNodeId = draggedElement.dataset.nodeId;
const draggedChapterId = draggedElement.dataset.chapterId;
const targetChapterId = item.dataset.chapterId;
// 保存原始状态(用于错误回滚)
const originalParent = draggedElement.parentElement;
const originalNextSibling = draggedElement.nextSibling;
const originalChapterId = draggedElement.dataset.chapterId;
// 临时移动DOM元素到目标位置
const targetParent = item.parentElement;
const targetIndex = Array.from(targetParent.children).indexOf(item);
// 如果跨父元素移动
if (originalParent !== targetParent) {
targetParent.insertBefore(draggedElement, item);
} else {
// 同父元素内移动
const draggedIndex = Array.from(originalParent.children).indexOf(draggedElement);
if (draggedIndex < targetIndex) {
targetParent.insertBefore(draggedElement, item.nextSibling);
} else {
targetParent.insertBefore(draggedElement, item);
}
}
// 临时更新被拖拽节点的data-chapter-id属性
draggedElement.dataset.chapterId = targetChapterId;
// ✅ 收集所有节点的新状态包括chapterId和orderIndex
const nodes = [];
let globalOrder = 0;
// 遍历所有章节
document.querySelectorAll('.chapter-item').forEach(chapterEl => {
const chapterId = chapterEl.dataset.chapterId;
chapterEl.querySelectorAll('.node-item').forEach((nodeEl) => {
nodes.push({
id: nodeEl.dataset.nodeId,
chapterId: chapterId,
order: globalOrder++
});
});
});
// 无章节的节点
const nodesWithoutChapter = document.querySelector('.nodes-without-chapter');
if (nodesWithoutChapter) {
nodesWithoutChapter.querySelectorAll('.node-item').forEach((nodeEl) => {
nodes.push({
id: nodeEl.dataset.nodeId,
chapterId: null,
order: globalOrder++
});
});
}
// ✅ 一次性更新所有节点包括chapterId和orderIndex
try {
await updateOrder([], nodes);
} catch (error) {
// 如果更新失败恢复DOM
if (originalNextSibling) {
originalParent.insertBefore(draggedElement, originalNextSibling);
} else {
originalParent.appendChild(draggedElement);
}
draggedElement.dataset.chapterId = originalChapterId;
alert('移动节点失败:' + error.message);
}
}
});
});
}
// 更新顺序
async function updateOrder(chapters, nodes) {
try {
const { response, result } = await apiRequest(`${API_BASE}/api/courses/${currentCourseId}/reorder`, {
method: 'PATCH',
body: JSON.stringify({ chapters, nodes })
});
if (response.ok && result.success) {
// 重新加载结构
await manageCourseStructure(currentCourseId);
} else {
alert('更新顺序失败:' + (result.error?.message || '未知错误'));
}
} catch (error) {
alert('更新顺序失败:' + error.message);
}
}
// 添加章节
async function addChapter() {
const title = prompt('请输入章节标题:');
if (!title || !title.trim()) {
return;
}
try {
const { response, result } = await apiRequest(`${API_BASE}/api/courses/${currentCourseId}/chapters`, {
method: 'POST',
body: JSON.stringify({ title: title.trim() })
});
if (response.ok && result.success) {
// 重新加载结构
await manageCourseStructure(currentCourseId);
} else {
alert('创建失败:' + (result.error?.message || '未知错误'));
}
} catch (error) {
alert('创建失败:' + error.message);
}
}
// 编辑章节
async function editChapter(chapterId, currentTitle) {
const title = prompt('请输入章节标题:', currentTitle);
if (!title || !title.trim() || title === currentTitle) {
return;
}
try {
const { response, result } = await apiRequest(`${API_BASE}/api/courses/${currentCourseId}/chapters/${chapterId}`, {
method: 'PATCH',
body: JSON.stringify({ title: title.trim() })
});
if (response.ok && result.success) {
// 重新加载结构
await manageCourseStructure(currentCourseId);
} else {
alert('更新失败:' + (result.error?.message || '未知错误'));
}
} catch (error) {
alert('更新失败:' + error.message);
}
}
// 删除章节
async function deleteChapter(chapterId) {
if (!confirm('确定要删除这个章节吗?章节下的所有节点也会被删除!')) {
return;
}
try {
const { response, result } = await apiRequest(`${API_BASE}/api/courses/${currentCourseId}/chapters/${chapterId}`, {
method: 'DELETE'
});
if (response.ok && result.success) {
// 重新加载结构
await manageCourseStructure(currentCourseId);
} else {
alert('删除失败:' + (result.error?.message || '未知错误'));
}
} catch (error) {
alert('删除失败:' + error.message);
}
}
// 添加节点
async function addNode(chapterId) {
const title = prompt('请输入节点标题:');
if (!title || !title.trim()) {
return;
}
const subtitle = prompt('请输入节点副标题(可选,直接回车跳过):');
try {
const { response, result } = await apiRequest(`${API_BASE}/api/courses/${currentCourseId}/nodes`, {
method: 'POST',
body: JSON.stringify({
title: title.trim(),
subtitle: subtitle ? subtitle.trim() : null,
duration: null, // 时长将根据内容自动计算
chapterId: chapterId || null
})
});
if (response.ok && result.success) {
// 重新加载结构
await manageCourseStructure(currentCourseId);
} else {
alert('创建失败:' + (result.error?.message || '未知错误'));
}
} catch (error) {
alert('创建失败:' + error.message);
}
}
// 编辑节点
async function editNode(nodeId, chapterId) {
// 先获取节点信息
try {
const { response: structureResponse, result: structureResult } = await apiRequest(`${API_BASE}/api/courses/${currentCourseId}/structure`, {
method: 'GET'
});
if (!structureResponse.ok || !structureResult.success) {
alert('获取节点信息失败');
return;
}
// 查找节点
let node = null;
for (const chapter of structureResult.data.chapters) {
const found = chapter.nodes.find(n => n.id === nodeId);
if (found) {
node = found;
break;
}
}
if (!node) {
node = structureResult.data.nodesWithoutChapter.find(n => n.id === nodeId);
}
if (!node) {
alert('节点不存在');
return;
}
const title = prompt('请输入节点标题:', node.title);
if (!title || !title.trim()) {
return;
}
const subtitle = prompt('请输入节点副标题(可选,直接回车跳过):', node.subtitle || '');
// 显示当前时长(自动计算)
const durationInfo = node.duration ? `\n\n当前时长${node.duration}分钟(根据内容自动计算)` : '\n\n时长将根据内容自动计算';
alert(`节点信息${durationInfo}`);
try {
const { response, result } = await apiRequest(`${API_BASE}/api/courses/${currentCourseId}/nodes/${nodeId}`, {
method: 'PATCH',
body: JSON.stringify({
title: title.trim(),
subtitle: subtitle ? subtitle.trim() : null,
// duration字段不更新保持自动计算
chapterId: chapterId || null
})
});
if (response.ok && result.success) {
// 重新加载结构
await manageCourseStructure(currentCourseId);
} else {
alert('更新失败:' + (result.error?.message || '未知错误'));
}
} catch (error) {
alert('更新失败:' + error.message);
}
} catch (error) {
alert('获取节点信息失败:' + error.message);
}
}
// 删除节点
async function deleteNode(nodeId) {
if (!confirm('确定要删除这个节点吗?节点下的所有内容也会被删除!')) {
return;
}
try {
const { response, result } = await apiRequest(`${API_BASE}/api/courses/${currentCourseId}/nodes/${nodeId}`, {
method: 'DELETE'
});
if (response.ok && result.success) {
// 重新加载结构
await manageCourseStructure(currentCourseId);
} else {
alert('删除失败:' + (result.error?.message || '未知错误'));
}
} catch (error) {
alert('删除失败:' + error.message);
}
}
// 生成占位图片SVG data URI
function getPlaceholderImage(text = '无封面') {
const svg = `<svg width="300" height="200" xmlns="http://www.w3.org/2000/svg">
<rect width="300" height="200" fill="#f5f5f5"/>
<text x="50%" y="50%" font-family="Arial, sans-serif" font-size="16" fill="#999" text-anchor="middle" dominant-baseline="middle">${text}</text>
</svg>`;
return 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg)));
}
// 全局错误处理
window.addEventListener('error', function(event) {
console.error('全局错误:', event.error);
});
// 未捕获的 Promise 错误处理
window.addEventListener('unhandledrejection', function(event) {
console.error('未处理的 Promise 错误:', event.reason);
});
// 绑定登录按钮
function bindAdminLoginButton() {
const btn = document.getElementById('adminLoginBtn');
if (btn) {
btn.onclick = function() {
if (typeof window.adminLogin === 'function') {
window.adminLogin();
} else {
console.error('adminLogin 函数未定义');
alert('页面加载中,请稍候再试');
}
};
}
}
// 页面加载时检查登录状态
window.onload = function() {
try {
bindAdminLoginButton();
// 确保 adminLogin 函数已定义
if (typeof window.adminLogin === 'function') {
console.log('adminLogin 函数已定义');
} else {
console.warn('adminLogin 函数未定义');
}
checkAuth();
} catch (error) {
console.error('页面加载错误:', error);
// 如果出错,至少显示登录页
const loginPage = document.getElementById('loginPage');
if (loginPage) {
loginPage.classList.remove('hidden');
}
}
};
// DOMContentLoaded 时也尝试初始化(更早执行)
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function() {
console.log('页面DOM加载完成');
bindAdminLoginButton();
// 确保页面元素存在后再初始化
setTimeout(function() {
try {
checkAuth();
} catch (error) {
console.error('初始化错误:', error);
}
}, 100);
});
} else {
// DOM已经加载完成直接执行
console.log('页面DOM已加载');
bindAdminLoginButton();
setTimeout(function() {
try {
checkAuth();
} catch (error) {
console.error('初始化错误:', error);
}
}, 100);
}
// 编辑节点内容
let currentNodeId = null;
let currentNodeTitle = null;
let allSlides = []; // 存储当前节点的所有幻灯片,供批量上传功能使用
async function editNodeContent(nodeId, nodeTitle) {
currentNodeId = nodeId; // ✅ 系统笔记管理确保currentNodeId被设置
currentNodeTitle = nodeTitle;
const loadingIndicator = document.getElementById('loadingIndicator');
const appContent = document.getElementById('appContent');
loadingIndicator.style.display = 'block';
appContent.innerHTML = '';
try {
// 获取节点幻灯片
const { response, result } = await apiRequest(`${API_BASE}/api/courses/nodes/${nodeId}/slides`, {
method: 'GET'
});
if (response.ok && result.success) {
loadingIndicator.style.display = 'none';
renderSlideEditorPage(result.data);
} else {
loadingIndicator.style.display = 'none';
appContent.innerHTML = `<div class="message error">加载失败:${result.error?.message || '未知错误'}</div>`;
}
} catch (error) {
loadingIndicator.style.display = 'none';
if (error.message.includes('登录已过期')) {
return; // 已跳转登录页
}
appContent.innerHTML = `<div class="message error">${error.message}</div>`;
}
}
// 渲染节点内容编辑页面
function renderSlideEditorPage(data) {
const appContent = document.getElementById('appContent');
// 防御性检查:确保数据格式正确
const slides = Array.isArray(data.slides) ? data.slides : [];
// ✅ 存储所有幻灯片供批量上传功能使用
allSlides = slides;
const nodeTitle = data.nodeTitle || '未知节点';
const courseId = currentCourseId || null;
// 构建返回按钮的onclick如果courseId为null返回课程列表
const backButtonOnclick = courseId
? `manageCourseStructure('${courseId}')`
: 'loadCourseList()';
const backButtonText = courseId ? '← 返回章节节点' : '← 返回课程列表';
const html = `
<div class="slide-editor-page">
<div class="structure-header">
<h2>编辑节点内容 - ${escapeHtml(nodeTitle)}</h2>
<button class="back-btn" onclick="${backButtonOnclick}">${backButtonText}</button>
</div>
<!-- ✅ 系统笔记管理:添加标签页切换 -->
<div class="tab-container" style="display: flex !important; gap: 8px; margin-bottom: 16px; margin-top: 16px; border-bottom: 2px solid #e0e0e0; width: 100%; position: relative; z-index: 1;">
<button class="tab-btn active" onclick="switchTab('slides')" id="tab-slides" style="padding: 12px 24px !important; background: none !important; border: none !important; border-bottom: 3px solid #667eea !important; color: #667eea !important; font-weight: 600 !important; cursor: pointer !important; font-size: 14px !important; display: inline-block !important; visibility: visible !important; opacity: 1 !important;">
幻灯片
</button>
<button class="tab-btn" onclick="switchTab('system-notes')" id="tab-system-notes" style="padding: 12px 24px !important; background: none !important; border: none !important; border-bottom: 3px solid transparent !important; color: #666 !important; font-weight: 500 !important; cursor: pointer !important; font-size: 14px !important; display: inline-block !important; visibility: visible !important; opacity: 1 !important;">
系统笔记
</button>
</div>
<!-- 幻灯片编辑面板 -->
<div id="slides-panel" class="tab-panel">
<div class="slide-editor-container">
<div class="slide-list-panel">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
<h3 style="margin: 0; font-size: 16px;">幻灯片列表</h3>
<div style="display: flex; gap: 8px;">
<button class="btn-small btn-add" onclick="addSlide()">+ 添加</button>
<button class="btn-small btn-secondary" onclick="showBatchUploadModal()" title="批量上传图片并自动匹配到卡片">
📷 批量上传
</button>
<button class="btn-small btn-danger" onclick="clearAllImages()" title="清除所有卡片的图片">
🗑️ 清除图片
</button>
</div>
</div>
<div id="slideList">
${slides.map((slide, index) => `
<div class="slide-list-item" data-slide-id="${slide.id}">
<div class="slide-order-controls">
<button class="order-btn" onclick="moveSlideUp('${slide.id}', event)" title="上移" ${index === 0 ? 'disabled' : ''}>
</button>
<button class="order-btn" onclick="moveSlideDown('${slide.id}', event)" title="下移" ${index === slides.length - 1 ? 'disabled' : ''}>
</button>
</div>
<div class="slide-item-content" onclick="selectSlide('${slide.id}')">
<div class="slide-item-header">
<span class="slide-item-type ${getSlideTypeLabel(slide)}">${getSlideTypeLabel(slide)}</span>
<span class="slide-item-order">#${index + 1}</span>
</div>
<div class="slide-item-title">
${slide.content?.title || '无标题'}
</div>
</div>
</div>
`).join('')}
${slides.length === 0 ? '<div style="text-align: center; color: #999; padding: 40px;">暂无幻灯片,点击"添加"创建</div>' : ''}
</div>
</div>
<div class="slide-editor-panel">
<div id="slideEditor">
<div style="text-align: center; color: #999; padding: 60px;">
<p>请从左侧选择要编辑的幻灯片</p>
</div>
</div>
</div>
</div>
</div>
<!-- 系统笔记管理面板 -->
<div id="system-notes-panel" class="tab-panel" style="display: none;">
<div id="systemNotesContent">
<div style="text-align: center; color: #999; padding: 40px;">
<p>加载中...</p>
</div>
</div>
</div>
</div>
`;
appContent.innerHTML = html;
// ✅ 系统笔记管理:初始化标签页切换功能
initTabSwitching();
// 如果有幻灯片,默认选择第一个
if (slides.length > 0) {
selectSlide(slides[0].id);
}
}
// 选择幻灯片
let currentSlideId = null;
let currentSlideData = null;
// 自动保存管理器
const autoSaveManager = {
timer: null,
isSaving: false,
lastSavedContent: null,
pendingAutoSave: false,
debounceTime: {
title: 2000, // 标题2秒
content: 3000, // 正文3秒
imagePosition: 1000 // 图片位置1秒
},
eventListeners: [] // 存储事件监听器,用于清理
};
// 获取当前编辑器内容(用于比较)
function getCurrentContent() {
const titleInput = document.getElementById('slideTitle');
const richTextEditor = document.getElementById('richTextEditor');
const imagePositionSelect = document.getElementById('imagePosition');
const imageUrl = currentSlideData?.content?.imageUrl || null;
// 提取标题
const title = titleInput?.value.trim() || '';
// 提取正文内容改造支持HTML标签
let paragraphs = [];
if (richTextEditor) {
const paragraphElements = richTextEditor.querySelectorAll('p');
if (paragraphElements.length > 0) {
paragraphs = Array.from(paragraphElements)
.map(p => {
// 获取段落的HTML内容
let html = p.innerHTML;
// ✅ 关键修复:检查段落是否有实际文本内容
const textContent = p.textContent.trim();
if (!textContent || textContent.length === 0) {
// 如果段落没有文本内容,跳过(不保存空段落)
return null;
}
// 检查是否包含自定义标签
if (containsCustomTags(html)) {
// 包含自定义标签,清理并保留
const cleanedHtml = cleanHtmlContent(html);
// ✅ 再次检查清理后的HTML是否还有文本内容
const tempCheck = document.createElement('div');
tempCheck.innerHTML = cleanedHtml;
if (!tempCheck.textContent || tempCheck.textContent.trim().length === 0) {
// 如果清理后没有文本内容,返回纯文本(避免保存空标签)
return textContent;
}
return cleanedHtml;
} else {
// 不包含自定义标签使用textContent向后兼容
return textContent;
}
})
.filter(p => p !== null && p.length > 0); // 过滤掉null和空字符串
} else {
// 如果没有p标签检查整个编辑器内容
const text = richTextEditor.textContent.trim();
if (text) {
// 检查是否包含HTML标签
const html = richTextEditor.innerHTML;
if (containsCustomTags(html)) {
// 包含自定义标签,清理并保留
const cleanedHtml = cleanHtmlContent(html);
// ✅ 再次检查清理后的HTML是否还有文本内容
const tempCheck = document.createElement('div');
tempCheck.innerHTML = cleanedHtml;
if (!tempCheck.textContent || tempCheck.textContent.trim().length === 0) {
// 如果清理后没有文本内容,返回纯文本(避免保存空标签)
paragraphs = [text];
} else {
paragraphs = [cleanedHtml];
}
} else {
// 不包含自定义标签,按行分割(向后兼容)
paragraphs = text.split('\n').filter(p => p.trim());
}
}
}
}
// 获取图片位置
const imagePosition = imagePositionSelect ? imagePositionSelect.value : 'top';
return {
title: title || null,
paragraphs: paragraphs.length > 0 ? paragraphs : null,
imageUrl: imageUrl,
imagePosition: imageUrl ? imagePosition : null
};
}
// 检查是否有未保存的更改
function hasChanges() {
const currentContent = getCurrentContent();
if (!autoSaveManager.lastSavedContent) {
return true; // 首次编辑,需要保存
}
return JSON.stringify(currentContent) !== JSON.stringify(autoSaveManager.lastSavedContent);
}
// 触发自动保存(防抖)
function triggerAutoSave(field) {
clearTimeout(autoSaveManager.timer);
const debounceTime = autoSaveManager.debounceTime[field] || 2000;
// 更新保存按钮状态为"未保存"
updateSaveButtonStatus('unsaved');
autoSaveManager.timer = setTimeout(() => {
if (!autoSaveManager.isSaving && hasChanges()) {
performAutoSave();
}
}, debounceTime);
}
// 执行自动保存
async function performAutoSave() {
if (!currentSlideId || !currentSlideData) {
return;
}
if (autoSaveManager.isSaving) {
// 如果正在保存,标记需要再次保存
autoSaveManager.pendingAutoSave = true;
return;
}
autoSaveManager.isSaving = true;
updateSaveButtonStatus('saving');
try {
// 获取当前内容
const content = getCurrentContent();
const imageUrl = content.imageUrl;
const imagePosition = content.imagePosition || 'top';
// 根据内容自动推断类型
let slideType = 'text';
if (imageUrl) {
slideType = 'image';
} else if (content.paragraphs && content.paragraphs.length > 0 || content.title) {
slideType = 'text';
}
// 调用保存API
const { response, result } = await apiRequest(`${API_BASE}/api/courses/nodes/${currentNodeId}/slides/${currentSlideId}`, {
method: 'PATCH',
body: JSON.stringify({
slideType,
content
})
});
if (response.ok && result.success) {
// 更新 currentSlideData
if (result.data && result.data.slide) {
currentSlideData = result.data.slide;
}
// 更新 lastSavedContent
autoSaveManager.lastSavedContent = getCurrentContent();
// 更新幻灯片列表中的显示
const slideListItem = document.querySelector(`.slide-list-item[data-slide-id="${currentSlideId}"]`);
if (slideListItem) {
const slideItemTitle = slideListItem.querySelector('.slide-item-title');
const slideItemType = slideListItem.querySelector('.slide-item-type');
if (slideItemTitle) {
slideItemTitle.textContent = content.title || '无标题';
}
if (slideItemType && currentSlideData) {
slideItemType.textContent = getSlideTypeLabel(currentSlideData);
slideItemType.className = `slide-item-type ${getSlideTypeLabel(currentSlideData)}`;
}
}
// 更新按钮状态为"已保存"
updateSaveButtonStatus('saved');
} else {
// 保存失败
updateSaveButtonStatus('error');
}
} catch (error) {
// 保存失败
updateSaveButtonStatus('error');
} finally {
autoSaveManager.isSaving = false;
// 如果有待处理的保存,延迟执行
if (autoSaveManager.pendingAutoSave) {
autoSaveManager.pendingAutoSave = false;
setTimeout(() => {
if (hasChanges()) {
performAutoSave();
}
}, 500);
}
}
}
// 更新保存按钮状态
function updateSaveButtonStatus(status) {
const saveBtn = document.querySelector('.btn-primary[onclick="saveSlide()"]');
if (!saveBtn) return;
// 移除所有状态类
saveBtn.classList.remove('btn-saving', 'btn-saved', 'btn-error', 'btn-unsaved');
switch(status) {
case 'unsaved':
saveBtn.textContent = '保存(有未保存更改)';
saveBtn.classList.add('btn-unsaved');
saveBtn.disabled = false;
break;
case 'saving':
saveBtn.textContent = '保存中...';
saveBtn.classList.add('btn-saving');
saveBtn.disabled = true;
break;
case 'saved':
saveBtn.textContent = '已保存 ✓';
saveBtn.classList.add('btn-saved');
saveBtn.disabled = false;
setTimeout(() => {
if (saveBtn.textContent === '已保存 ✓') {
saveBtn.textContent = '保存';
saveBtn.classList.remove('btn-saved');
}
}, 2000);
break;
case 'error':
saveBtn.textContent = '保存失败,点击重试';
saveBtn.classList.add('btn-error');
saveBtn.disabled = false;
break;
default:
saveBtn.textContent = '保存';
saveBtn.disabled = false;
break;
}
}
// 清理自动保存事件监听器
function cleanupAutoSaveListeners() {
autoSaveManager.eventListeners.forEach(({ element, event, handler }) => {
if (element) {
element.removeEventListener(event, handler);
}
});
autoSaveManager.eventListeners = [];
}
// 设置自动保存事件监听
function setupAutoSave() {
// 先清理旧的事件监听器
cleanupAutoSaveListeners();
const titleInput = document.getElementById('slideTitle');
const richTextEditor = document.getElementById('richTextEditor');
const imagePositionSelect = document.getElementById('imagePosition');
// 标题输入监听
if (titleInput) {
const handler = () => triggerAutoSave('title');
titleInput.addEventListener('input', handler);
autoSaveManager.eventListeners.push({ element: titleInput, event: 'input', handler });
}
// 富文本编辑器监听
if (richTextEditor) {
const handler = () => triggerAutoSave('content');
richTextEditor.addEventListener('input', handler);
autoSaveManager.eventListeners.push({ element: richTextEditor, event: 'input', handler });
// 添加选择变化监听,实时更新工具栏按钮状态
const selectionHandler = () => {
updateToolbarButtons();
};
richTextEditor.addEventListener('mouseup', selectionHandler);
richTextEditor.addEventListener('keyup', selectionHandler);
document.addEventListener('selectionchange', selectionHandler);
autoSaveManager.eventListeners.push({ element: richTextEditor, event: 'mouseup', handler: selectionHandler });
autoSaveManager.eventListeners.push({ element: richTextEditor, event: 'keyup', handler: selectionHandler });
autoSaveManager.eventListeners.push({ element: document, event: 'selectionchange', handler: selectionHandler });
}
// 图片位置选择监听
if (imagePositionSelect) {
const handler = () => triggerAutoSave('imagePosition');
imagePositionSelect.addEventListener('change', handler);
autoSaveManager.eventListeners.push({ element: imagePositionSelect, event: 'change', handler });
}
// 初始化 lastSavedContent
autoSaveManager.lastSavedContent = getCurrentContent();
}
async function selectSlide(slideId) {
currentSlideId = slideId;
// 取消待处理的自动保存
clearTimeout(autoSaveManager.timer);
// 更新选中状态
document.querySelectorAll('.slide-list-item').forEach(item => {
item.classList.remove('active');
});
document.querySelector(`.slide-list-item[data-slide-id="${slideId}"]`)?.classList.add('active');
// 获取幻灯片数据
try {
const { response, result } = await apiRequest(`${API_BASE}/api/courses/nodes/${currentNodeId}/slides`, {
method: 'GET'
});
if (response.ok && result.success) {
const slide = result.data.slides.find(s => s.id === slideId);
if (slide) {
currentSlideData = slide;
renderSlideEditor(slide);
// renderSlideEditor 中会调用 setupAutoSave所以这里不需要再设置
}
}
} catch (error) {
alert('加载幻灯片失败:' + error.message);
}
}
// 渲染幻灯片编辑器(统一编辑器,同时支持文字和图片)
function renderSlideEditor(slide) {
const editor = document.getElementById('slideEditor');
const content = slide.content || {};
const title = content.title || '';
const paragraphs = content.paragraphs || [];
const imageUrl = content.imageUrl || '';
const imagePosition = content.imagePosition || 'top';
// 将段落数组合并为HTML内容改造支持HTML标签
const htmlContent = paragraphs.length > 0
? paragraphs.map(p => {
// 检查段落是否包含自定义标签
if (containsCustomTags(p)) {
// 包含自定义标签使用sanitizeHtml安全处理
return `<p>${sanitizeHtml(p)}</p>`;
} else {
// 不包含自定义标签使用escapeHtml转义向后兼容
return `<p>${escapeHtml(p)}</p>`;
}
}).join('')
: '';
const html = `
<div class="slide-editor-form">
<div class="form-group">
<label for="slideTitle">标题</label>
<input type="text" id="slideTitle" value="${escapeHtml(title)}" placeholder="请输入标题(可选)">
</div>
<div class="form-group">
<label>正文内容</label>
<div class="rich-text-toolbar">
<button type="button" class="toolbar-btn" onclick="applyBold()" title="加粗">
<strong>B</strong>
</button>
<button type="button" class="toolbar-btn" onclick="showColorMenu(this)" title="颜色强调">
颜色 ▼
</button>
<button type="button" class="toolbar-btn" onclick="applyQuote()" title="引用">
❝ 引用
</button>
<button type="button" class="toolbar-btn" onclick="applyHighlight()" title="高亮">
🖍 高亮
</button>
<div class="toolbar-separator"></div>
<button type="button" class="toolbar-btn" onclick="clearFormat()" title="清除格式">
清除格式
</button>
</div>
<div
id="richTextEditor"
contenteditable="true"
class="rich-text-editor"
placeholder="请输入正文内容(可选),支持格式化..."
>${htmlContent}</div>
<div class="editor-hint">💡 提示:按 Enter 换行Shift+Enter 换行不产生新段落</div>
</div>
<div class="form-group">
<label>图片(可选)</label>
<div class="image-upload-area" id="slideImageUpload" onclick="document.getElementById('slideImageInput').click()" style="min-height: 200px;">
${imageUrl ? `
<img src="${getImageUrl(imageUrl)}" style="max-width: 100%; max-height: 300px; border-radius: 8px;" onerror="this.onerror=null; this.style.display='none';">
<button class="btn-small btn-delete" onclick="removeSlideImage(event)" style="margin-top: 8px;">删除图片</button>
` : `
<p>点击或拖拽图片到此处上传</p>
<p style="font-size: 12px; color: #999; margin-top: 8px;">支持 JPG、PNG、WebP最大 2MB</p>
`}
</div>
<input type="file" id="slideImageInput" accept="image/jpeg,image/png,image/webp" style="display: none;" onchange="handleSlideImageSelect(event)">
</div>
${imageUrl ? `
<div class="form-group">
<label for="imagePosition">图片位置</label>
<select id="imagePosition" style="width: 100%; padding: 12px; border: 2px solid #e0e0e0; border-radius: 8px;">
<option value="top" ${imagePosition === 'top' ? 'selected' : ''}>顶部</option>
<option value="middle" ${imagePosition === 'middle' ? 'selected' : ''}>中间</option>
<option value="bottom" ${imagePosition === 'bottom' ? 'selected' : ''}>底部</option>
</select>
</div>
` : ''}
<div class="button-group" style="margin-top: 24px;">
<button class="btn-secondary" onclick="deleteCurrentSlide()">删除幻灯片</button>
<button class="btn-primary" onclick="saveSlide()">保存</button>
</div>
</div>
`;
editor.innerHTML = html;
// 保存当前图片URL到slideData中
if (currentSlideData) {
currentSlideData.content = currentSlideData.content || {};
currentSlideData.content.imageUrl = imageUrl;
}
// ✅ 设置自动保存事件监听
setTimeout(() => {
setupAutoSave();
// 初始化保存按钮状态
updateSaveButtonStatus('default');
// 初始化工具栏按钮状态
updateToolbarButtons();
}, 100);
}
// 渲染文本卡片编辑器
function renderTextSlideEditor(content) {
const title = content.title || '';
// 将段落数组合并为HTML内容段落用<p>标签分隔改造支持HTML标签
const paragraphs = content.paragraphs || [];
const htmlContent = paragraphs.length > 0
? paragraphs.map(p => {
// 检查段落是否包含自定义标签
if (containsCustomTags(p)) {
// 包含自定义标签使用sanitizeHtml安全处理
return `<p>${sanitizeHtml(p)}</p>`;
} else {
// 不包含自定义标签使用escapeHtml转义向后兼容
return `<p>${escapeHtml(p)}</p>`;
}
}).join('')
: '';
return `
<div class="form-group">
<label for="slideTitle">标题</label>
<input type="text" id="slideTitle" value="${escapeHtml(title)}" placeholder="请输入标题">
</div>
<div class="form-group">
<label>正文内容</label>
<div class="rich-text-toolbar">
<button type="button" class="toolbar-btn" onclick="applyBold()" title="加粗">
<strong>B</strong>
</button>
<button type="button" class="toolbar-btn" onclick="showColorMenu(this)" title="颜色强调">
颜色 ▼
</button>
<button type="button" class="toolbar-btn" onclick="applyQuote()" title="引用">
❝ 引用
</button>
<button type="button" class="toolbar-btn" onclick="applyHighlight()" title="高亮">
🖍 高亮
</button>
<div class="toolbar-separator"></div>
<button type="button" class="toolbar-btn" onclick="clearFormat()" title="清除格式">
清除格式
</button>
</div>
<div
id="richTextEditor"
contenteditable="true"
class="rich-text-editor"
placeholder="请输入正文内容,支持格式化..."
>${htmlContent}</div>
<div class="editor-hint">💡 提示:按 Enter 换行Shift+Enter 换行不产生新段落</div>
</div>
`;
}
// 渲染图片卡片编辑器
function renderImageSlideEditor(content) {
const title = content.title || '';
const imageUrl = content.imageUrl || '';
const imagePosition = content.imagePosition || 'top';
return `
<div class="form-group">
<label for="slideTitle">标题</label>
<input type="text" id="slideTitle" value="${escapeHtml(title)}" placeholder="请输入标题">
</div>
<div class="form-group">
<label>图片</label>
<div class="image-upload-area" id="slideImageUpload" onclick="document.getElementById('slideImageInput').click()" style="min-height: 200px;">
${imageUrl ? `
<img src="${getImageUrl(imageUrl)}" style="max-width: 100%; max-height: 300px; border-radius: 8px;" onerror="this.onerror=null; this.style.display='none';">
` : `
<p>点击或拖拽图片到此处上传</p>
<p style="font-size: 12px; color: #999; margin-top: 8px;">支持 JPG、PNG、WebP最大 2MB</p>
`}
</div>
<input type="file" id="slideImageInput" accept="image/jpeg,image/png,image/webp" style="display: none;" onchange="handleSlideImageSelect(event)">
</div>
<div class="form-group">
<label for="imagePosition">图片位置</label>
<select id="imagePosition" style="width: 100%; padding: 12px; border: 2px solid #e0e0e0; border-radius: 8px;">
<option value="top" ${imagePosition === 'top' ? 'selected' : ''}>顶部</option>
<option value="middle" ${imagePosition === 'middle' ? 'selected' : ''}>中间</option>
<option value="bottom" ${imagePosition === 'bottom' ? 'selected' : ''}>底部</option>
</select>
</div>
`;
}
// 富文本格式化函数(保留原有功能,不影响现有逻辑)
function formatText(command) {
document.execCommand(command, false, null);
document.getElementById('richTextEditor')?.focus();
}
// 清除格式(改造:清除所有自定义样式)
function clearFormat() {
console.log('✅ clearFormat called');
const editor = document.getElementById('richTextEditor');
if (!editor) {
console.error('❌ Editor not found');
return;
}
// 清除任何保存的选择范围(避免干扰)
savedSelectionRange = null;
const selection = window.getSelection();
if (selection.rangeCount === 0) {
console.log('⚠️ No selection found');
alert('请先选中要清除格式的文字');
return;
}
const range = selection.getRangeAt(0);
console.log('✅ Range found:', range.toString());
// 安全地检查范围是否在编辑器内
const container = range.commonAncestorContainer;
let isInEditor = false;
if (container.nodeType === Node.TEXT_NODE) {
isInEditor = editor.contains(container.parentElement);
} else {
isInEditor = editor.contains(container);
}
if (!isInEditor) {
console.log('⚠️ Selection not in editor');
alert('请先选中编辑器中的文字');
return;
}
const selectedText = range.toString();
if (!selectedText || selectedText.trim() === '') {
console.log('⚠️ No text selected');
alert('请先选中要清除格式的文字');
return;
}
console.log('✅ Selected text:', selectedText);
// 获取选中内容的HTML
const htmlContainer = document.createElement('div');
htmlContainer.appendChild(range.cloneContents());
let html = htmlContainer.innerHTML;
console.log('✅ Original HTML:', html);
// 移除所有自定义样式标签
html = removeStyle(html, 'b');
html = removeStyle(html, 'color'); // 这会移除所有 color 标签
html = removeStyle(html, 'span', { class: 'highlight' });
console.log('✅ Cleaned HTML:', html);
// 如果HTML为空或只有空白使用纯文本
if (!html || html.trim() === '' || html.trim() === '<br>') {
html = selectedText;
}
// 保存插入位置,以便后续恢复选择
let insertNode = null;
let insertOffset = 0;
try {
// 替换选中内容(只替换选中的部分,不影响其他内容)
range.deleteContents();
// 创建临时容器来解析HTML
const tempDiv = document.createElement('div');
tempDiv.innerHTML = html;
const fragment = document.createDocumentFragment();
while (tempDiv.firstChild) {
fragment.appendChild(tempDiv.firstChild);
}
// 插入清理后的内容
range.insertNode(fragment);
// 记录插入位置(用于恢复选择)
if (fragment.firstChild) {
if (fragment.firstChild.nodeType === Node.TEXT_NODE) {
insertNode = fragment.firstChild;
insertOffset = fragment.firstChild.textContent.length;
} else {
// 如果是元素节点,找到第一个文本节点
let textNode = fragment.firstChild;
while (textNode && textNode.nodeType !== Node.TEXT_NODE) {
textNode = textNode.firstChild;
}
if (textNode) {
insertNode = textNode;
insertOffset = textNode.textContent.length;
} else {
insertNode = fragment.firstChild;
insertOffset = 0;
}
}
} else {
// 如果没有子节点,使用范围的结束位置
insertNode = range.endContainer;
insertOffset = range.endOffset;
}
console.log('✅ Format cleared successfully');
} catch (e) {
console.error('❌ Error clearing format:', e);
alert('清除格式时出错,请重试');
return;
}
// 恢复焦点
editor.focus();
// 尝试恢复选择(让用户看到效果)
try {
const newSelection = window.getSelection();
newSelection.removeAllRanges();
const newRange = document.createRange();
if (insertNode && insertNode.nodeType === Node.TEXT_NODE) {
newRange.setStart(insertNode, 0);
newRange.setEnd(insertNode, Math.min(insertOffset, insertNode.textContent.length));
} else if (insertNode && insertNode.nodeType === Node.ELEMENT_NODE) {
if (insertNode.firstChild && insertNode.firstChild.nodeType === Node.TEXT_NODE) {
const textNode = insertNode.firstChild;
newRange.setStart(textNode, 0);
newRange.setEnd(textNode, Math.min(insertOffset, textNode.textContent.length));
} else {
newRange.selectNodeContents(insertNode);
}
} else {
// 回退:选择整个清理后的内容
const container = range.commonAncestorContainer;
if (container && container.nodeType === Node.ELEMENT_NODE) {
newRange.selectNodeContents(container);
}
}
newSelection.addRange(newRange);
console.log('✅ Selection restored after clearing format');
} catch (e) {
console.log('⚠️ Could not restore selection:', e);
}
// 添加点击反馈
const clearBtn = document.querySelector('.toolbar-btn[onclick="clearFormat()"]');
if (clearBtn) {
addButtonClickFeedback(clearBtn);
}
setTimeout(() => {
updateToolbarButtons();
}, 10);
}
// ============================================
// 自定义样式应用函数新增不影响formatText
// ============================================
/**
* 获取编辑器中选中的文本和范围重命名避免与window.getSelection冲突
* @returns {object|null} {text: string, range: Range} 或 null
*/
function getEditorSelection() {
const editor = document.getElementById('richTextEditor');
if (!editor) return null;
const selection = window.getSelection();
if (selection.rangeCount === 0) return null;
const range = selection.getRangeAt(0);
// 检查选中范围是否在编辑器内
if (!editor.contains(range.commonAncestorContainer)) {
return null;
}
const selectedText = range.toString();
return {
text: selectedText,
range: range,
editor: editor
};
}
/**
* 更新工具栏按钮状态(根据当前选中文字的样式)
*/
function updateToolbarButtons() {
const editor = document.getElementById('richTextEditor');
if (!editor) return;
const selection = window.getSelection();
if (selection.rangeCount === 0) {
// 没有选中,清除所有激活状态
document.querySelectorAll('.toolbar-btn').forEach(btn => {
btn.classList.remove('active');
});
return;
}
const range = selection.getRangeAt(0);
if (!editor.contains(range.commonAncestorContainer)) {
return;
}
// 获取选中内容的HTML
const container = document.createElement('div');
container.appendChild(range.cloneContents());
const html = container.innerHTML;
// 检测各种样式
const isBold = hasStyle(html, 'b');
const isVital = hasStyle(html, 'color', { type: 'vital' });
const isIris = hasStyle(html, 'color', { type: 'iris' });
const isNeon = hasStyle(html, 'color', { type: 'neon' });
const isHighlight = hasStyle(html, 'span', { class: 'highlight' });
// 检测引用(需要检查段落)
let isQuote = false;
let paragraph = range.commonAncestorContainer;
// 如果是文本节点,向上查找元素节点
while (paragraph && paragraph.nodeType !== Node.ELEMENT_NODE) {
paragraph = paragraph.parentNode;
}
// 如果还不是P标签尝试使用closest但需要确保是元素节点
if (paragraph && paragraph.nodeType === Node.ELEMENT_NODE) {
if (paragraph.tagName !== 'P') {
paragraph = paragraph.closest('p');
}
} else {
paragraph = null;
}
if (paragraph) {
isQuote = hasStyle(paragraph.innerHTML, 'quote');
}
// 更新按钮状态
const boldBtn = document.querySelector('.toolbar-btn[onclick="applyBold()"]');
const colorBtn = document.querySelector('.toolbar-btn[onclick*="showColorMenu"]');
const quoteBtn = document.querySelector('.toolbar-btn[onclick="applyQuote()"]');
const highlightBtn = document.querySelector('.toolbar-btn[onclick="applyHighlight()"]');
if (boldBtn) {
boldBtn.classList.toggle('active', isBold);
}
if (colorBtn) {
// 如果任意颜色已应用,激活颜色按钮
colorBtn.classList.toggle('active', isVital || isIris || isNeon);
}
if (quoteBtn) {
quoteBtn.classList.toggle('active', isQuote);
}
if (highlightBtn) {
highlightBtn.classList.toggle('active', isHighlight);
}
}
/**
* 添加按钮点击动画反馈
*/
function addButtonClickFeedback(button) {
if (!button) return;
button.classList.add('clicked');
setTimeout(() => {
button.classList.remove('clicked');
}, 300);
}
/**
* 应用加粗样式
*/
function applyBold() {
console.log('✅ applyBold called');
try {
const sel = getEditorSelection();
if (!sel) {
alert('请先选中要格式化的文字');
return;
}
if (!sel.text) {
alert('请先选中要格式化的文字');
return;
}
console.log('Selected text:', sel.text);
const range = sel.range;
const selectedText = sel.text;
// 检查是否已加粗
const container = document.createElement('div');
container.appendChild(range.cloneContents());
const html = container.innerHTML;
const isBold = hasStyle(html, 'b');
if (isBold) {
// 移除加粗,但保留内层标签
const newHtml = removeStyle(html, 'b');
range.deleteContents();
// 使用临时容器插入HTML内容保留内层标签
const tempDiv = document.createElement('div');
tempDiv.innerHTML = newHtml;
const fragment = document.createDocumentFragment();
while (tempDiv.firstChild) {
fragment.appendChild(tempDiv.firstChild);
}
range.insertNode(fragment);
} else {
// 添加加粗
const boldTag = document.createElement('b');
boldTag.textContent = selectedText;
range.deleteContents();
range.insertNode(boldTag);
}
// 恢复焦点并更新按钮状态
sel.editor.focus();
// 添加点击反馈
const boldBtn = document.querySelector('.toolbar-btn[onclick="applyBold()"]');
addButtonClickFeedback(boldBtn);
// 延迟更新按钮状态等待DOM更新
setTimeout(() => {
updateToolbarButtons();
}, 10);
} catch (error) {
console.error('❌ applyBold error:', error);
alert('应用加粗样式时出错:' + error.message);
}
}
/**
* 应用颜色样式
* @param {string} colorType - 'vital', 'iris', 'neon'
*/
function applyColor(colorType) {
if (!['vital', 'iris', 'neon'].includes(colorType)) {
console.error('Invalid color type:', colorType);
return;
}
console.log('✅ applyColor called with type:', colorType);
const editor = document.getElementById('richTextEditor');
if (!editor) {
alert('编辑器未找到');
return;
}
// 优先使用保存的选择范围
let range = null;
let selectedText = '';
if (savedSelectionRange) {
// 使用保存的选择范围
range = savedSelectionRange;
selectedText = range.toString();
console.log('✅ Using saved selection:', selectedText);
} else {
// 尝试从当前选择获取
const selection = window.getSelection();
if (selection.rangeCount > 0) {
range = selection.getRangeAt(0);
if (editor.contains(range.commonAncestorContainer)) {
selectedText = range.toString();
console.log('✅ Using current selection:', selectedText);
}
}
}
if (!range || !selectedText) {
alert('请先选中要格式化的文字');
savedSelectionRange = null; // 清除保存的选择
return;
}
console.log('Selected text:', selectedText);
// 检查是否已有该颜色
// 注意使用保存的范围时需要重新创建容器来检查HTML
const container = document.createElement('div');
if (savedSelectionRange) {
container.appendChild(savedSelectionRange.cloneContents());
} else {
container.appendChild(range.cloneContents());
}
const html = container.innerHTML;
const hasColor = hasStyle(html, 'color', { type: colorType });
// 使用保存的范围或当前范围(但必须确保范围有效且在编辑器内)
let workingRange = null;
if (savedSelectionRange) {
// 验证保存的范围是否仍然有效
try {
const testText = savedSelectionRange.toString();
if (testText && editor.contains(savedSelectionRange.commonAncestorContainer)) {
workingRange = savedSelectionRange;
console.log('✅ Using saved range');
} else {
workingRange = range;
console.log('⚠️ Saved range invalid, using current range');
}
} catch (e) {
workingRange = range;
console.log('⚠️ Saved range error, using current range:', e);
}
} else {
workingRange = range;
}
// 再次验证工作范围
if (!workingRange || !editor.contains(workingRange.commonAncestorContainer)) {
alert('选择范围无效,请重新选择');
savedSelectionRange = null;
return;
}
// 保存插入位置,以便后续恢复选择
let insertNode = null;
let insertOffset = 0;
if (hasColor) {
// 移除颜色,但保留内层标签
const newHtml = removeStyle(html, 'color', { type: colorType });
workingRange.deleteContents();
// 使用临时容器插入HTML内容保留内层标签
const tempDiv = document.createElement('div');
tempDiv.innerHTML = newHtml;
const fragment = document.createDocumentFragment();
while (tempDiv.firstChild) {
fragment.appendChild(tempDiv.firstChild);
}
workingRange.insertNode(fragment);
// 记录插入位置
insertNode = workingRange.endContainer;
insertOffset = workingRange.endOffset;
} else {
// 添加颜色
const colorTag = document.createElement('color');
colorTag.setAttribute('type', colorType);
colorTag.textContent = selectedText;
workingRange.deleteContents();
workingRange.insertNode(colorTag);
// 记录插入位置在color标签内
insertNode = colorTag.firstChild || colorTag;
insertOffset = selectedText.length;
}
// 清除保存的选择范围(操作完成后立即清除)
savedSelectionRange = null;
// 恢复焦点
editor.focus();
// 尝试恢复选择(让用户看到效果,方便后续操作)
try {
const newSelection = window.getSelection();
newSelection.removeAllRanges();
const newRange = document.createRange();
if (insertNode && insertNode.nodeType === Node.TEXT_NODE) {
// 如果是文本节点,直接设置范围
newRange.setStart(insertNode, 0);
newRange.setEnd(insertNode, Math.min(insertOffset, insertNode.textContent.length));
} else if (insertNode && insertNode.nodeType === Node.ELEMENT_NODE) {
// 如果是元素节点,选择其文本内容
if (insertNode.firstChild && insertNode.firstChild.nodeType === Node.TEXT_NODE) {
const textNode = insertNode.firstChild;
newRange.setStart(textNode, 0);
newRange.setEnd(textNode, Math.min(insertOffset, textNode.textContent.length));
} else {
newRange.selectNodeContents(insertNode);
}
} else {
// 回退选择整个color标签
const colorTags = editor.querySelectorAll('color[type="' + colorType + '"]');
if (colorTags.length > 0) {
const lastColorTag = colorTags[colorTags.length - 1];
newRange.selectNodeContents(lastColorTag);
}
}
newSelection.addRange(newRange);
console.log('✅ Selection restored after color application');
} catch (e) {
console.log('⚠️ Could not restore selection:', e);
}
// 添加点击反馈
const colorBtn = document.querySelector('.toolbar-btn[onclick*="showColorMenu"]');
addButtonClickFeedback(colorBtn);
// 延迟更新按钮状态等待DOM更新
setTimeout(() => {
updateToolbarButtons();
}, 10);
}
/**
* 应用引用样式(整段)
*/
function applyQuote() {
const sel = getEditorSelection();
if (!sel) {
alert('请先选中要格式化的段落');
return;
}
const range = sel.range;
const editor = sel.editor;
// 找到包含选中内容的段落
let paragraph = range.commonAncestorContainer;
while (paragraph && paragraph.nodeType !== Node.ELEMENT_NODE) {
paragraph = paragraph.parentNode;
}
// 如果不在段落内尝试找到最近的p标签
if (!paragraph || paragraph.tagName !== 'P') {
paragraph = range.commonAncestorContainer.closest('p');
}
if (!paragraph) {
alert('请选中段落内容');
return;
}
// 检查段落是否已经是引用
const paragraphHtml = paragraph.innerHTML;
const isQuote = hasStyle(paragraphHtml, 'quote');
if (isQuote) {
// 移除引用
const newContent = removeStyle(paragraphHtml, 'quote');
paragraph.innerHTML = newContent;
} else {
// 添加引用
const quoteTag = document.createElement('quote');
quoteTag.innerHTML = paragraph.innerHTML;
paragraph.innerHTML = '';
paragraph.appendChild(quoteTag);
}
// 恢复焦点并更新按钮状态
editor.focus();
// 添加点击反馈
const quoteBtn = document.querySelector('.toolbar-btn[onclick="applyQuote()"]');
addButtonClickFeedback(quoteBtn);
// 延迟更新按钮状态等待DOM更新
setTimeout(() => {
updateToolbarButtons();
}, 10);
}
/**
* 应用高亮样式(向后兼容)
*/
function applyHighlight() {
const sel = getEditorSelection();
if (!sel) {
alert('请先选中要格式化的文字');
return;
}
if (!sel.text) {
alert('请先选中要格式化的文字');
return;
}
const range = sel.range;
const selectedText = sel.text;
// 检查是否已高亮
const container = document.createElement('div');
container.appendChild(range.cloneContents());
const html = container.innerHTML;
console.log('Current HTML:', html);
const isHighlighted = hasStyle(html, 'span', { class: 'highlight' });
console.log('Is highlighted:', isHighlighted);
if (isHighlighted) {
// 移除高亮,但保留内层标签
let newHtml = removeStyle(html, 'span', { class: 'highlight' });
console.log('After remove highlight:', newHtml);
// 如果移除后内容为空或只有空白标签,使用原始文本
const tempCheck = document.createElement('div');
tempCheck.innerHTML = newHtml;
if (!tempCheck.textContent || tempCheck.textContent.trim() === '') {
newHtml = selectedText;
}
range.deleteContents();
// 使用临时容器插入HTML内容保留内层标签
const tempDiv = document.createElement('div');
tempDiv.innerHTML = newHtml;
const fragment = document.createDocumentFragment();
while (tempDiv.firstChild) {
fragment.appendChild(tempDiv.firstChild);
}
range.insertNode(fragment);
} else {
// 添加高亮
const spanTag = document.createElement('span');
spanTag.setAttribute('class', 'highlight');
spanTag.textContent = selectedText;
range.deleteContents();
range.insertNode(spanTag);
}
// 恢复焦点并更新按钮状态
const editor = document.getElementById('richTextEditor');
if (editor) {
editor.focus();
}
// 添加点击反馈
const highlightBtn = document.querySelector('.toolbar-btn[onclick="applyHighlight()"]');
addButtonClickFeedback(highlightBtn);
// 延迟更新按钮状态等待DOM更新
setTimeout(() => {
updateToolbarButtons();
}, 10);
}
// 保存当前选择范围(用于颜色选择)
let savedSelectionRange = null;
/**
* 显示颜色选择下拉菜单
* @param {HTMLElement} button - 触发按钮
*/
function showColorMenu(button) {
console.log('✅ showColorMenu called');
// 保存当前选择范围(在菜单显示前)
const editor = document.getElementById('richTextEditor');
if (editor) {
const selection = window.getSelection();
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
if (editor.contains(range.commonAncestorContainer)) {
savedSelectionRange = range.cloneRange(); // 克隆范围,避免丢失
console.log('✅ Selection saved:', savedSelectionRange.toString());
} else {
savedSelectionRange = null;
}
} else {
savedSelectionRange = null;
}
}
// 移除已存在的菜单
const existingMenu = document.getElementById('colorMenu');
if (existingMenu) {
existingMenu.remove();
return;
}
// 创建菜单
const menu = document.createElement('div');
menu.id = 'colorMenu';
menu.style.cssText = `
position: absolute;
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
padding: 8px;
z-index: 1000;
min-width: 150px;
`;
const colors = [
{ type: 'vital', name: 'Vital电光蓝', color: '#2266FF' },
{ type: 'iris', name: 'Iris赛博紫', color: '#8A4FFF' },
{ type: 'neon', name: 'Neon霓虹粉', color: '#FF4081' }
];
colors.forEach(color => {
const item = document.createElement('div');
item.style.cssText = `
padding: 8px 12px;
cursor: pointer;
border-radius: 4px;
display: flex;
align-items: center;
gap: 8px;
`;
item.innerHTML = `
<span style="width: 20px; height: 20px; background: ${color.color}; border-radius: 4px; display: inline-block;"></span>
<span>${color.name}</span>
`;
item.onmouseenter = () => {
item.style.background = '#f0f0f0';
};
item.onmouseleave = () => {
item.style.background = 'transparent';
};
item.onclick = (e) => {
e.stopPropagation(); // 阻止事件冒泡
e.preventDefault(); // 阻止默认行为
// 先关闭菜单
menu.remove();
// 立即应用颜色(使用保存的选择范围)
applyColor(color.type);
};
menu.appendChild(item);
});
// 定位菜单
const rect = button.getBoundingClientRect();
menu.style.left = rect.left + 'px';
menu.style.top = (rect.bottom + 4) + 'px';
document.body.appendChild(menu);
// 点击外部关闭菜单
const closeMenu = (e) => {
if (!menu.contains(e.target) && e.target !== button) {
menu.remove();
document.removeEventListener('click', closeMenu);
}
};
setTimeout(() => {
document.addEventListener('click', closeMenu);
}, 0);
}
// ============================================
// 确保所有样式函数在全局作用域可用
// ============================================
window.applyBold = applyBold;
window.applyQuote = applyQuote;
window.applyHighlight = applyHighlight;
window.showColorMenu = showColorMenu;
window.applyColor = applyColor;
// 页面加载完成后,测试函数是否可用
if (typeof window !== 'undefined') {
console.log('✅ 样式函数已注册到全局作用域');
console.log('applyBold:', typeof window.applyBold);
console.log('applyQuote:', typeof window.applyQuote);
console.log('applyHighlight:', typeof window.applyHighlight);
console.log('showColorMenu:', typeof window.showColorMenu);
console.log('applyColor:', typeof window.applyColor);
}
// 处理图片选择
async function handleSlideImageSelect(event) {
const file = event.target.files[0];
if (!file) return;
// 验证文件
const validTypes = ['image/jpeg', 'image/png', 'image/webp'];
if (!validTypes.includes(file.type)) {
alert('只支持 JPG、PNG、WebP 格式');
return;
}
if (file.size > 2 * 1024 * 1024) {
alert('文件大小不能超过 2MB');
return;
}
// 上传图片
try {
const token = getToken();
const formData = new FormData();
formData.append('image', file);
const xhr = new XMLHttpRequest();
xhr.open('POST', `${API_BASE}/api/upload/image`);
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
xhr.onload = () => {
if (xhr.status === 200) {
const result = JSON.parse(xhr.responseText);
if (result.success) {
const imageUrl = `${API_BASE}/${result.data.imageUrl}`;
// ✅ 修复问题1在重新渲染之前先保存当前编辑器的内容
// 保存标题
const titleInput = document.getElementById('slideTitle');
if (titleInput) {
if (!currentSlideData.content) {
currentSlideData.content = {};
}
currentSlideData.content.title = titleInput.value.trim() || null;
}
// 保存正文内容(使用和 getCurrentContent() 相同的逻辑)
const richTextEditor = document.getElementById('richTextEditor');
if (richTextEditor) {
const paragraphElements = richTextEditor.querySelectorAll('p');
let paragraphs = [];
if (paragraphElements.length > 0) {
paragraphs = Array.from(paragraphElements)
.map(p => {
// 获取段落的HTML内容
let html = p.innerHTML;
// ✅ 关键修复:检查段落是否有实际文本内容
const textContent = p.textContent.trim();
if (!textContent || textContent.length === 0) {
// 如果段落没有文本内容,跳过(不保存空段落)
return null;
}
// 检查是否包含自定义标签
if (containsCustomTags(html)) {
// 包含自定义标签,清理并保留
const cleanedHtml = cleanHtmlContent(html);
// ✅ 再次检查清理后的HTML是否还有文本内容
const tempCheck = document.createElement('div');
tempCheck.innerHTML = cleanedHtml;
if (!tempCheck.textContent || tempCheck.textContent.trim().length === 0) {
// 如果清理后没有文本内容,返回纯文本(避免保存空标签)
return textContent;
}
return cleanedHtml;
} else {
// 不包含自定义标签使用textContent向后兼容
return textContent;
}
})
.filter(p => p !== null && p.length > 0); // 过滤掉null和空字符串
} else {
// 如果没有p标签检查整个编辑器内容
const text = richTextEditor.textContent.trim();
if (text) {
// 检查是否包含HTML标签
const html = richTextEditor.innerHTML;
if (containsCustomTags(html)) {
// 包含自定义标签,清理并保留
const cleanedHtml = cleanHtmlContent(html);
// ✅ 再次检查清理后的HTML是否还有文本内容
const tempCheck = document.createElement('div');
tempCheck.innerHTML = cleanedHtml;
if (!tempCheck.textContent || tempCheck.textContent.trim().length === 0) {
// 如果清理后没有文本内容,返回纯文本(避免保存空标签)
paragraphs = [text];
} else {
paragraphs = [cleanedHtml];
}
} else {
// 不包含自定义标签,按行分割(向后兼容)
paragraphs = text.split('\n').filter(p => p.trim());
}
}
}
if (!currentSlideData.content) {
currentSlideData.content = {};
}
currentSlideData.content.paragraphs = paragraphs.length > 0 ? paragraphs : null;
}
// 保存图片URL
if (!currentSlideData.content) {
currentSlideData.content = {};
}
currentSlideData.content.imageUrl = imageUrl;
// 重新渲染编辑器以显示图片和位置选择器
renderSlideEditor(currentSlideData);
// ✅ 图片上传成功后立即保存(不需要防抖)
setTimeout(() => {
if (hasChanges()) {
performAutoSave();
}
}, 100);
} else {
alert('上传失败:' + (result.error?.message || '未知错误'));
}
} else {
alert('上传失败');
}
};
xhr.onerror = () => {
alert('网络错误');
};
xhr.send(formData);
} catch (error) {
alert('上传失败:' + error.message);
}
}
// 移除图片
function removeSlideImage(event) {
if (event) {
event.stopPropagation();
}
if (!confirm('确定要删除这张图片吗?')) {
return;
}
// ✅ 修复问题1在重新渲染之前先保存当前编辑器的内容
// 保存标题
const titleInput = document.getElementById('slideTitle');
if (titleInput && currentSlideData) {
if (!currentSlideData.content) {
currentSlideData.content = {};
}
currentSlideData.content.title = titleInput.value.trim() || null;
}
// 保存正文内容(使用和 getCurrentContent() 相同的逻辑)
const richTextEditor = document.getElementById('richTextEditor');
if (richTextEditor && currentSlideData) {
const paragraphElements = richTextEditor.querySelectorAll('p');
let paragraphs = [];
if (paragraphElements.length > 0) {
paragraphs = Array.from(paragraphElements)
.map(p => {
// 获取段落的HTML内容
let html = p.innerHTML;
// ✅ 关键修复:检查段落是否有实际文本内容
const textContent = p.textContent.trim();
if (!textContent || textContent.length === 0) {
// 如果段落没有文本内容,跳过(不保存空段落)
return null;
}
// 检查是否包含自定义标签
if (containsCustomTags(html)) {
// 包含自定义标签,清理并保留
const cleanedHtml = cleanHtmlContent(html);
// ✅ 再次检查清理后的HTML是否还有文本内容
const tempCheck = document.createElement('div');
tempCheck.innerHTML = cleanedHtml;
if (!tempCheck.textContent || tempCheck.textContent.trim().length === 0) {
// 如果清理后没有文本内容,返回纯文本(避免保存空标签)
return textContent;
}
return cleanedHtml;
} else {
// 不包含自定义标签使用textContent向后兼容
return textContent;
}
})
.filter(p => p !== null && p.length > 0); // 过滤掉null和空字符串
} else {
// 如果没有p标签检查整个编辑器内容
const text = richTextEditor.textContent.trim();
if (text) {
// 检查是否包含HTML标签
const html = richTextEditor.innerHTML;
if (containsCustomTags(html)) {
// 包含自定义标签,清理并保留
const cleanedHtml = cleanHtmlContent(html);
// ✅ 再次检查清理后的HTML是否还有文本内容
const tempCheck = document.createElement('div');
tempCheck.innerHTML = cleanedHtml;
if (!tempCheck.textContent || tempCheck.textContent.trim().length === 0) {
// 如果清理后没有文本内容,返回纯文本(避免保存空标签)
paragraphs = [text];
} else {
paragraphs = [cleanedHtml];
}
} else {
// 不包含自定义标签,按行分割(向后兼容)
paragraphs = text.split('\n').filter(p => p.trim());
}
}
}
if (!currentSlideData.content) {
currentSlideData.content = {};
}
currentSlideData.content.paragraphs = paragraphs.length > 0 ? paragraphs : null;
}
if (currentSlideData && currentSlideData.content) {
currentSlideData.content.imageUrl = null;
// 重新渲染编辑器
renderSlideEditor(currentSlideData);
}
}
// 保存幻灯片(根据内容自动推断类型)- 手动保存
async function saveSlide() {
if (!currentSlideId || !currentSlideData) {
alert('请先选择要编辑的幻灯片');
return;
}
// 取消待处理的自动保存
clearTimeout(autoSaveManager.timer);
autoSaveManager.pendingAutoSave = false;
// 获取所有字段
const content = getCurrentContent();
const imageUrl = content.imageUrl;
const imagePosition = content.imagePosition || 'top';
// 根据内容自动推断类型(保持兼容性)
let slideType = 'text';
if (imageUrl) {
slideType = 'image';
} else if (content.paragraphs && content.paragraphs.length > 0 || content.title) {
slideType = 'text';
}
// 更新保存状态
updateSaveButtonStatus('saving');
try {
const { response, result } = await apiRequest(`${API_BASE}/api/courses/nodes/${currentNodeId}/slides/${currentSlideId}`, {
method: 'PATCH',
body: JSON.stringify({
slideType,
content
})
});
if (response.ok && result.success) {
// ✅ 修复问题2只更新当前幻灯片数据不重新加载整个页面
if (result.data && result.data.slide) {
// 更新 currentSlideData 为服务器返回的最新数据
currentSlideData = result.data.slide;
// 重新渲染编辑器以显示最新数据(保持当前选中状态)
renderSlideEditor(currentSlideData);
}
// 更新幻灯片列表中的显示
const slideListItem = document.querySelector(`.slide-list-item[data-slide-id="${currentSlideId}"]`);
if (slideListItem) {
const slideItemTitle = slideListItem.querySelector('.slide-item-title');
const slideItemType = slideListItem.querySelector('.slide-item-type');
if (slideItemTitle) {
slideItemTitle.textContent = content.title || '无标题';
}
if (slideItemType && currentSlideData) {
slideItemType.textContent = getSlideTypeLabel(currentSlideData);
slideItemType.className = `slide-item-type ${getSlideTypeLabel(currentSlideData)}`;
}
}
// 更新 lastSavedContent
autoSaveManager.lastSavedContent = getCurrentContent();
// 更新按钮状态
updateSaveButtonStatus('saved');
// 显示成功提示(手动保存显示提示)
const saveMessage = document.createElement('div');
saveMessage.className = 'message success';
saveMessage.style.cssText = 'position: fixed; top: 20px; right: 20px; padding: 12px 20px; background: #10b981; color: white; border-radius: 8px; z-index: 10000; box-shadow: 0 4px 6px rgba(0,0,0,0.1);';
saveMessage.textContent = '✅ 保存成功!节点时长已自动更新';
document.body.appendChild(saveMessage);
setTimeout(() => {
saveMessage.style.opacity = '0';
saveMessage.style.transition = 'opacity 0.3s';
setTimeout(() => saveMessage.remove(), 300);
}, 2000);
} else {
updateSaveButtonStatus('error');
alert('保存失败:' + (result.error?.message || '未知错误'));
}
} catch (error) {
updateSaveButtonStatus('error');
alert('保存失败:' + error.message);
}
}
// 添加幻灯片(不再需要选择类型)
async function addSlide() {
// 直接创建空内容的幻灯片,类型会根据内容自动推断
const content = {
title: '',
paragraphs: null,
imageUrl: null,
imagePosition: 'top'
};
try {
// 先创建一个默认类型的幻灯片,保存时会自动推断
const { response, result } = await apiRequest(`${API_BASE}/api/courses/nodes/${currentNodeId}/slides`, {
method: 'POST',
body: JSON.stringify({
slideType: 'text', // 默认类型,保存时会根据内容自动更新
content
})
});
if (response.ok && result.success) {
// 重新加载(会自动计算时长)
await editNodeContent(currentNodeId, currentNodeTitle);
// 自动选择新创建的幻灯片进行编辑
if (result.data && result.data.slide) {
setTimeout(() => {
selectSlide(result.data.slide.id);
}, 100);
}
} else {
alert('创建失败:' + (result.error?.message || '未知错误'));
}
} catch (error) {
alert('创建失败:' + error.message);
}
}
// 删除当前幻灯片
async function deleteCurrentSlide() {
if (!currentSlideId) {
alert('请先选择要删除的幻灯片');
return;
}
if (!confirm('确定要删除这个幻灯片吗?')) {
return;
}
try {
const { response, result } = await apiRequest(`${API_BASE}/api/courses/nodes/${currentNodeId}/slides/${currentSlideId}`, {
method: 'DELETE'
});
if (response.ok && result.success) {
// 重新加载(会自动计算时长)
await editNodeContent(currentNodeId, currentNodeTitle);
// 延迟提示
setTimeout(() => {
alert('删除成功!节点时长已自动更新');
}, 300);
} else {
alert('删除失败:' + (result.error?.message || '未知错误'));
}
} catch (error) {
alert('删除失败:' + error.message);
}
}
// 上移幻灯片
async function moveSlideUp(slideId, event) {
if (event) {
event.stopPropagation();
}
try {
// 获取当前所有幻灯片
const { response, result } = await apiRequest(`${API_BASE}/api/courses/nodes/${currentNodeId}/slides`, {
method: 'GET'
});
if (!response.ok || !result.success) {
alert('获取幻灯片列表失败');
return;
}
const slides = result.data.slides;
const currentIndex = slides.findIndex(s => s.id === slideId);
if (currentIndex <= 0) {
return; // 已经在最上面
}
// 交换位置
const newOrder = [...slides];
[newOrder[currentIndex - 1], newOrder[currentIndex]] = [newOrder[currentIndex], newOrder[currentIndex - 1]];
// 更新顺序
const { response: reorderResponse, result: reorderResult } = await apiRequest(`${API_BASE}/api/courses/nodes/${currentNodeId}/slides/reorder`, {
method: 'PATCH',
body: JSON.stringify({
slides: newOrder.map((s, index) => ({ id: s.id, order: index }))
})
});
if (reorderResponse.ok && reorderResult.success) {
// 重新加载
await editNodeContent(currentNodeId, currentNodeTitle);
} else {
alert('更新顺序失败:' + (reorderResult.error?.message || '未知错误'));
}
} catch (error) {
alert('更新顺序失败:' + error.message);
}
}
// 下移幻灯片
async function moveSlideDown(slideId, event) {
if (event) {
event.stopPropagation();
}
try {
// 获取当前所有幻灯片
const { response, result } = await apiRequest(`${API_BASE}/api/courses/nodes/${currentNodeId}/slides`, {
method: 'GET'
});
if (!response.ok || !result.success) {
alert('获取幻灯片列表失败');
return;
}
const slides = result.data.slides;
const currentIndex = slides.findIndex(s => s.id === slideId);
if (currentIndex >= slides.length - 1) {
return; // 已经在最下面
}
// 交换位置
const newOrder = [...slides];
[newOrder[currentIndex], newOrder[currentIndex + 1]] = [newOrder[currentIndex + 1], newOrder[currentIndex]];
// 更新顺序
const { response: reorderResponse, result: reorderResult } = await apiRequest(`${API_BASE}/api/courses/nodes/${currentNodeId}/slides/reorder`, {
method: 'PATCH',
body: JSON.stringify({
slides: newOrder.map((s, index) => ({ id: s.id, order: index }))
})
});
if (reorderResponse.ok && reorderResult.success) {
// 重新加载
await editNodeContent(currentNodeId, currentNodeTitle);
} else {
alert('更新顺序失败:' + (reorderResult.error?.message || '未知错误'));
}
} catch (error) {
alert('更新顺序失败:' + error.message);
}
}
// ==================== 脑图导入功能 ====================
// 显示导入脑图模态框
window.showImportMindMapModal = function(courseId, chapterId = null) {
const modalHtml = `
<div class="modal-overlay" id="importMindMapModal">
<div class="modal-content">
<div class="modal-header">
<h3>导入脑图</h3>
<button class="modal-close" onclick="closeImportMindMapModal()">×</button>
</div>
<div class="format-hint">
<strong>格式说明:</strong><br>
• 无缩进 = 节点标题<br>
• 2个空格缩进 = 幻灯片标题<br>
• 4个空格缩进 = 幻灯片内容段落(保留完整文本,包括序号)<br>
<strong>示例:</strong><br>
<code>节点标题1<br>
&nbsp;&nbsp;幻灯片标题1<br>
&nbsp;&nbsp;&nbsp;&nbsp;内容段落1<br>
&nbsp;&nbsp;&nbsp;&nbsp;1、列表项1<br>
&nbsp;&nbsp;&nbsp;&nbsp;2、列表项2</code>
</div>
<div class="form-group" style="margin-bottom: 15px;">
<label style="margin-bottom: 8px;">导入模式:</label>
<div style="display: flex; gap: 15px;">
<label style="display: flex; align-items: center; cursor: pointer;">
<input type="radio" name="importMode" value="overwrite" checked style="width: auto; margin-right: 6px;">
<span>覆盖模式(删除现有节点和幻灯片)</span>
</label>
<label style="display: flex; align-items: center; cursor: pointer;">
<input type="radio" name="importMode" value="append" style="width: auto; margin-right: 6px;">
<span>新增模式(追加到现有节点后面)</span>
</label>
</div>
</div>
<textarea id="mindMapText" class="mindmap-textarea" placeholder="请按格式输入脑图内容..."></textarea>
<div id="previewSection" class="preview-section" style="display: none;">
<h4>预览结构:</h4>
<div id="previewContent"></div>
</div>
<div class="modal-actions">
<button class="btn-secondary" onclick="closeImportMindMapModal()">取消</button>
<button class="btn-secondary" onclick="previewMindMap()">预览结构</button>
<button class="btn-primary" onclick="importMindMap('${courseId}')">导入</button>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
// 监听文本变化,自动预览
const textarea = document.getElementById('mindMapText');
let previewTimer = null;
textarea.addEventListener('input', () => {
clearTimeout(previewTimer);
previewTimer = setTimeout(() => {
previewMindMap();
}, 500);
});
};
// 关闭导入脑图模态框
window.closeImportMindMapModal = function() {
const modal = document.getElementById('importMindMapModal');
if (modal) {
modal.remove();
}
};
// 解析脑图文本(前端预览用)
function parseMindMapText(text) {
const lines = text.split('\n');
const nodes = [];
let currentNode = null;
let currentSlide = null;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmedLine = line.trim();
if (trimmedLine === '') continue;
const indentMatch = line.match(/^(\s*)/);
const indentSpaces = indentMatch ? indentMatch[1].length : 0;
const indentLevel = Math.floor(indentSpaces / 2);
if (indentLevel === 0) {
if (currentNode) {
if (currentSlide) {
currentNode.slides.push(currentSlide);
currentSlide = null;
}
nodes.push(currentNode);
}
currentNode = { title: trimmedLine, slides: [] };
currentSlide = null;
} else if (indentLevel === 1) {
if (currentSlide && currentNode) {
currentNode.slides.push(currentSlide);
}
currentSlide = { title: trimmedLine, paragraphs: [] };
} else if (indentLevel >= 2) {
if (currentSlide) {
// ✅ 保留完整文本,不做特殊处理
currentSlide.paragraphs.push(trimmedLine);
} else if (currentNode) {
currentSlide = { title: '内容', paragraphs: [trimmedLine] };
}
}
}
if (currentNode) {
if (currentSlide) {
currentNode.slides.push(currentSlide);
}
nodes.push(currentNode);
}
return nodes;
}
// 预览脑图结构
window.previewMindMap = function() {
const textarea = document.getElementById('mindMapText');
const previewSection = document.getElementById('previewSection');
const previewContent = document.getElementById('previewContent');
const text = textarea.value.trim();
if (!text) {
previewSection.style.display = 'none';
return;
}
try {
const parsedNodes = parseMindMapText(text);
if (parsedNodes.length === 0) {
previewContent.innerHTML = '<div style="color: #999; font-size: 12px;">未解析到任何节点,请检查格式</div>';
previewSection.style.display = 'block';
return;
}
let html = '';
parsedNodes.forEach((node, nodeIndex) => {
html += `
<div class="preview-node">
<div class="preview-node-title">${nodeIndex + 1}. ${escapeHtml(node.title)}</div>
${node.slides.map((slide, slideIndex) => `
<div class="preview-slide">
<div class="preview-slide-title">${slideIndex + 1}. ${escapeHtml(slide.title)}</div>
${slide.paragraphs.length > 0 ? `
<div class="preview-slide-content">
${slide.paragraphs.map(p => `• ${escapeHtml(p)}`).join('<br>')}
</div>
` : '<div class="preview-slide-content" style="color: #999;">(无内容)</div>'}
</div>
`).join('')}
</div>
`;
});
previewContent.innerHTML = html;
previewSection.style.display = 'block';
} catch (error) {
previewContent.innerHTML = `<div style="color: #ef4444; font-size: 12px;">解析错误:${error.message}</div>`;
previewSection.style.display = 'block';
}
};
// 导入脑图
window.importMindMap = async function(courseId, chapterId = null) {
const textarea = document.getElementById('mindMapText');
const text = textarea.value.trim();
if (!text) {
alert('请输入脑图内容');
return;
}
// 验证格式
const parsedNodes = parseMindMapText(text);
if (parsedNodes.length === 0) {
alert('解析失败:未找到任何节点,请检查文本格式');
return;
}
// 验证每个节点
for (const node of parsedNodes) {
if (!node.title || node.title.trim() === '') {
alert('解析失败:存在空节点标题');
return;
}
if (node.slides.length === 0) {
alert(`解析失败:节点"${node.title}"没有幻灯片`);
return;
}
for (const slide of node.slides) {
if (!slide.title || slide.title.trim() === '') {
alert(`解析失败:节点"${node.title}"存在空幻灯片标题`);
return;
}
}
}
// 获取导入模式
const importMode = document.querySelector('input[name="importMode"]:checked')?.value || 'overwrite';
const overwrite = importMode === 'overwrite';
// 确认提示
const totalSlides = parsedNodes.reduce((sum, n) => sum + n.slides.length, 0);
let confirmMessage = `将${overwrite ? '导入' : '新增'} ${parsedNodes.length} 个节点,共 ${totalSlides} 个幻灯片。\n\n`;
if (overwrite) {
confirmMessage += '注意:这将覆盖课程现有的所有节点和幻灯片!\n\n';
} else {
confirmMessage += '新节点将追加到现有节点后面。\n\n';
}
confirmMessage += '确定要继续吗?';
if (!confirm(confirmMessage)) {
return;
}
const importBtn = event.target;
const originalText = importBtn.textContent;
importBtn.disabled = true;
importBtn.textContent = '导入中...';
try {
const { response, result } = await apiRequest(`${API_BASE}/api/courses/${courseId}/import-mindmap`, {
method: 'POST',
body: JSON.stringify({
text: text,
overwrite: overwrite
})
});
if (response.ok && result.success) {
const actionText = overwrite ? '导入' : '新增';
alert(`✅ ${result.data.message}\n\n已${actionText} ${result.data.nodes.length} 个节点`);
closeImportMindMapModal();
// 刷新课程编辑页面
if (currentCourseId === courseId) {
await editCourse(courseId);
}
} else {
alert('导入失败:' + (result.error?.message || '未知错误'));
}
} catch (error) {
if (error.message.includes('登录已过期')) {
return; // 已跳转登录页
}
alert('导入失败:' + error.message);
} finally {
importBtn.disabled = false;
importBtn.textContent = originalText;
}
};
// 点击模态框外部关闭
document.addEventListener('click', (e) => {
const modal = document.getElementById('importMindMapModal');
if (modal && e.target === modal) {
closeImportMindMapModal();
}
});
// ==================== 章节导出功能 ====================
// 显示导出章节模态框
window.showExportChapterModal = function(courseId, chapterId, chapterTitle) {
const modalHtml = `
<div class="modal-overlay" id="exportChapterModal">
<div class="modal-content">
<div class="modal-header">
<h3>导出章节:${escapeHtml(chapterTitle)}</h3>
<button class="modal-close" onclick="closeExportChapterModal()">×</button>
</div>
<div class="form-group" style="margin-bottom: 20px;">
<label style="margin-bottom: 12px; font-weight: 600;">导出内容:</label>
<div style="display: flex; flex-direction: column; gap: 12px;">
<label style="display: flex; align-items: center; cursor: pointer;">
<input type="checkbox" id="exportIncludeNodeTitle" checked style="width: auto; margin-right: 8px;">
<span>包含节点标题</span>
</label>
<label style="display: flex; align-items: center; cursor: pointer;">
<input type="checkbox" id="exportIncludeSlideTitle" checked style="width: auto; margin-right: 8px;">
<span>包含幻灯片标题</span>
</label>
<label style="display: flex; align-items: center; cursor: pointer;">
<input type="checkbox" id="exportIncludeSlideContent" checked style="width: auto; margin-right: 8px;">
<span>包含幻灯片内容</span>
</label>
</div>
</div>
<div class="form-group" style="margin-bottom: 20px;">
<label style="margin-bottom: 12px; font-weight: 600;">导出格式:</label>
<div style="display: flex; flex-direction: column; gap: 12px;">
<label style="display: flex; align-items: center; cursor: pointer;">
<input type="radio" name="exportFormat" value="text" checked style="width: auto; margin-right: 8px;">
<span>纯文本 (.txt)</span>
</label>
<label style="display: flex; align-items: center; cursor: pointer;">
<input type="radio" name="exportFormat" value="markdown" style="width: auto; margin-right: 8px;">
<span>Markdown (.md)</span>
</label>
<label style="display: flex; align-items: center; cursor: pointer;">
<input type="radio" name="exportFormat" value="json" style="width: auto; margin-right: 8px;">
<span>JSON (.json)</span>
</label>
</div>
</div>
<div class="modal-actions">
<button class="btn-secondary" onclick="closeExportChapterModal()">取消</button>
<button class="btn-primary" onclick="exportChapter('${courseId}', '${chapterId}')">导出</button>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
};
// 关闭导出章节模态框
window.closeExportChapterModal = function() {
const modal = document.getElementById('exportChapterModal');
if (modal) {
modal.remove();
}
};
// 导出章节
window.exportChapter = async function(courseId, chapterId) {
// 获取配置
const includeNodeTitle = document.getElementById('exportIncludeNodeTitle')?.checked ?? true;
const includeSlideTitle = document.getElementById('exportIncludeSlideTitle')?.checked ?? true;
const includeSlideContent = document.getElementById('exportIncludeSlideContent')?.checked ?? true;
const format = document.querySelector('input[name="exportFormat"]:checked')?.value || 'text';
// 构建查询参数
const params = new URLSearchParams({
includeNodeTitle: includeNodeTitle.toString(),
includeSlideTitle: includeSlideTitle.toString(),
includeSlideContent: includeSlideContent.toString(),
format: format,
});
const exportBtn = event.target;
const originalText = exportBtn.textContent;
exportBtn.disabled = true;
exportBtn.textContent = '导出中...';
try {
const token = getToken();
const url = `${API_BASE}/api/courses/${courseId}/chapters/${chapterId}/export?${params.toString()}`;
// 使用fetch下载文件
const response = await fetch(url, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: { message: '导出失败' } }));
throw new Error(errorData.error?.message || '导出失败');
}
// 获取文件名
const contentDisposition = response.headers.get('Content-Disposition');
let fileName = 'chapter_export';
if (contentDisposition) {
const fileNameMatch = contentDisposition.match(/filename="?([^"]+)"?/);
if (fileNameMatch) {
fileName = decodeURIComponent(fileNameMatch[1]);
}
} else {
// 根据格式设置默认文件名
const extensions = { text: '.txt', markdown: '.md', json: '.json' };
fileName = `chapter_export${extensions[format] || '.txt'}`;
}
// 获取文件内容
const blob = await response.blob();
// 创建下载链接
const downloadUrl = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = downloadUrl;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(downloadUrl);
// 关闭模态框
closeExportChapterModal();
// 显示成功提示
alert('✅ 导出成功!');
} catch (error) {
if (error.message.includes('登录已过期')) {
return; // 已跳转登录页
}
alert('导出失败:' + error.message);
} finally {
exportBtn.disabled = false;
exportBtn.textContent = originalText;
}
};
// ==================== 小节课导出功能 ====================
// 显示导出课程模态框(小节课)
window.showExportCourseModal = function(courseId, courseTitle) {
const modalHtml = `
<div class="modal-overlay" id="exportCourseModal">
<div class="modal-content">
<div class="modal-header">
<h3>导出课程:${escapeHtml(courseTitle)}</h3>
<button class="modal-close" onclick="closeExportCourseModal()">×</button>
</div>
<div class="form-group" style="margin-bottom: 20px;">
<label style="margin-bottom: 12px; font-weight: 600;">导出内容:</label>
<div style="display: flex; flex-direction: column; gap: 12px;">
<label style="display: flex; align-items: center; cursor: pointer;">
<input type="checkbox" id="exportCourseIncludeNodeTitle" checked style="width: auto; margin-right: 8px;">
<span>包含节点标题</span>
</label>
<label style="display: flex; align-items: center; cursor: pointer;">
<input type="checkbox" id="exportCourseIncludeSlideTitle" checked style="width: auto; margin-right: 8px;">
<span>包含幻灯片标题</span>
</label>
<label style="display: flex; align-items: center; cursor: pointer;">
<input type="checkbox" id="exportCourseIncludeSlideContent" checked style="width: auto; margin-right: 8px;">
<span>包含幻灯片内容</span>
</label>
</div>
</div>
<div class="form-group" style="margin-bottom: 20px;">
<label style="margin-bottom: 12px; font-weight: 600;">导出格式:</label>
<div style="display: flex; flex-direction: column; gap: 12px;">
<label style="display: flex; align-items: center; cursor: pointer;">
<input type="radio" name="exportCourseFormat" value="text" checked style="width: auto; margin-right: 8px;">
<span>纯文本 (.txt)</span>
</label>
<label style="display: flex; align-items: center; cursor: pointer;">
<input type="radio" name="exportCourseFormat" value="markdown" style="width: auto; margin-right: 8px;">
<span>Markdown (.md)</span>
</label>
<label style="display: flex; align-items: center; cursor: pointer;">
<input type="radio" name="exportCourseFormat" value="json" style="width: auto; margin-right: 8px;">
<span>JSON (.json)</span>
</label>
</div>
</div>
<div class="modal-actions">
<button class="btn-secondary" onclick="closeExportCourseModal()">取消</button>
<button class="btn-primary" onclick="exportCourse('${courseId}')">导出</button>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
};
// 关闭导出课程模态框
window.closeExportCourseModal = function() {
const modal = document.getElementById('exportCourseModal');
if (modal) {
modal.remove();
}
};
// 导出课程(小节课)
window.exportCourse = async function(courseId) {
// 获取配置
const includeNodeTitle = document.getElementById('exportCourseIncludeNodeTitle')?.checked ?? true;
const includeSlideTitle = document.getElementById('exportCourseIncludeSlideTitle')?.checked ?? true;
const includeSlideContent = document.getElementById('exportCourseIncludeSlideContent')?.checked ?? true;
const format = document.querySelector('input[name="exportCourseFormat"]:checked')?.value || 'text';
// 构建查询参数
const params = new URLSearchParams({
includeNodeTitle: includeNodeTitle.toString(),
includeSlideTitle: includeSlideTitle.toString(),
includeSlideContent: includeSlideContent.toString(),
format: format,
});
// 获取导出按钮
const exportBtn = document.querySelector('#exportCourseModal .btn-primary[onclick*="exportCourse"]');
const originalText = exportBtn?.textContent || '导出';
if (exportBtn) {
exportBtn.disabled = true;
exportBtn.textContent = '导出中...';
}
try {
const token = getToken();
const url = `${API_BASE}/api/courses/${courseId}/export?${params.toString()}`;
const response = await fetch(url, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: { message: '导出失败' } }));
throw new Error(errorData.error?.message || '导出失败');
}
// 获取文件名
const contentDisposition = response.headers.get('Content-Disposition');
let fileName = 'course_export';
if (contentDisposition) {
const fileNameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
if (fileNameMatch && fileNameMatch[1]) {
fileName = decodeURIComponent(fileNameMatch[1].replace(/['"]/g, ''));
}
} else {
// 根据格式设置默认文件名
const ext = format === 'json' ? 'json' : format === 'markdown' ? 'md' : 'txt';
fileName = `course_export.${ext}`;
}
// 获取内容
const blob = await response.blob();
const downloadUrl = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = downloadUrl;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(downloadUrl);
closeExportCourseModal();
// 显示成功提示
alert('✅ 导出成功!');
} catch (error) {
if (error && error.message && error.message.includes('登录已过期')) {
return; // 已跳转登录页
}
alert('导出失败:' + (error && error.message ? error.message : '未知错误'));
} finally {
if (exportBtn) {
exportBtn.disabled = false;
exportBtn.textContent = originalText;
}
}
};
// 点击模态框外部关闭
document.addEventListener('click', (e) => {
const exportChapterModal = document.getElementById('exportChapterModal');
if (exportChapterModal && e.target === exportChapterModal) {
closeExportChapterModal();
}
const exportCourseModal = document.getElementById('exportCourseModal');
if (exportCourseModal && e.target === exportCourseModal) {
closeExportCourseModal();
}
});
// ==================== 批量图片上传功能 ====================
// ✅ 新增功能:不影响原有功能,独立实现
// 1. 显示批量上传模态框
function showBatchUploadModal() {
if (!currentNodeId) {
alert('请先选择节点');
return;
}
if (allSlides.length === 0) {
alert('当前节点没有卡片,无法匹配图片');
return;
}
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/jpeg,image/png,image/webp';
input.multiple = true; // 支持多选
input.onchange = handleBatchImageSelect;
input.click();
}
// 2. 处理批量图片选择
async function handleBatchImageSelect(event) {
const files = Array.from(event.target.files);
if (files.length === 0) return;
// 验证文件
const validFiles = files.filter(file => {
const validTypes = ['image/jpeg', 'image/png', 'image/webp'];
return validTypes.includes(file.type) && file.size <= 2 * 1024 * 1024;
});
if (validFiles.length === 0) {
alert('没有符合要求的图片文件(支持 JPG、PNG、WebP最大 2MB');
return;
}
if (validFiles.length !== files.length) {
alert(`部分文件不符合要求,已过滤 ${files.length - validFiles.length} 个文件`);
}
// 显示上传进度
const progressModal = showBatchUploadProgress(validFiles.length);
// 批量上传
const uploadResults = await batchUploadImages(validFiles, progressModal);
// 关闭上传进度
if (progressModal && progressModal.parentNode) {
progressModal.remove();
}
// 智能匹配
const matchResult = matchImagesToSlides(uploadResults, allSlides);
// 显示匹配预览
showMatchPreview(matchResult);
}
// 3. 批量上传图片
async function batchUploadImages(files, progressModal) {
const results = [];
const token = getToken();
for (let i = 0; i < files.length; i++) {
const file = files[i];
const formData = new FormData();
formData.append('image', file);
try {
const response = await fetch(`${API_BASE}/api/upload/image`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
},
body: formData
});
const result = await response.json();
if (result.success) {
results.push({
fileName: file.name,
imageUrl: `${API_BASE}/${result.data.imageUrl}`,
success: true
});
} else {
results.push({
fileName: file.name,
error: result.error?.message || '上传失败',
success: false
});
}
} catch (error) {
results.push({
fileName: file.name,
error: error.message,
success: false
});
}
// 更新进度
if (progressModal) {
updateBatchUploadProgress(i + 1, files.length, progressModal);
}
}
return results;
}
// 4. 智能匹配图片到卡片(匹配所有卡片)
function matchImagesToSlides(uploadResults, existingSlides) {
// 只获取成功的上传结果
const successImages = uploadResults.filter(r => r.success);
if (successImages.length === 0) {
return {
matches: [],
warnings: ['没有成功上传的图片']
};
}
// 匹配所有卡片,不限制类型
if (existingSlides.length === 0) {
return {
matches: [],
warnings: ['当前节点没有卡片,无法匹配图片']
};
}
const matches = [];
const warnings = [];
// 按顺序匹配所有卡片
const matchCount = Math.min(successImages.length, existingSlides.length);
for (let i = 0; i < matchCount; i++) {
const slide = existingSlides[i];
const hasTextContent = !!(slide.content?.title || (slide.content?.paragraphs && slide.content.paragraphs.length > 0));
matches.push({
imageUrl: successImages[i].imageUrl,
fileName: successImages[i].fileName,
slideId: slide.id,
slideOrder: slide.orderIndex,
slideTitle: slide.content?.title || '无标题',
slideType: slide.slideType, // 保留原类型
hasTextContent: hasTextContent, // 判断是否有文字内容
action: 'update'
});
}
// 如果图片数量 > 卡片数量,提示但不创建
if (successImages.length > existingSlides.length) {
warnings.push(`图片数量(${successImages.length}) > 卡片数量(${existingSlides.length}),只匹配前${existingSlides.length}张图片`);
}
// 如果上传有失败的,提示
const failedCount = uploadResults.filter(r => !r.success).length;
if (failedCount > 0) {
warnings.push(`${failedCount} 张图片上传失败`);
}
return {
matches,
warnings
};
}
// 5. 显示匹配预览
function showMatchPreview(matchResult) {
const { matches, warnings } = matchResult;
if (matches.length === 0) {
alert(warnings.join('\n'));
return;
}
// 创建模态框
const modal = document.createElement('div');
modal.className = 'modal-overlay batch-upload-modal';
modal.style.cssText = 'position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 10000; display: flex; align-items: center; justify-content: center;';
const modalContent = document.createElement('div');
modalContent.style.cssText = 'background: white; border-radius: 8px; padding: 24px; max-width: 600px; max-height: 80vh; overflow-y: auto;';
let html = `
<h3 style="margin: 0 0 16px 0;">批量上传完成 - 匹配预览</h3>
<div style="margin-bottom: 16px;">
`;
// 显示匹配列表
matches.forEach((match, index) => {
// 显示类型变化提示
let typeChangeHint = '';
if (match.hasTextContent && match.slideType !== 'text') {
typeChangeHint = '<span style="color: #10b981; font-size: 12px;">(将变为图文类型)</span>';
} else if (match.hasTextContent) {
typeChangeHint = '<span style="color: #10b981; font-size: 12px;">(图文类型)</span>';
}
html += `
<div style="padding: 12px; background: #f5f5f5; border-radius: 4px; margin-bottom: 8px;">
<div style="display: flex; align-items: center; gap: 12px; flex-wrap: wrap;">
<span style="font-weight: bold; color: #666;">${index + 1}.</span>
<span style="flex: 1; color: #333; min-width: 150px;">${escapeHtml(match.fileName)}</span>
<span style="color: #10b981;"></span>
<span style="color: #667eea;">卡片#${match.slideOrder}</span>
<span style="color: #999; font-size: 12px;">(${escapeHtml(match.slideTitle)})</span>
${typeChangeHint}
</div>
</div>
`;
});
html += `</div>`;
// 显示警告
if (warnings.length > 0) {
html += `
<div style="padding: 12px; background: #fff3cd; border-radius: 4px; margin-bottom: 16px;">
<div style="font-weight: bold; color: #856404; margin-bottom: 4px;">⚠️ 提示:</div>
${warnings.map(w => `<div style="color: #856404; font-size: 14px;">${escapeHtml(w)}</div>`).join('')}
</div>
`;
}
html += `
<div style="display: flex; gap: 12px; justify-content: flex-end;">
<button class="btn-secondary" onclick="this.closest('.batch-upload-modal').remove()">取消</button>
<button class="btn-primary" onclick="confirmBatchMatch(${JSON.stringify(matches).replace(/"/g, '&quot;').replace(/'/g, '&#39;')})">确认匹配</button>
</div>
`;
modalContent.innerHTML = html;
modal.appendChild(modalContent);
document.body.appendChild(modal);
// 点击背景关闭
modal.addEventListener('click', (e) => {
if (e.target === modal) {
modal.remove();
}
});
}
// 6. 确认批量匹配
async function confirmBatchMatch(matches) {
// 关闭模态框
const modal = document.querySelector('.batch-upload-modal');
if (modal) {
modal.remove();
}
// 显示保存进度
const progressModal = showBatchSaveProgress(matches.length);
const token = getToken();
let successCount = 0;
let failCount = 0;
// 批量更新卡片
for (let i = 0; i < matches.length; i++) {
const match = matches[i];
try {
// ✅ 修复:直接使用 allSlides 中的数据不需要通过API获取
const slide = allSlides.find(s => s.id === match.slideId);
if (!slide) {
throw new Error('找不到卡片数据');
}
const currentContent = slide.content || {};
// 更新图片URL
const updatedContent = {
...currentContent,
imageUrl: match.imageUrl
};
// 确定卡片类型如果卡片有文字内容匹配图片后保持为text类型图文类型
let slideType = slide.slideType;
if (match.hasTextContent) {
slideType = 'text'; // 保持为text类型变成图文类型
} else {
// 如果卡片没有文字内容,根据原有类型决定
slideType = slide.slideType || 'image';
}
// 更新卡片(使用现有的 apiRequest 函数,确保错误处理一致)
const { response, result } = await apiRequest(`${API_BASE}/api/courses/nodes/${currentNodeId}/slides/${match.slideId}`, {
method: 'PATCH',
body: JSON.stringify({
slideType: slideType,
content: updatedContent
})
});
if (response.ok && result.success) {
successCount++;
} else {
failCount++;
console.error(`更新卡片 ${match.slideId} 失败:`, result.error);
}
} catch (error) {
failCount++;
console.error(`更新卡片 ${match.slideId} 失败:`, error);
}
// 更新进度
updateBatchSaveProgress(i + 1, matches.length, progressModal);
}
// 关闭进度模态框
if (progressModal && progressModal.parentNode) {
progressModal.remove();
}
// 显示结果
if (successCount > 0) {
alert(`✅ 成功匹配 ${successCount} 张图片${failCount > 0 ? `,失败 ${failCount} 张` : ''}`);
// 重新加载幻灯片列表
await editNodeContent(currentNodeId, currentNodeTitle);
} else {
alert(`❌ 匹配失败,请重试`);
}
}
// 7. 一键清除所有图片
async function clearAllImages() {
if (!currentNodeId) {
alert('请先选择节点');
return;
}
if (!confirm('确定要清除当前节点下所有卡片的图片吗?此操作不可撤销!')) {
return;
}
// 清除所有有图片的卡片(不限制类型)
const imageSlides = allSlides.filter(slide => {
return slide.content?.imageUrl; // 只要有imageUrl就清除
});
if (imageSlides.length === 0) {
alert('当前节点没有包含图片的卡片');
return;
}
// 显示进度
const progressModal = showBatchSaveProgress(imageSlides.length);
const token = getToken();
let successCount = 0;
let failCount = 0;
// 批量清除图片
for (let i = 0; i < imageSlides.length; i++) {
const slide = imageSlides[i];
try {
const currentContent = slide.content || {};
// 移除图片URL
const updatedContent = {
...currentContent,
imageUrl: null
};
// 确定清除后的类型
let slideType = slide.slideType;
// 如果原来是image类型清除图片后
// - 如果有文字内容改为text类型
// - 如果没有文字内容保持为image类型但imageUrl为null
if (slideType === 'image') {
const hasTextContent = !!(updatedContent.title || (updatedContent.paragraphs && updatedContent.paragraphs.length > 0));
if (hasTextContent) {
slideType = 'text'; // 有文字内容改为text类型
} else {
slideType = 'image'; // 没有文字内容保持为image类型
}
}
// 如果原来是text类型图文类型清除图片后保持为text类型
// 如果原来是其他类型,保持原类型
// 更新卡片(使用现有的 apiRequest 函数,确保错误处理一致)
const { response, result } = await apiRequest(`${API_BASE}/api/courses/nodes/${currentNodeId}/slides/${slide.id}`, {
method: 'PATCH',
body: JSON.stringify({
slideType: slideType,
content: updatedContent
})
});
if (response.ok && result.success) {
successCount++;
} else {
failCount++;
console.error(`清除卡片 ${slide.id} 图片失败:`, result.error);
}
} catch (error) {
failCount++;
console.error(`清除卡片 ${slide.id} 图片失败:`, error);
}
// 更新进度
updateBatchSaveProgress(i + 1, imageSlides.length, progressModal);
}
// 关闭进度模态框
if (progressModal && progressModal.parentNode) {
progressModal.remove();
}
// 显示结果
if (successCount > 0) {
alert(`✅ 成功清除 ${successCount} 个卡片的图片${failCount > 0 ? `,失败 ${failCount} 个` : ''}`);
// 重新加载幻灯片列表
await editNodeContent(currentNodeId, currentNodeTitle);
} else {
alert(`❌ 清除失败,请重试`);
}
}
// 8. 显示批量上传进度
function showBatchUploadProgress(total) {
const modal = document.createElement('div');
modal.className = 'batch-upload-progress';
modal.style.cssText = 'position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 10000; display: flex; align-items: center; justify-content: center;';
const content = document.createElement('div');
content.style.cssText = 'background: white; border-radius: 8px; padding: 24px; min-width: 300px;';
content.innerHTML = `
<h3 style="margin: 0 0 16px 0;">上传中...</h3>
<div class="progress-bar" style="width: 100%; height: 8px; background: #e5e7eb; border-radius: 4px; overflow: hidden;">
<div class="progress-fill" style="width: 0%; height: 100%; background: #10b981; transition: width 0.3s;"></div>
</div>
<p style="margin: 8px 0 0 0; color: #666; font-size: 14px; text-align: center;">
<span class="progress-text">0</span> / <span class="progress-total">${total}</span>
</p>
`;
modal.appendChild(content);
document.body.appendChild(modal);
return modal;
}
// 9. 更新批量上传进度
function updateBatchUploadProgress(current, total, modal) {
if (!modal) return;
const progressFill = modal.querySelector('.progress-fill');
const progressText = modal.querySelector('.progress-text');
if (progressFill && progressText) {
const percentage = (current / total) * 100;
progressFill.style.width = percentage + '%';
progressText.textContent = current;
}
}
// 10. 显示批量保存进度
function showBatchSaveProgress(total) {
const modal = document.createElement('div');
modal.className = 'batch-save-progress';
modal.style.cssText = 'position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 10000; display: flex; align-items: center; justify-content: center;';
const content = document.createElement('div');
content.style.cssText = 'background: white; border-radius: 8px; padding: 24px; min-width: 300px;';
content.innerHTML = `
<h3 style="margin: 0 0 16px 0;">保存中...</h3>
<div class="progress-bar" style="width: 100%; height: 8px; background: #e5e7eb; border-radius: 4px; overflow: hidden;">
<div class="progress-fill" style="width: 0%; height: 100%; background: #667eea; transition: width 0.3s;"></div>
</div>
<p style="margin: 8px 0 0 0; color: #666; font-size: 14px; text-align: center;">
<span class="progress-text">0</span> / <span class="progress-total">${total}</span>
</p>
`;
modal.appendChild(content);
document.body.appendChild(modal);
return modal;
}
// 11. 更新批量保存进度
function updateBatchSaveProgress(current, total, modal) {
if (!modal) return;
const progressFill = modal.querySelector('.progress-fill');
const progressText = modal.querySelector('.progress-text');
if (progressFill && progressText) {
const percentage = (current / total) * 100;
progressFill.style.width = percentage + '%';
progressText.textContent = current;
}
}
// ==================== 批量图片上传功能结束 ====================
// ==================== 系统笔记管理功能开始 ====================
// 标签页切换
function switchTab(tabName) {
const slidesPanel = document.getElementById('slides-panel');
const systemNotesPanel = document.getElementById('system-notes-panel');
const slidesTab = document.getElementById('tab-slides');
const systemNotesTab = document.getElementById('tab-system-notes');
if (tabName === 'slides') {
slidesPanel.style.display = 'block';
systemNotesPanel.style.display = 'none';
slidesTab.classList.add('active');
slidesTab.style.borderBottomColor = '#667eea';
slidesTab.style.color = '#667eea';
slidesTab.style.fontWeight = '600';
systemNotesTab.classList.remove('active');
systemNotesTab.style.borderBottomColor = 'transparent';
systemNotesTab.style.color = '#666';
systemNotesTab.style.fontWeight = '500';
} else if (tabName === 'system-notes') {
slidesPanel.style.display = 'none';
systemNotesPanel.style.display = 'block';
systemNotesTab.classList.add('active');
systemNotesTab.style.borderBottomColor = '#667eea';
systemNotesTab.style.color = '#667eea';
systemNotesTab.style.fontWeight = '600';
slidesTab.classList.remove('active');
slidesTab.style.borderBottomColor = 'transparent';
slidesTab.style.color = '#666';
slidesTab.style.fontWeight = '500';
// 加载系统笔记管理页面(在标签页场景中)
// 注意这里调用的是标签页内的系统笔记管理使用当前节点的ID
loadSystemNotesPageForTab();
}
}
// 初始化标签页切换
function initTabSwitching() {
// 标签页切换功能已在switchTab函数中实现
}
// 标签页内的系统笔记管理(使用当前节点)
async function loadSystemNotesPageForTab() {
const systemNotesContent = document.getElementById('systemNotesContent');
if (!systemNotesContent) {
console.error('找不到 systemNotesContent 元素');
return;
}
// 检查是否有当前节点ID
if (!currentNodeId) {
systemNotesContent.innerHTML = '<div style="text-align: center; color: #999; padding: 40px;"><p>请先选择一个节点</p></div>';
return;
}
systemNotesContent.innerHTML = '<div style="text-align: center; color: #999; padding: 40px;"><p>加载中...</p></div>';
try {
// 获取节点的纯文本内容
const { response: plainTextResponse, result: plainTextResult } = await apiRequest(`${API_BASE}/api/lessons/${currentNodeId}/plain-text`, {
method: 'GET'
});
if (!plainTextResponse.ok || !plainTextResult.success) {
throw new Error('获取节点纯文本失败');
}
const plainText = plainTextResult.data.plainText;
// 获取该节点的所有系统笔记
const { response: notesResponse, result: notesResult } = await apiRequest(`${API_BASE}/api/lessons/${currentNodeId}/notes`, {
method: 'GET'
});
let systemNotes = [];
if (notesResponse.ok && notesResult.success) {
// 过滤出系统笔记
systemNotes = (notesResult.data.notes || []).filter(note => note.is_system_note || note.user_id === '00000000-0000-0000-0000-000000000000');
}
// 渲染系统笔记管理界面(使用标签页内的渲染函数)
renderSystemNotesPageForTab(plainText, systemNotes);
} catch (error) {
console.error('加载系统笔记失败:', error);
systemNotesContent.innerHTML = `<div class="message error">加载失败:${error.message}</div>`;
}
}
// 标签页内的系统笔记渲染
function renderSystemNotesPageForTab(plainText, systemNotes) {
const systemNotesContent = document.getElementById('systemNotesContent');
if (!systemNotesContent) return;
const html = `
<div style="display: flex; gap: 20px; height: calc(100vh - 300px);">
<!-- 左侧:系统笔记列表 -->
<div style="width: 350px; border-right: 1px solid #e0e0e0; padding-right: 16px; overflow-y: auto;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
<h3 style="margin: 0; font-size: 16px;">系统笔记列表</h3>
<button class="btn-small btn-add" onclick="showAddSystemNoteModalForTab()">+ 添加</button>
</div>
<div id="systemNotesListForTab">
${systemNotes.length === 0
? '<div style="text-align: center; color: #999; padding: 40px;">暂无系统笔记</div>'
: systemNotes.map(note => `
<div class="system-note-item" style="padding: 12px; margin-bottom: 8px; border: 1px solid #e0e0e0; border-radius: 8px; background: #f9f9f9;">
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 8px;">
<span style="font-size: 12px; color: #666; background: ${note.type === 'thought' ? '#ffebee' : '#e3f2fd'}; padding: 2px 8px; border-radius: 4px;">
${note.type === 'thought' ? '💡 想法' : '📌 划线'}
</span>
<div style="display: flex; gap: 4px;">
<button class="btn-small" onclick="editSystemNoteForTab('${note.id}')" style="padding: 4px 8px; font-size: 12px;">编辑</button>
<button class="btn-small btn-danger" onclick="deleteSystemNoteForTab('${note.id}')" style="padding: 4px 8px; font-size: 12px;">删除</button>
</div>
</div>
<div style="font-size: 13px; color: #333; margin-bottom: 4px; font-weight: 500;">
"${escapeHtml((note.quoted_text || '').substring(0, 50))}${(note.quoted_text || '').length > 50 ? '...' : ''}"
</div>
${note.content ? `<div style="font-size: 12px; color: #666; margin-top: 4px;">${escapeHtml(note.content.substring(0, 60))}${note.content.length > 60 ? '...' : ''}</div>` : ''}
<div style="font-size: 11px; color: #999; margin-top: 4px;">
位置: ${note.start_index || 0}-${(note.start_index || 0) + (note.length || 0)}
</div>
</div>
`).join('')
}
</div>
</div>
<!-- 右侧:文本选择器 -->
<div style="flex: 1; display: flex; flex-direction: column;">
<h3 style="margin: 0 0 16px 0; font-size: 16px;">节点内容(纯文本)</h3>
<textarea
id="plainTextSelectorForTab"
readonly
style="flex: 1; padding: 16px; border: 1px solid #e0e0e0; border-radius: 8px; font-family: monospace; font-size: 14px; line-height: 1.6; resize: none; background: #fafafa;"
onselect="handleTextSelectionForTab()"
onmouseup="handleTextSelectionForTab()"
onkeyup="handleTextSelectionForTab()"
>${escapeHtml(plainText)}</textarea>
<div id="textSelectionInfoForTab" style="margin-top: 12px; padding: 12px; background: #f0f0f0; border-radius: 8px; font-size: 13px;">
<div>选中文本: <span id="selectedTextForTab" style="color: #667eea; font-weight: 600;"></span></div>
<div style="margin-top: 4px;">开始位置: <span id="startIndexForTab">0</span> | 长度: <span id="selectedLengthForTab">0</span></div>
</div>
</div>
</div>
`;
systemNotesContent.innerHTML = html;
}
// 标签页内的文本选择处理
window.handleTextSelectionForTab = function() {
const textarea = document.getElementById('plainTextSelectorForTab');
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selectedText = textarea.value.substring(start, end);
const selectedTextEl = document.getElementById('selectedTextForTab');
const startIndexEl = document.getElementById('startIndexForTab');
const selectedLengthEl = document.getElementById('selectedLengthForTab');
if (selectedTextEl) selectedTextEl.textContent = selectedText || '(未选择)';
if (startIndexEl) startIndexEl.textContent = start;
if (selectedLengthEl) selectedLengthEl.textContent = end - start;
};
// 标签页内的添加系统笔记
window.showAddSystemNoteModalForTab = function() {
const textarea = document.getElementById('plainTextSelectorForTab');
if (!textarea) {
alert('请先加载节点内容');
return;
}
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selectedText = textarea.value.substring(start, end);
if (!selectedText || selectedText.trim().length === 0) {
alert('请先选择要添加笔记的文本');
return;
}
// 使用主导航的添加模态框逻辑(但使用标签页的上下文)
showAddSystemNoteModal();
};
// 标签页内的编辑系统笔记
window.editSystemNoteForTab = function(noteId) {
editSystemNote(noteId);
};
// 标签页内的删除系统笔记
window.deleteSystemNoteForTab = async function(noteId) {
if (!confirm('确定要删除这条系统笔记吗?')) {
return;
}
try {
const response = await apiRequest(`${API_BASE}/api/notes/${noteId}`, {
method: 'DELETE'
});
if (response.response.ok && response.result.success) {
// 重新加载标签页内的系统笔记
loadSystemNotesPageForTab();
} else {
throw new Error(response.result.error?.message || '删除系统笔记失败');
}
} catch (error) {
console.error('删除系统笔记失败:', error);
alert('删除失败:' + error.message);
}
};
// 全局变量:存储当前选择
let currentSystemNotesCourseId = null;
let currentSystemNotesChapterId = null;
let currentSystemNotesNodeId = null;
let currentSystemNotesSlideId = null;
// 加载系统笔记管理页面
async function loadSystemNotesPage() {
const token = getToken();
if (!token) {
showLoginPage();
return;
}
const loadingIndicator = document.getElementById('loadingIndicator');
const appContent = document.getElementById('appContent');
loadingIndicator.style.display = 'block';
appContent.innerHTML = '';
try {
// 获取所有课程
const { response, result } = await apiRequest(`${API_BASE}/api/courses?includeDrafts=true`, {
method: 'GET'
});
if (!response.ok || !result.success) {
throw new Error('获取课程列表失败');
}
const courses = result.data.courses;
// 重置选择状态
currentSystemNotesCourseId = null;
currentSystemNotesChapterId = null;
currentSystemNotesNodeId = null;
currentSystemNotesSlideId = null;
// 渲染级联选择界面
renderSystemNotesCascade(courses);
} catch (error) {
loadingIndicator.style.display = 'none';
console.error('加载系统笔记页面失败:', error);
appContent.innerHTML = `<div class="message error">加载失败:${error.message}</div>`;
}
}
// 渲染级联选择界面
function renderSystemNotesCascade(courses) {
const loadingIndicator = document.getElementById('loadingIndicator');
const appContent = document.getElementById('appContent');
loadingIndicator.style.display = 'none';
const html = `
<div class="system-notes-page">
<div class="structure-header">
<h2>系统笔记管理</h2>
</div>
<!-- 级联选择器 -->
<div class="cascade-selector" style="display: flex; gap: 16px; margin-bottom: 24px; padding: 20px; background: #f8f9fa; border-radius: 8px;">
<!-- 课程选择 -->
<div style="flex: 1;">
<label style="display: block; margin-bottom: 8px; font-weight: 600; color: #333;">选择课程</label>
<select id="systemNotesCourseSelect" onchange="onSystemNotesCourseChange()" style="width: 100%; padding: 10px; border: 2px solid #e0e0e0; border-radius: 6px; font-size: 14px; background: white;">
<option value="">-- 请选择课程 --</option>
${courses.map(course => `
<option value="${course.id}">${escapeHtml(course.title)}</option>
`).join('')}
</select>
</div>
<!-- 章节选择 -->
<div style="flex: 1;">
<label style="display: block; margin-bottom: 8px; font-weight: 600; color: #333;">选择章节</label>
<select id="systemNotesChapterSelect" onchange="onSystemNotesChapterChange()" style="width: 100%; padding: 10px; border: 2px solid #e0e0e0; border-radius: 6px; font-size: 14px; background: white;" disabled>
<option value="">-- 请先选择课程 --</option>
</select>
</div>
<!-- 小节选择 -->
<div style="flex: 1;">
<label style="display: block; margin-bottom: 8px; font-weight: 600; color: #333;">选择小节</label>
<select id="systemNotesNodeSelect" onchange="onSystemNotesNodeChange()" style="width: 100%; padding: 10px; border: 2px solid #e0e0e0; border-radius: 6px; font-size: 14px; background: white;" disabled>
<option value="">-- 请先选择章节 --</option>
</select>
</div>
</div>
<!-- 内容区域 -->
<div id="systemNotesContentArea">
<div style="text-align: center; color: #999; padding: 60px;">
<p>请选择课程、章节和小节以开始管理系统笔记</p>
</div>
</div>
</div>
`;
appContent.innerHTML = html;
}
// 课程选择变化
window.onSystemNotesCourseChange = async function() {
const courseSelect = document.getElementById('systemNotesCourseSelect');
const chapterSelect = document.getElementById('systemNotesChapterSelect');
const nodeSelect = document.getElementById('systemNotesNodeSelect');
const contentArea = document.getElementById('systemNotesContentArea');
const courseId = courseSelect.value;
currentSystemNotesCourseId = courseId;
currentSystemNotesChapterId = null;
currentSystemNotesNodeId = null;
currentSystemNotesSlideId = null;
// 重置章节和小节选择
chapterSelect.innerHTML = '<option value="">-- 请先选择课程 --</option>';
chapterSelect.disabled = true;
nodeSelect.innerHTML = '<option value="">-- 请先选择章节 --</option>';
nodeSelect.disabled = true;
contentArea.innerHTML = '<div style="text-align: center; color: #999; padding: 60px;"><p>请选择章节和小节</p></div>';
if (!courseId) {
return;
}
try {
// 获取课程结构
const { response, result } = await apiRequest(`${API_BASE}/api/courses/${courseId}/structure`, {
method: 'GET'
});
if (!response.ok || !result.success) {
throw new Error('获取课程结构失败');
}
const { chapters, nodesWithoutChapter } = result.data;
// 填充章节选择
let chapterOptions = '<option value="">-- 请选择章节 --</option>';
// 添加有章节的节点
chapters.forEach(chapter => {
chapterOptions += `<option value="chapter_${chapter.id}">${escapeHtml(chapter.title)}</option>`;
});
// 添加无章节的节点(作为特殊章节)
if (nodesWithoutChapter && nodesWithoutChapter.length > 0) {
chapterOptions += `<option value="no_chapter">无章节节点</option>`;
}
chapterSelect.innerHTML = chapterOptions;
chapterSelect.disabled = false;
// 存储章节和节点数据
window.systemNotesChapters = chapters;
window.systemNotesNodesWithoutChapter = nodesWithoutChapter;
} catch (error) {
console.error('获取课程结构失败:', error);
alert('获取课程结构失败:' + error.message);
}
};
// 章节选择变化
window.onSystemNotesChapterChange = async function() {
const chapterSelect = document.getElementById('systemNotesChapterSelect');
const nodeSelect = document.getElementById('systemNotesNodeSelect');
const contentArea = document.getElementById('systemNotesContentArea');
const chapterValue = chapterSelect.value;
currentSystemNotesChapterId = chapterValue;
currentSystemNotesNodeId = null;
currentSystemNotesSlideId = null;
// 重置小节选择
nodeSelect.innerHTML = '<option value="">-- 请先选择章节 --</option>';
nodeSelect.disabled = true;
contentArea.innerHTML = '<div style="text-align: center; color: #999; padding: 60px;"><p>请选择小节</p></div>';
if (!chapterValue) {
return;
}
let nodes = [];
if (chapterValue === 'no_chapter') {
// 无章节的节点
nodes = window.systemNotesNodesWithoutChapter || [];
} else {
// 有章节的节点
const chapterId = chapterValue.replace('chapter_', '');
const chapter = (window.systemNotesChapters || []).find(c => c.id === chapterId);
if (chapter) {
nodes = chapter.nodes || [];
}
}
// 填充小节选择
let nodeOptions = '<option value="">-- 请选择小节 --</option>';
nodes.forEach(node => {
nodeOptions += `<option value="${node.id}">${escapeHtml(node.title)}</option>`;
});
nodeSelect.innerHTML = nodeOptions;
nodeSelect.disabled = false;
};
// 小节选择变化
window.onSystemNotesNodeChange = async function() {
const nodeSelect = document.getElementById('systemNotesNodeSelect');
const contentArea = document.getElementById('systemNotesContentArea');
const nodeId = nodeSelect.value;
currentSystemNotesNodeId = nodeId;
currentSystemNotesSlideId = null;
if (!nodeId) {
contentArea.innerHTML = '<div style="text-align: center; color: #999; padding: 60px;"><p>请选择小节</p></div>';
return;
}
// 显示加载中
contentArea.innerHTML = '<div style="text-align: center; color: #999; padding: 60px;"><p>加载中...</p></div>';
try {
// 获取节点幻灯片
const { response, result } = await apiRequest(`${API_BASE}/api/courses/nodes/${nodeId}/slides`, {
method: 'GET'
});
if (!response.ok || !result.success) {
throw new Error('获取幻灯片列表失败');
}
const { slides, nodeTitle } = result.data;
// 渲染幻灯片列表和系统笔记管理界面
renderSystemNotesSlides(slides, nodeId, nodeTitle);
} catch (error) {
console.error('获取幻灯片列表失败:', error);
contentArea.innerHTML = `<div class="message error">加载失败:${error.message}</div>`;
}
};
// 渲染幻灯片列表和系统笔记管理界面
function renderSystemNotesSlides(slides, nodeId, nodeTitle) {
const contentArea = document.getElementById('systemNotesContentArea');
const html = `
<div class="system-notes-slides-container">
<div style="margin-bottom: 20px; padding: 16px; background: #e7f3ff; border-radius: 8px;">
<h3 style="margin: 0; font-size: 18px; color: #333;">${escapeHtml(nodeTitle)}</h3>
<p style="margin: 8px 0 0 0; color: #666; font-size: 14px;">共 ${slides.length} 个幻灯片</p>
</div>
<div class="slides-grid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px;">
${slides.map((slide, index) => {
const slideType = slide.slideType || 'text';
const content = slide.content || {};
const title = content.title || '无标题';
return `
<div class="slide-card" data-slide-id="${slide.id}" style="border: 2px solid #e0e0e0; border-radius: 8px; padding: 16px; background: white; cursor: pointer; transition: all 0.3s;"
onmouseover="this.style.borderColor='#667eea'; this.style.boxShadow='0 4px 12px rgba(102, 126, 234, 0.2)'"
onmouseout="this.style.borderColor='#e0e0e0'; this.style.boxShadow='none'"
onclick="selectSystemNotesSlide('${slide.id}', '${escapeHtml(title)}')">
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 12px;">
<span style="font-size: 12px; color: #666; background: #f0f0f0; padding: 4px 8px; border-radius: 4px;">
#${index + 1} ${slideType === 'text' ? '📄 文本' : slideType === 'image' ? '🖼️ 图片' : '📝 其他'}
</span>
</div>
<h4 style="margin: 0 0 8px 0; font-size: 16px; color: #333; font-weight: 600;">
${escapeHtml(title.substring(0, 50))}${title.length > 50 ? '...' : ''}
</h4>
<p style="margin: 0; font-size: 12px; color: #999;">点击查看和管理系统笔记</p>
</div>
`;
}).join('')}
</div>
</div>
`;
contentArea.innerHTML = html;
}
// 选择幻灯片
window.selectSystemNotesSlide = async function(slideId, slideTitle) {
currentSystemNotesSlideId = slideId;
// 显示加载中
const contentArea = document.getElementById('systemNotesContentArea');
const originalContent = contentArea.innerHTML;
contentArea.innerHTML = '<div style="text-align: center; color: #999; padding: 60px;"><p>加载中...</p></div>';
try {
// 获取节点的纯文本内容(用于文本选择)
const { response: plainTextResponse, result: plainTextResult } = await apiRequest(`${API_BASE}/api/lessons/${currentSystemNotesNodeId}/plain-text`, {
method: 'GET'
});
if (!plainTextResponse.ok || !plainTextResult.success) {
throw new Error('获取节点纯文本失败');
}
const plainText = plainTextResult.data.plainText;
// 获取该节点的所有系统笔记
const { response: notesResponse, result: notesResult } = await apiRequest(`${API_BASE}/api/lessons/${currentSystemNotesNodeId}/notes`, {
method: 'GET'
});
let systemNotes = [];
if (notesResponse.ok && notesResult.success) {
// 过滤出系统笔记
systemNotes = (notesResult.data.notes || []).filter(note => note.is_system_note || note.user_id === '00000000-0000-0000-0000-000000000000');
}
// 渲染系统笔记管理界面
renderSystemNotesEditor(plainText, systemNotes, slideId, slideTitle);
} catch (error) {
console.error('加载系统笔记编辑器失败:', error);
contentArea.innerHTML = `<div class="message error">加载失败:${error.message}</div>`;
}
};
// 渲染系统笔记编辑器
function renderSystemNotesEditor(plainText, systemNotes, slideId, slideTitle) {
const contentArea = document.getElementById('systemNotesContentArea');
// 存储当前节点ID和课程ID供后续使用
window.currentSystemNotesNodeId = currentSystemNotesNodeId;
window.currentSystemNotesCourseId = currentSystemNotesCourseId;
const html = `
<div style="margin-bottom: 16px;">
<button class="btn-small" onclick="onSystemNotesNodeChange()" style="padding: 8px 16px; background: #f0f0f0; border: 1px solid #ddd; border-radius: 6px; cursor: pointer;">
← 返回幻灯片列表
</button>
</div>
<div style="display: flex; gap: 20px; height: calc(100vh - 300px);">
<!-- 左侧:系统笔记列表 -->
<div style="width: 350px; border-right: 1px solid #e0e0e0; padding-right: 16px; overflow-y: auto;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
<h3 style="margin: 0; font-size: 16px;">系统笔记列表</h3>
<button class="btn-small btn-add" onclick="showAddSystemNoteModal()">+ 添加</button>
</div>
<div id="systemNotesList">
${systemNotes.length === 0
? '<div style="text-align: center; color: #999; padding: 40px;">暂无系统笔记</div>'
: systemNotes.map(note => `
<div class="system-note-item" style="padding: 12px; margin-bottom: 8px; border: 1px solid #e0e0e0; border-radius: 8px; background: #f9f9f9;">
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 8px;">
<span style="font-size: 12px; color: #666; background: ${note.type === 'thought' ? '#ffebee' : '#e3f2fd'}; padding: 2px 8px; border-radius: 4px;">
${note.type === 'thought' ? '💡 想法' : '📌 划线'}
</span>
<div style="display: flex; gap: 4px;">
<button class="btn-small" onclick="editSystemNote('${note.id}')" style="padding: 4px 8px; font-size: 12px;">编辑</button>
<button class="btn-small btn-danger" onclick="deleteSystemNote('${note.id}')" style="padding: 4px 8px; font-size: 12px;">删除</button>
</div>
</div>
<div style="font-size: 13px; color: #333; margin-bottom: 4px; font-weight: 500;">
"${escapeHtml((note.quoted_text || '').substring(0, 50))}${(note.quoted_text || '').length > 50 ? '...' : ''}"
</div>
${note.content ? `<div style="font-size: 12px; color: #666; margin-top: 4px;">${escapeHtml(note.content.substring(0, 60))}${note.content.length > 60 ? '...' : ''}</div>` : ''}
<div style="font-size: 11px; color: #999; margin-top: 4px;">
位置: ${note.start_index || 0}-${(note.start_index || 0) + (note.length || 0)}
</div>
</div>
`).join('')
}
</div>
</div>
<!-- 右侧:文本选择器 -->
<div style="flex: 1; display: flex; flex-direction: column;">
<h3 style="margin: 0 0 16px 0; font-size: 16px;">节点内容(纯文本)</h3>
<textarea
id="plainTextSelector"
readonly
style="flex: 1; padding: 16px; border: 1px solid #e0e0e0; border-radius: 8px; font-family: monospace; font-size: 14px; line-height: 1.6; resize: none; background: #fafafa;"
onselect="handleTextSelection()"
onmouseup="handleTextSelection()"
onkeyup="handleTextSelection()"
>${escapeHtml(plainText)}</textarea>
<div id="textSelectionInfo" style="margin-top: 12px; padding: 12px; background: #f0f0f0; border-radius: 8px; font-size: 13px;">
<div>选中文本: <span id="selectedText" style="color: #667eea; font-weight: 600;"></span></div>
<div style="margin-top: 4px;">开始位置: <span id="startIndex">0</span> | 长度: <span id="selectedLength">0</span></div>
</div>
</div>
</div>
`;
contentArea.innerHTML = html;
}
// 渲染系统笔记管理页面
function renderSystemNotesPage(plainText, systemNotes, courseId) {
// ✅ 存储courseId供后续使用
window.currentCourseId = courseId;
const systemNotesContent = document.getElementById('systemNotesContent');
if (!systemNotesContent) return;
const html = `
<div style="display: flex; gap: 20px; height: calc(100vh - 200px);">
<!-- 左侧:系统笔记列表 -->
<div style="width: 300px; border-right: 1px solid #e0e0e0; padding-right: 16px; overflow-y: auto;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
<h3 style="margin: 0; font-size: 16px;">系统笔记列表</h3>
<button class="btn-small btn-add" onclick="showAddSystemNoteModal()">+ 添加</button>
</div>
<div id="systemNotesList">
${systemNotes.length === 0
? '<div style="text-align: center; color: #999; padding: 40px;">暂无系统笔记</div>'
: systemNotes.map(note => `
<div class="system-note-item" style="padding: 12px; margin-bottom: 8px; border: 1px solid #e0e0e0; border-radius: 8px; background: #f9f9f9;">
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 8px;">
<span style="font-size: 12px; color: #666; background: ${note.type === 'thought' ? '#ffebee' : '#e3f2fd'}; padding: 2px 8px; border-radius: 4px;">
${note.type === 'thought' ? '💡 想法' : '📌 划线'}
</span>
<div style="display: flex; gap: 4px;">
<button class="btn-small" onclick="editSystemNote('${note.id}')" style="padding: 4px 8px; font-size: 12px;">编辑</button>
<button class="btn-small btn-danger" onclick="deleteSystemNote('${note.id}')" style="padding: 4px 8px; font-size: 12px;">删除</button>
</div>
</div>
<div style="font-size: 13px; color: #333; margin-bottom: 4px; font-weight: 500;">
"${escapeHtml((note.quoted_text || '').substring(0, 50))}${(note.quoted_text || '').length > 50 ? '...' : ''}"
</div>
${note.content ? `<div style="font-size: 12px; color: #666; margin-top: 4px;">${escapeHtml(note.content.substring(0, 60))}${note.content.length > 60 ? '...' : ''}</div>` : ''}
<div style="font-size: 11px; color: #999; margin-top: 4px;">
位置: ${note.start_index || 0}-${(note.start_index || 0) + (note.length || 0)}
</div>
</div>
`).join('')
}
</div>
</div>
<!-- 右侧:文本选择器 -->
<div style="flex: 1; display: flex; flex-direction: column;">
<h3 style="margin: 0 0 16px 0; font-size: 16px;">节点内容(纯文本)</h3>
<textarea
id="plainTextSelector"
readonly
style="flex: 1; padding: 16px; border: 1px solid #e0e0e0; border-radius: 8px; font-family: monospace; font-size: 14px; line-height: 1.6; resize: none; background: #fafafa;"
onselect="handleTextSelection()"
onmouseup="handleTextSelection()"
onkeyup="handleTextSelection()"
>${escapeHtml(plainText)}</textarea>
<div id="textSelectionInfo" style="margin-top: 12px; padding: 12px; background: #f0f0f0; border-radius: 8px; font-size: 13px;">
<div>选中文本: <span id="selectedText" style="color: #667eea; font-weight: 600;"></span></div>
<div style="margin-top: 4px;">开始位置: <span id="startIndex">0</span> | 长度: <span id="selectedLength">0</span></div>
</div>
</div>
</div>
`;
systemNotesContent.innerHTML = html;
// 高亮显示已存在的系统笔记
highlightSystemNotes(systemNotes, plainText);
}
// 处理文本选择
window.handleTextSelection = function() {
const textarea = document.getElementById('plainTextSelector');
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selectedText = textarea.value.substring(start, end);
const selectedTextEl = document.getElementById('selectedText');
const startIndexEl = document.getElementById('startIndex');
const selectedLengthEl = document.getElementById('selectedLength');
if (selectedTextEl) selectedTextEl.textContent = selectedText || '(未选择)';
if (startIndexEl) startIndexEl.textContent = start;
if (selectedLengthEl) selectedLengthEl.textContent = end - start;
};
// 高亮显示已存在的系统笔记
function highlightSystemNotes(notes, plainText) {
// 由于textarea不支持富文本我们使用一个覆盖层来显示高亮
// 这里简化处理,在笔记列表中显示位置信息即可
}
// 显示添加系统笔记模态框
window.showAddSystemNoteModal = function() {
const textarea = document.getElementById('plainTextSelector');
if (!textarea) {
alert('请先加载节点内容');
return;
}
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selectedText = textarea.value.substring(start, end);
if (!selectedText || selectedText.trim().length === 0) {
alert('请先选择要添加笔记的文本');
return;
}
const modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.style.cssText = 'position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 10000;';
modal.innerHTML = `
<div style="background: white; padding: 24px; border-radius: 12px; width: 500px; max-width: 90vw; max-height: 90vh; overflow-y: auto;">
<h3 style="margin: 0 0 20px 0;">添加系统笔记</h3>
<div style="margin-bottom: 16px;">
<label style="display: block; margin-bottom: 8px; font-weight: 600;">引用文本</label>
<input type="text" id="addNoteQuotedText" value="${escapeHtml(selectedText)}" style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;">
</div>
<div style="margin-bottom: 16px;">
<label style="display: block; margin-bottom: 8px; font-weight: 600;">笔记类型</label>
<select id="addNoteType" style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;">
<option value="highlight">📌 划线</option>
<option value="thought">💡 想法</option>
</select>
</div>
<div id="addNoteContentContainer" style="margin-bottom: 16px; display: none;">
<label style="display: block; margin-bottom: 8px; font-weight: 600;">笔记内容</label>
<textarea id="addNoteContent" style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px; min-height: 100px; resize: vertical;"></textarea>
</div>
<div style="margin-bottom: 16px; padding: 12px; background: #f0f0f0; border-radius: 4px; font-size: 12px; color: #666;">
<div>开始位置: <span id="addNoteStartIndex">${start}</span></div>
<div>长度: <span id="addNoteLength">${end - start}</span></div>
</div>
<div style="display: flex; gap: 8px; justify-content: flex-end;">
<button class="btn-small" onclick="this.closest('.modal-overlay').remove()">取消</button>
<button class="btn-small btn-add" onclick="saveSystemNote(${start}, ${end - start})">保存</button>
</div>
</div>
`;
document.body.appendChild(modal);
// 监听笔记类型变化,显示/隐藏内容输入框
const typeSelect = modal.querySelector('#addNoteType');
const contentContainer = modal.querySelector('#addNoteContentContainer');
typeSelect.addEventListener('change', function() {
if (this.value === 'thought') {
contentContainer.style.display = 'block';
} else {
contentContainer.style.display = 'none';
}
});
};
// 保存系统笔记
window.saveSystemNote = async function(startIndex, length) {
const quotedText = document.getElementById('addNoteQuotedText').value.trim();
const type = document.getElementById('addNoteType').value;
const content = type === 'thought' ? document.getElementById('addNoteContent').value.trim() : '';
if (!quotedText) {
alert('引用文本不能为空');
return;
}
if (type === 'thought' && !content) {
alert('想法笔记的内容不能为空');
return;
}
try {
// ✅ 支持两种场景:
// 1. 主导航的系统笔记管理:使用 window.currentSystemNotesCourseId 和 window.currentSystemNotesNodeId
// 2. 标签页内的系统笔记管理:使用 currentCourseId 和 currentNodeId全局变量
let courseId = window.currentSystemNotesCourseId || currentCourseId;
let nodeId = window.currentSystemNotesNodeId || currentNodeId;
if (!courseId || !nodeId) {
throw new Error('无法获取课程ID或节点ID');
}
// 创建系统笔记
const response = await apiRequest(`${API_BASE}/api/notes/system`, {
method: 'POST',
body: JSON.stringify({
course_id: courseId,
node_id: nodeId,
type: type,
quoted_text: quotedText,
content: content || '',
start_index: startIndex,
length: length
})
});
if (response.response.ok && response.result.success) {
// 关闭模态框
document.querySelector('.modal-overlay')?.remove();
// ✅ 根据场景重新加载:
// 1. 主导航场景:重新加载当前幻灯片或节点
// 2. 标签页场景:重新加载标签页内的系统笔记
if (currentSystemNotesSlideId) {
selectSystemNotesSlide(currentSystemNotesSlideId, '');
} else if (document.getElementById('systemNotesContent')) {
// 标签页场景
loadSystemNotesPageForTab();
} else if (window.onSystemNotesNodeChange) {
// 主导航场景
onSystemNotesNodeChange();
} else {
// 重新加载当前页面
if (currentNodeId && currentNodeTitle) {
await editNodeContent(currentNodeId, currentNodeTitle);
}
}
} else {
throw new Error(response.result.error?.message || '创建系统笔记失败');
}
} catch (error) {
console.error('保存系统笔记失败:', error);
alert('保存失败:' + error.message);
}
};
// 编辑系统笔记
async function editSystemNote(noteId) {
try {
// 获取笔记详情
const response = await apiRequest(`${API_BASE}/api/notes/${noteId}`, {
method: 'GET'
});
if (!response.response.ok || !response.result.success) {
throw new Error('获取笔记详情失败');
}
const note = response.result.data;
const modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.style.cssText = 'position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 10000;';
modal.innerHTML = `
<div style="background: white; padding: 24px; border-radius: 12px; width: 500px; max-width: 90vw; max-height: 90vh; overflow-y: auto;">
<h3 style="margin: 0 0 20px 0;">编辑系统笔记</h3>
<div style="margin-bottom: 16px;">
<label style="display: block; margin-bottom: 8px; font-weight: 600;">引用文本</label>
<input type="text" id="editNoteQuotedText" value="${escapeHtml(note.quoted_text || '')}" style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;">
</div>
<div style="margin-bottom: 16px;">
<label style="display: block; margin-bottom: 8px; font-weight: 600;">笔记类型</label>
<select id="editNoteType" style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;">
<option value="highlight" ${note.type === 'highlight' ? 'selected' : ''}>📌 划线</option>
<option value="thought" ${note.type === 'thought' ? 'selected' : ''}>💡 想法</option>
</select>
</div>
<div id="editNoteContentContainer" style="margin-bottom: 16px; ${note.type === 'thought' ? '' : 'display: none;'}">
<label style="display: block; margin-bottom: 8px; font-weight: 600;">笔记内容</label>
<textarea id="editNoteContent" style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px; min-height: 100px; resize: vertical;">${escapeHtml(note.content || '')}</textarea>
</div>
<div style="margin-bottom: 16px; padding: 12px; background: #f0f0f0; border-radius: 4px; font-size: 12px; color: #666;">
<div>开始位置: ${note.start_index || 0}(不可修改)</div>
<div>长度: ${note.length || 0}(不可修改)</div>
<div style="margin-top: 4px; color: #999;">提示:要修改位置,请删除后重新创建</div>
</div>
<div style="display: flex; gap: 8px; justify-content: flex-end;">
<button class="btn-small" onclick="this.closest('.modal-overlay').remove()">取消</button>
<button class="btn-small btn-add" onclick="updateSystemNote('${noteId}')">保存</button>
</div>
</div>
`;
document.body.appendChild(modal);
// 监听笔记类型变化
const typeSelect = modal.querySelector('#editNoteType');
const contentContainer = modal.querySelector('#editNoteContentContainer');
typeSelect.addEventListener('change', function() {
if (this.value === 'thought') {
contentContainer.style.display = 'block';
} else {
contentContainer.style.display = 'none';
}
});
} catch (error) {
console.error('编辑系统笔记失败:', error);
alert('加载笔记详情失败:' + error.message);
}
}
// 更新系统笔记
async function updateSystemNote(noteId) {
const quotedText = document.getElementById('editNoteQuotedText').value.trim();
const type = document.getElementById('editNoteType').value;
const content = type === 'thought' ? document.getElementById('editNoteContent').value.trim() : '';
if (!quotedText) {
alert('引用文本不能为空');
return;
}
if (type === 'thought' && !content) {
alert('想法笔记的内容不能为空');
return;
}
try {
const response = await apiRequest(`${API_BASE}/api/notes/${noteId}`, {
method: 'PUT',
body: JSON.stringify({
quoted_text: quotedText,
type: type,
content: content || ''
})
});
if (response.response.ok && response.result.success) {
// 关闭模态框
document.querySelector('.modal-overlay')?.remove();
// ✅ 根据场景重新加载
if (currentSystemNotesSlideId) {
selectSystemNotesSlide(currentSystemNotesSlideId, '');
} else if (document.getElementById('systemNotesContent')) {
// 标签页场景
loadSystemNotesPageForTab();
} else if (window.onSystemNotesNodeChange) {
// 主导航场景
onSystemNotesNodeChange();
} else {
// 重新加载当前页面
if (currentNodeId && currentNodeTitle) {
await editNodeContent(currentNodeId, currentNodeTitle);
}
}
} else {
throw new Error(response.result.error?.message || '更新系统笔记失败');
}
} catch (error) {
console.error('更新系统笔记失败:', error);
alert('更新失败:' + error.message);
}
}
// 删除系统笔记
async function deleteSystemNote(noteId) {
if (!confirm('确定要删除这条系统笔记吗?')) {
return;
}
try {
const response = await apiRequest(`${API_BASE}/api/notes/${noteId}`, {
method: 'DELETE'
});
if (response.response.ok && response.result.success) {
// ✅ 根据场景重新加载
if (currentSystemNotesSlideId) {
selectSystemNotesSlide(currentSystemNotesSlideId, '');
} else if (document.getElementById('systemNotesContent')) {
// 标签页场景
loadSystemNotesPageForTab();
} else if (window.onSystemNotesNodeChange) {
// 主导航场景
onSystemNotesNodeChange();
} else {
// 重新加载当前页面
if (currentNodeId && currentNodeTitle) {
await editNodeContent(currentNodeId, currentNodeTitle);
}
}
} else {
throw new Error(response.result.error?.message || '删除系统笔记失败');
}
} catch (error) {
console.error('删除系统笔记失败:', error);
alert('删除失败:' + error.message);
}
}
// ==================== 系统笔记管理功能结束 ====================
// ==================== AI 创建课程(保存为草稿)功能 ====================
let aiCreateCoursePollTimer = null;
let aiCreateCourseTaskId = null;
let aiCreateCourseId = null;
async function loadAICreateCoursePage() {
document.getElementById('loadingIndicator').style.display = 'none';
renderAICreateCourseForm();
}
// 讲解老师配置(与 iOS PersonaSelectionView 完全一致)
const PERSONA_OPTIONS = {
direct: [
{ value: 'direct_test_lite', title: '小红学姐', slogan: '万粉博主使用钩子教学法适合重度学习困难者生成10张卡片' },
{ value: 'direct_test_lite_outline', title: '林老师(推荐)', slogan: '温柔有耐心擅长用大白话和故事的方式讲解生成10卡片' },
{ value: 'direct_test_lite_summary', title: '丁老师', slogan: '擅长有条理地讲解内容生成20张卡片' },
],
document: [
{ value: 'text_parse_xiaohongshu', title: '小红学姐', slogan: '万粉知识博主,使用钩子教学法,适合重度学习困难者' },
{ value: 'text_parse_xiaolin', title: '林老师(推荐)', slogan: '极其温柔有耐心,擅长用大白话和故事的方式讲解' },
{ value: 'text_parse_douyin', title: '丁老师', slogan: '擅长有条理地讲解内容' },
],
continue: [
{ value: 'continue_course_xiaohongshu', title: '小红学姐', slogan: '万粉博主使用钩子教学法适合重度学习困难者生成10张卡片' },
{ value: 'continue_course_xiaolin', title: '林老师(推荐)', slogan: '温柔有耐心擅长用大白话和故事的方式讲解生成10卡片' },
{ value: 'continue_course_douyin', title: '丁老师', slogan: '擅长有条理地讲解内容生成20张卡片' },
],
};
function renderPersonaOptions(sourceType) {
const container = document.getElementById('aiCreatePersonaOptions');
if (!container) return;
const options = PERSONA_OPTIONS[sourceType] || PERSONA_OPTIONS.direct;
container.innerHTML = options.map((opt, idx) => `
<label class="persona-card-label ${idx === 0 ? 'selected' : ''}" onclick="selectPersonaCard(this)">
<input type="radio" name="aiCreatePersona" value="${opt.value}" ${idx === 0 ? 'checked' : ''} style="display:none;">
<div style="display:flex;align-items:center;gap:12px;width:100%;">
<div style="flex:1;min-width:0;">
<div style="font-weight:600;font-size:15px;color:#1a1a2e;">${opt.title}</div>
<div style="font-size:13px;color:#888;margin-top:4px;font-style:italic;">${opt.slogan}</div>
</div>
<div class="persona-check" style="flex-shrink:0;width:22px;height:22px;border-radius:50%;border:2px solid ${idx === 0 ? '#F5A623' : '#ddd'};display:flex;align-items:center;justify-content:center;">
${idx === 0 ? '<div style="width:12px;height:12px;border-radius:50%;background:#F5A623;"></div>' : ''}
</div>
</div>
</label>
`).join('');
}
window.selectPersonaCard = function(el) {
const container = el.parentElement;
container.querySelectorAll('.persona-card-label').forEach(c => {
c.classList.remove('selected');
const check = c.querySelector('.persona-check');
if (check) { check.style.borderColor = '#ddd'; check.innerHTML = ''; }
});
el.classList.add('selected');
el.querySelector('input[type="radio"]').checked = true;
const check = el.querySelector('.persona-check');
if (check) { check.style.borderColor = '#F5A623'; check.innerHTML = '<div style="width:12px;height:12px;border-radius:50%;background:#F5A623;"></div>'; }
};
function renderAICreateCourseForm() {
const appContent = document.getElementById('appContent');
appContent.innerHTML = `
<style>
.persona-card-label {
display: block; cursor: pointer; padding: 16px 18px;
background: #fff; border: 2px solid transparent; border-radius: 12px;
transition: all .2s; box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
.persona-card-label:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.persona-card-label.selected { border-color: #F5A623; background: #FFF9EE; }
</style>
<div class="page-header">
<h2>AI 创建课程</h2>
<p style="color: #666; margin-top: 8px;">与 App 一致流程,创建后为草稿,需在课程管理中编辑并手动发布。</p>
</div>
<div class="form-group" style="margin-top: 24px;">
<label for="aiCreateSourceText">主题/文本内容 <span class="required">*</span></label>
<textarea id="aiCreateSourceText" class="mindmap-textarea" placeholder="输入主题或粘贴文档内容(续旧课时可粘贴已提取的文本)" style="min-height: 280px;"></textarea>
<div style="font-size: 12px; color: #999; margin-top: 6px;">字符数: <span id="aiCreateCharCount">0</span></div>
</div>
<div class="form-group" style="margin-top: 24px;">
<label>生成模式 <span class="required">*</span></label>
<div style="display: flex; flex-wrap: wrap; gap: 12px;">
<label class="radio-label"><input type="radio" name="aiCreateSourceType" value="direct" checked onchange="renderPersonaOptions('direct')"> 直接生成</label>
<label class="radio-label"><input type="radio" name="aiCreateSourceType" value="document" onchange="renderPersonaOptions('document')"> 文本解析</label>
<label class="radio-label"><input type="radio" name="aiCreateSourceType" value="continue" onchange="renderPersonaOptions('continue')"> 续旧课</label>
</div>
</div>
<div class="form-group" style="margin-top: 24px;">
<label>选择讲解老师 <span class="required">*</span></label>
<div id="aiCreatePersonaOptions" style="display: flex; flex-direction: column; gap: 10px;"></div>
</div>
<div class="form-group" style="margin-top: 24px;">
<label>可见范围 <span class="required">*</span></label>
<div style="display: flex; flex-wrap: wrap; gap: 12px;">
<label class="radio-label"><input type="radio" name="aiCreateVisibility" value="public" checked> 公开</label>
<label class="radio-label"><input type="radio" name="aiCreateVisibility" value="private"> 仅创建者</label>
</div>
</div>
<div class="form-group" style="margin-top: 28px;">
<button class="btn-primary" type="button" id="aiCreateSubmitBtn" onclick="startAICreateCourse()">创建并生成(保存为草稿)</button>
</div>
<div id="aiCreateMessage" class="message" style="display: none;"></div>
<div id="aiCreateProgress" style="display: none; margin-top: 20px; padding: 20px; background: #f8f9ff; border-radius: 12px;">
<p style="font-weight: 600; margin-bottom: 8px;">正在生成课程…</p>
<p style="color: #666; font-size: 14px;" id="aiCreateProgressText">0%</p>
</div>
`;
// 初始渲染直接生成的老师选项
renderPersonaOptions('direct');
const textarea = document.getElementById('aiCreateSourceText');
const countEl = document.getElementById('aiCreateCharCount');
if (textarea && countEl) {
textarea.addEventListener('input', function() {
countEl.textContent = this.value.length;
});
}
}
window.startAICreateCourse = async function() {
const token = getToken();
if (!token) {
showLoginPage();
return;
}
const sourceText = (document.getElementById('aiCreateSourceText') || {}).value?.trim();
if (!sourceText) {
showAICreateMessage('请输入主题/文本内容', 'error');
return;
}
const sourceType = (document.querySelector('input[name="aiCreateSourceType"]:checked') || {}).value || 'direct';
const persona = (document.querySelector('input[name="aiCreatePersona"]:checked') || {}).value || 'direct_test_lite';
const visibility = (document.querySelector('input[name="aiCreateVisibility"]:checked') || {}).value || 'private';
const btn = document.getElementById('aiCreateSubmitBtn');
const msgEl = document.getElementById('aiCreateMessage');
const progressEl = document.getElementById('aiCreateProgress');
const progressText = document.getElementById('aiCreateProgressText');
if (btn) btn.disabled = true;
if (msgEl) { msgEl.style.display = 'none'; msgEl.className = 'message'; }
if (progressEl) progressEl.style.display = 'block';
if (progressText) progressText.textContent = '提交中…';
try {
const { response, result } = await apiRequest(API_BASE + '/api/ai/courses/create', {
method: 'POST',
body: JSON.stringify({
sourceText: sourceText,
sourceType: sourceType,
persona: persona,
visibility: visibility,
saveAsDraft: true
})
});
if (!response.ok || !result.success) {
throw new Error(result.error?.message || result.message || '创建失败');
}
const taskId = result.data?.taskId;
const courseId = result.data?.courseId;
if (!taskId || !courseId) {
throw new Error('未返回 taskId/courseId');
}
aiCreateCourseTaskId = taskId;
aiCreateCourseId = courseId;
if (progressText) progressText.textContent = '已创建任务,轮询进度…';
aiCreateCoursePollTimer = setInterval(pollAICreateCourseStatus, 2500);
pollAICreateCourseStatus();
} catch (err) {
if (btn) btn.disabled = false;
if (progressEl) progressEl.style.display = 'none';
showAICreateMessage(err.message || '创建失败', 'error');
}
};
function showAICreateMessage(text, type) {
const el = document.getElementById('aiCreateMessage');
if (!el) return;
el.textContent = text;
el.className = 'message ' + (type === 'error' ? 'error' : '');
el.style.display = 'block';
}
async function pollAICreateCourseStatus() {
if (!aiCreateCourseTaskId) return;
const progressText = document.getElementById('aiCreateProgressText');
try {
const { response, result } = await apiRequest(API_BASE + '/api/ai/courses/' + aiCreateCourseTaskId + '/status', { method: 'GET' });
if (!response.ok || !result.success) {
return;
}
const status = result.data?.status;
const progress = result.data?.progress != null ? result.data.progress : 0;
if (progressText) progressText.textContent = (status || '') + ' ' + progress + '%';
if (status === 'completed') {
clearInterval(aiCreateCoursePollTimer);
aiCreateCoursePollTimer = null;
document.getElementById('aiCreateSubmitBtn').disabled = false;
document.getElementById('aiCreateProgress').style.display = 'none';
showAICreateMessage('课程已生成并保存为草稿,可在课程管理中编辑并发布。', '');
const cid = aiCreateCourseId;
aiCreateCourseTaskId = null;
aiCreateCourseId = null;
setTimeout(function() {
switchPage('courses');
}, 1500);
} else if (status === 'failed') {
clearInterval(aiCreateCoursePollTimer);
aiCreateCoursePollTimer = null;
document.getElementById('aiCreateSubmitBtn').disabled = false;
document.getElementById('aiCreateProgress').style.display = 'none';
showAICreateMessage('生成失败: ' + (result.data?.errorMessage || '未知错误'), 'error');
aiCreateCourseTaskId = null;
aiCreateCourseId = null;
}
} catch (e) {
if (progressText) progressText.textContent = '轮询出错: ' + (e.message || '');
}
}
// ==================== 批量生成系统课程 ====================
let batchAborted = false;
function loadBatchCreatePage() {
document.getElementById('loadingIndicator').style.display = 'none';
renderBatchCreateForm();
}
function renderBatchCreateForm() {
const appContent = document.getElementById('appContent');
const teachers = PERSONA_OPTIONS.direct; // 固定直接生成流程
appContent.innerHTML = `
<style>
.batch-teacher-card {
display: inline-flex; align-items: center; gap: 10px; cursor: pointer;
padding: 14px 20px; background: #fff; border: 2px solid transparent;
border-radius: 12px; transition: all .2s; box-shadow: 0 1px 4px rgba(0,0,0,0.06);
flex: 1; min-width: 180px;
}
.batch-teacher-card:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.batch-teacher-card.selected { border-color: #F5A623; background: #FFF9EE; }
.batch-topic-row {
display: flex; align-items: center; gap: 8px; padding: 10px 14px;
background: #fff; border-radius: 10px; border: 1px solid #eee;
font-size: 14px; transition: all .2s;
}
.batch-topic-row .status-icon { width: 22px; text-align: center; flex-shrink: 0; }
.batch-topic-row .topic-text { flex: 1; }
.batch-topic-row .topic-detail { font-size: 12px; color: #999; flex-shrink: 0; }
.batch-topic-row.success { background: #f0fdf4; border-color: #86efac; }
.batch-topic-row.failed { background: #fef2f2; border-color: #fca5a5; }
.batch-topic-row.running { background: #fffbeb; border-color: #fcd34d; }
.batch-summary { display: flex; gap: 16px; margin-top: 16px; flex-wrap: wrap; }
.batch-summary .stat { padding: 12px 20px; border-radius: 10px; font-size: 14px; font-weight: 600; }
</style>
<div class="page-header">
<h2>🚀 批量生成系统课程</h2>
<p style="color: #666; margin-top: 8px;">选讲解老师 → 输入主题(每行一个)→ 一键批量生成,固定「直接生成 + 公开」</p>
</div>
<div class="form-group" style="margin-top: 24px;">
<label>选择讲解老师</label>
<div style="display: flex; gap: 12px; flex-wrap: wrap; margin-top: 8px;">
${teachers.map((t, i) => `
<label class="batch-teacher-card ${i === 1 ? 'selected' : ''}" onclick="selectBatchTeacher(this)">
<input type="radio" name="batchPersona" value="${t.value}" ${i === 1 ? 'checked' : ''} style="display:none;">
<div>
<div style="font-weight:600;font-size:15px;color:#1a1a2e;">${t.title}</div>
<div style="font-size:12px;color:#888;margin-top:2px;">${t.slogan}</div>
</div>
</label>
`).join('')}
</div>
</div>
<div class="form-group" style="margin-top: 24px;">
<label for="batchTopicsInput">主题列表 <span style="color:#999;font-weight:normal;font-size:13px;">每行一个主题,空行自动忽略</span></label>
<textarea id="batchTopicsInput" class="mindmap-textarea" placeholder="如何高效学英语?\n如何学会高等数学\n如何写好一篇论文\n..." style="min-height: 220px; font-size: 15px; line-height: 1.8;"></textarea>
<div style="font-size: 12px; color: #999; margin-top: 6px;"><span id="batchTopicCount">0</span> 个主题</div>
</div>
<div class="form-group" style="margin-top: 20px; display: flex; gap: 12px; align-items: center;">
<button class="btn-primary" type="button" id="batchStartBtn" onclick="startBatchCreate()" style="flex-shrink:0;">开始批量生成</button>
<button class="btn-primary" type="button" id="batchAbortBtn" onclick="abortBatchCreate()" style="flex-shrink:0; background:#ef4444; display:none;">停止</button>
<span id="batchStatusText" style="font-size: 14px; color: #666;"></span>
</div>
<div id="batchProgressArea" style="display: none; margin-top: 24px;">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:16px;">
<div style="flex:1;height:8px;background:#f1f5f9;border-radius:4px;overflow:hidden;">
<div id="batchProgressBar" style="height:100%;background:linear-gradient(90deg,#F5A623,#f59e0b);width:0%;transition:width .3s;border-radius:4px;"></div>
</div>
<span id="batchProgressPercent" style="font-size:13px;font-weight:600;color:#F5A623;min-width:40px;text-align:right;">0%</span>
</div>
<div id="batchTopicList" style="display:flex;flex-direction:column;gap:6px;"></div>
<div id="batchSummary" class="batch-summary" style="display:none;"></div>
</div>
`;
// 实时统计主题数
const textarea = document.getElementById('batchTopicsInput');
const countEl = document.getElementById('batchTopicCount');
textarea.addEventListener('input', function() {
const lines = this.value.split('\n').filter(l => l.trim());
countEl.textContent = lines.length;
});
}
window.selectBatchTeacher = function(el) {
document.querySelectorAll('.batch-teacher-card').forEach(c => c.classList.remove('selected'));
el.classList.add('selected');
el.querySelector('input[type="radio"]').checked = true;
};
window.startBatchCreate = async function() {
const token = getToken();
if (!token) { showLoginPage(); return; }
const topicsRaw = (document.getElementById('batchTopicsInput') || {}).value || '';
const topics = topicsRaw.split('\n').map(l => l.trim()).filter(l => l.length > 0);
if (topics.length === 0) {
document.getElementById('batchStatusText').textContent = '⚠️ 请输入至少一个主题';
return;
}
const persona = (document.querySelector('input[name="batchPersona"]:checked') || {}).value || 'direct_test_lite_outline';
batchAborted = false;
document.getElementById('batchStartBtn').style.display = 'none';
document.getElementById('batchAbortBtn').style.display = '';
document.getElementById('batchTopicsInput').disabled = true;
document.getElementById('batchProgressArea').style.display = 'block';
document.getElementById('batchSummary').style.display = 'none';
// 渲染主题列表
const listEl = document.getElementById('batchTopicList');
listEl.innerHTML = topics.map((t, i) => `
<div class="batch-topic-row" id="batchRow${i}">
<span class="status-icon"></span>
<span class="topic-text">${i + 1}. ${escapeHtml(t)}</span>
<span class="topic-detail" id="batchDetail${i}">等待中</span>
</div>
`).join('');
let successCount = 0, failCount = 0;
for (let i = 0; i < topics.length; i++) {
if (batchAborted) break;
const row = document.getElementById('batchRow' + i);
const detail = document.getElementById('batchDetail' + i);
row.className = 'batch-topic-row running';
row.querySelector('.status-icon').textContent = '🔄';
detail.textContent = '创建中…';
document.getElementById('batchStatusText').textContent = '正在生成第 ' + (i + 1) + '/' + topics.length + ' 个…';
try {
// 1. 创建任务
const { response, result } = await apiRequest(API_BASE + '/api/ai/courses/create', {
method: 'POST',
body: JSON.stringify({
sourceText: topics[i],
sourceType: 'direct',
persona: persona,
visibility: 'public',
saveAsDraft: true
})
});
if (!response.ok || !result.success) {
throw new Error(result.error?.message || '创建失败');
}
const taskId = result.data?.taskId;
if (!taskId) throw new Error('未返回 taskId');
detail.textContent = '生成中 0%';
// 2. 轮询直到完成
let done = false;
while (!done && !batchAborted) {
await new Promise(r => setTimeout(r, 3000));
const poll = await apiRequest(API_BASE + '/api/ai/courses/' + taskId + '/status', { method: 'GET' });
const st = poll.result?.data?.status;
const prog = poll.result?.data?.progress || 0;
detail.textContent = '生成中 ' + prog + '%';
if (st === 'completed') {
done = true;
row.className = 'batch-topic-row success';
row.querySelector('.status-icon').textContent = '✅';
detail.textContent = '完成';
successCount++;
} else if (st === 'failed') {
done = true;
throw new Error(poll.result?.data?.errorMessage || '生成失败');
}
}
} catch (err) {
row.className = 'batch-topic-row failed';
row.querySelector('.status-icon').textContent = '❌';
detail.textContent = err.message || '失败';
failCount++;
}
// 更新进度条
const pct = Math.round(((i + 1) / topics.length) * 100);
document.getElementById('batchProgressBar').style.width = pct + '%';
document.getElementById('batchProgressPercent').textContent = pct + '%';
}
// 完成
document.getElementById('batchStartBtn').style.display = '';
document.getElementById('batchAbortBtn').style.display = 'none';
document.getElementById('batchTopicsInput').disabled = false;
const abortedCount = batchAborted ? topics.length - successCount - failCount : 0;
document.getElementById('batchStatusText').textContent = batchAborted ? '已停止' : '全部完成!';
const summaryEl = document.getElementById('batchSummary');
summaryEl.style.display = 'flex';
summaryEl.innerHTML = `
<div class="stat" style="background:#f0fdf4;color:#16a34a;">✅ 成功 ${successCount}</div>
<div class="stat" style="background:#fef2f2;color:#dc2626;">❌ 失败 ${failCount}</div>
${abortedCount > 0 ? '<div class="stat" style="background:#f1f5f9;color:#64748b;">⏸ 跳过 ' + abortedCount + '</div>' : ''}
<div class="stat" style="background:#f8fafc;color:#334155;">共 ${topics.length} 个</div>
`;
};
window.abortBatchCreate = function() {
batchAborted = true;
document.getElementById('batchAbortBtn').style.display = 'none';
document.getElementById('batchStatusText').textContent = '正在停止…';
};
function escapeHtml(str) {
const d = document.createElement('div');
d.textContent = str;
return d.innerHTML;
}
// ==================== 批量生成系统课程结束 ====================
// 当前任务ID和风格
let currentAITaskIdForGenerate = null;
let currentAIStyle = 'essence'; // 默认风格
// 渲染AI生成课程主页面输入文本界面 + 生成方式选择 + 风格选择)
function renderAIContentMainPage() {
const appContent = document.getElementById('appContent');
appContent.innerHTML = `
<div class="page-header">
<h2>AI 生成课程</h2>
<p style="color: #666; margin-top: 8px;">输入文本内容,然后生成课程</p>
</div>
<div class="form-group" style="margin-top: 30px;">
<label for="aiContentText">文本内容 <span class="required">*</span></label>
<textarea
id="aiContentText"
class="mindmap-textarea"
placeholder="请输入课程文本内容(建议不超过 2000 万字符)..."
style="min-height: 400px;"
></textarea>
<div style="font-size: 12px; color: #999; margin-top: 8px;">
字符数: <span id="aiContentCharCount">0</span>
</div>
</div>
<div class="form-group" style="margin-top: 30px;">
<label style="font-size: 16px; font-weight: 600; margin-bottom: 20px; display: block;">
选择生成风格 <span class="required">*</span>
</label>
<div style="display: flex; flex-direction: column; gap: 16px;">
<!-- 完整版 -->
<label style="display: flex; align-items: start; padding: 20px; border: 2px solid #e0e0e0; border-radius: 12px; cursor: pointer; transition: all 0.3s; background: white;"
onmouseover="this.style.borderColor='#667eea'; this.style.background='#f8f9ff';"
onmouseout="updateStyleSelectionHover(this, 'full')"
onclick="selectStyleOnMainPage('full')">
<input type="radio" name="generationStyle" value="full" id="styleFull" style="margin-right: 16px; margin-top: 4px; width: 20px; height: 20px; cursor: pointer;">
<div style="flex: 1;">
<div style="font-size: 18px; font-weight: 600; color: #333; margin-bottom: 8px;">
完整版
</div>
<div style="font-size: 14px; color: #666; line-height: 1.6;">
按原目录/结构拆大纲,保留原有结构。如果文本中有目录或章节结构,将原封不动地按照结构来拆分。
</div>
</div>
</label>
<!-- 精华版 -->
<label style="display: flex; align-items: start; padding: 20px; border: 2px solid #667eea; border-radius: 12px; cursor: pointer; transition: all 0.3s; background: #f8f9ff;"
onmouseover="this.style.borderColor='#667eea'; this.style.background='#f8f9ff';"
onmouseout="updateStyleSelectionHover(this, 'essence')"
onclick="selectStyleOnMainPage('essence')">
<input type="radio" name="generationStyle" value="essence" id="styleEssence" checked style="margin-right: 16px; margin-top: 4px; width: 20px; height: 20px; cursor: pointer;">
<div style="flex: 1;">
<div style="font-size: 18px; font-weight: 600; color: #333; margin-bottom: 8px;">
精华版 <span style="font-size: 12px; color: #667eea; font-weight: normal;">(默认)</span>
</div>
<div style="font-size: 14px; color: #666; line-height: 1.6;">
AI智能生成大纲优化学习路径。系统会自动分析内容生成最适合学习的章节结构。
</div>
</div>
</label>
<!-- 一页纸 -->
<label style="display: flex; align-items: start; padding: 20px; border: 2px solid #e0e0e0; border-radius: 12px; cursor: pointer; transition: all 0.3s; background: white;"
onmouseover="this.style.borderColor='#667eea'; this.style.background='#f8f9ff';"
onmouseout="updateStyleSelectionHover(this, 'one-page')"
onclick="selectStyleOnMainPage('one-page')">
<input type="radio" name="generationStyle" value="one-page" id="styleOnePage" style="margin-right: 16px; margin-top: 4px; width: 20px; height: 20px; cursor: pointer;">
<div style="flex: 1;">
<div style="font-size: 18px; font-weight: 600; color: #333; margin-bottom: 8px;">
一页纸
</div>
<div style="font-size: 14px; color: #666; line-height: 1.6;">
无大纲直接拆成一页。适合快速学习每页内容控制在5分钟阅读时间内。
</div>
</div>
</label>
</div>
</div>
<div class="form-group" style="margin-top: 30px;">
<button class="btn-primary" onclick="handleUploadAndGenerateOutline()" id="aiGenerateOutlineBtn" disabled>
<span>生成大纲</span>
</button>
<button class="btn-secondary" onclick="loadAIContentTaskList()" style="margin-left: 12px;">
查看历史任务
</button>
</div>
<div id="aiUploadMessage" class="message" style="display: none;"></div>
`;
// 字符数统计
const textarea = document.getElementById('aiContentText');
const charCount = document.getElementById('aiContentCharCount');
const generateBtn = document.getElementById('aiGenerateOutlineBtn');
if (textarea && charCount) {
textarea.addEventListener('input', () => {
const count = textarea.value.length;
charCount.textContent = count;
if (count > 100000) {
charCount.style.color = '#e74c3c';
} else {
charCount.style.color = '#999';
}
// 更新生成按钮状态
updateGenerateButtonState();
});
}
// 初始化生成按钮状态
updateGenerateButtonState();
}
// 选择风格(主页面)
function selectStyleOnMainPage(style) {
currentAIStyle = style;
// 更新单选按钮状态
document.getElementById('styleFull').checked = style === 'full';
document.getElementById('styleEssence').checked = style === 'essence';
document.getElementById('styleOnePage').checked = style === 'one-page';
// 更新样式
updateStyleSelectionHover(document.querySelector('label[onclick*="full"]'), 'full');
updateStyleSelectionHover(document.querySelector('label[onclick*="essence"]'), 'essence');
updateStyleSelectionHover(document.querySelector('label[onclick*="one-page"]'), 'one-page');
// 更新生成按钮状态
updateGenerateButtonState();
}
// 更新风格选择悬停效果
function updateStyleSelectionHover(element, style) {
if (!element) return;
const isSelected = currentAIStyle === style;
if (isSelected) {
element.style.borderColor = '#667eea';
element.style.background = '#f8f9ff';
} else {
element.style.borderColor = '#e0e0e0';
element.style.background = 'white';
}
}
// 更新生成按钮状态
function updateGenerateButtonState() {
const textarea = document.getElementById('aiContentText');
const generateBtn = document.getElementById('aiGenerateOutlineBtn');
if (!textarea || !generateBtn) return;
const hasText = textarea.value.trim().length > 0;
const hasStyle = currentAIStyle && ['full', 'essence', 'one-page'].includes(currentAIStyle);
if (hasText && hasStyle) {
generateBtn.disabled = false;
generateBtn.style.opacity = '1';
generateBtn.style.cursor = 'pointer';
} else {
generateBtn.disabled = true;
generateBtn.style.opacity = '0.5';
generateBtn.style.cursor = 'not-allowed';
}
}
// 上传文本并生成(书籍解析)
async function handleUploadAndGenerateOutline() {
const textarea = document.getElementById('aiContentText');
const generateBtn = document.getElementById('aiGenerateOutlineBtn');
const messageDiv = document.getElementById('aiUploadMessage');
if (!textarea || !generateBtn) {
alert('页面元素未找到,请刷新页面重试');
return;
}
const content = textarea.value.trim();
if (!content) {
showMessage('aiUploadMessage', '请输入文本内容', 'error');
return;
}
if (content.length > 20000000) {
showMessage('aiUploadMessage', '文本内容过长,建议不超过 2000 万字符', 'error');
return;
}
if (!currentAIStyle || !['full', 'essence', 'one-page'].includes(currentAIStyle)) {
showMessage('aiUploadMessage', '请选择生成风格', 'error');
return;
}
generateBtn.disabled = true;
generateBtn.innerHTML = '<span>处理中...</span>';
hideMessage('aiUploadMessage');
try {
// 书籍解析(一步生成)
await handleBookGeneration(content);
} catch (error) {
console.error('操作失败:', error);
showMessage('aiUploadMessage', '操作失败:' + error.message, 'error');
generateBtn.disabled = false;
generateBtn.innerHTML = '<span>生成大纲</span>';
updateGenerateButtonState();
}
}
// 处理书籍解析(一步生成)
async function handleBookGeneration(content) {
const generateBtn = document.getElementById('aiGenerateOutlineBtn');
generateBtn.innerHTML = '<span>创建任务中...</span>';
// 调用书籍解析API
const { response, result } = await apiRequest(
`${API_BASE}/api/ai/courses/create-book`,
{
method: 'POST',
body: JSON.stringify({
sourceText: content
})
}
);
if (!response.ok || !result.success) {
throw new Error(result.error?.message || '创建书籍解析任务失败');
}
const taskId = result.data.taskId;
const courseId = result.data.courseId;
currentAITaskId = taskId;
currentAITaskIdForGenerate = taskId;
showMessage('aiUploadMessage', '书籍解析任务已创建,正在生成中...', 'success');
// 开始轮询任务状态
startTaskPolling(taskId);
// 显示生成中页面
renderAIContentGeneratingPage();
}
// 渲染风格选择界面(新流程:第二步)
function renderStyleSelection(taskId) {
const appContent = document.getElementById('appContent');
appContent.innerHTML = `
<div class="page-header">
<h2>选择生成风格</h2>
<button class="btn-secondary" onclick="renderAIContentMainPage()" style="margin-left: 12px;">
← 返回
</button>
</div>
<div id="styleSelectionMessage" class="message" style="display: none;"></div>
<div style="margin-top: 30px;">
<div class="form-group">
<label style="font-size: 16px; font-weight: 600; margin-bottom: 20px; display: block;">
请选择生成风格
</label>
<div style="display: flex; flex-direction: column; gap: 16px;">
<!-- 完整版 -->
<label style="display: flex; align-items: start; padding: 20px; border: 2px solid #e0e0e0; border-radius: 12px; cursor: pointer; transition: all 0.3s; background: white;"
onmouseover="this.style.borderColor='#667eea'; this.style.background='#f8f9ff';"
onmouseout="this.style.borderColor='#e0e0e0'; this.style.background='white';"
onclick="selectStyle('full', '${taskId}')">
<input type="radio" name="generationStyle" value="full" style="margin-right: 16px; margin-top: 4px; width: 20px; height: 20px; cursor: pointer;">
<div style="flex: 1;">
<div style="font-size: 18px; font-weight: 600; color: #333; margin-bottom: 8px;">
完整版
</div>
<div style="font-size: 14px; color: #666; line-height: 1.6;">
按原目录/结构拆大纲,保留原有结构。如果文本中有目录或章节结构,将原封不动地按照结构来拆分。
</div>
</div>
</label>
<!-- 精华版 -->
<label style="display: flex; align-items: start; padding: 20px; border: 2px solid #e0e0e0; border-radius: 12px; cursor: pointer; transition: all 0.3s; background: white;"
onmouseover="this.style.borderColor='#667eea'; this.style.background='#f8f9ff';"
onmouseout="this.style.borderColor='#e0e0e0'; this.style.background='white';"
onclick="selectStyle('essence', '${taskId}')">
<input type="radio" name="generationStyle" value="essence" style="margin-right: 16px; margin-top: 4px; width: 20px; height: 20px; cursor: pointer;">
<div style="flex: 1;">
<div style="font-size: 18px; font-weight: 600; color: #333; margin-bottom: 8px;">
精华版
</div>
<div style="font-size: 14px; color: #666; line-height: 1.6;">
AI智能生成大纲优化学习路径。系统会自动分析内容生成最适合学习的章节结构。
</div>
</div>
</label>
<!-- 一页纸 -->
<label style="display: flex; align-items: start; padding: 20px; border: 2px solid #e0e0e0; border-radius: 12px; cursor: pointer; transition: all 0.3s; background: white;"
onmouseover="this.style.borderColor='#667eea'; this.style.background='#f8f9ff';"
onmouseout="this.style.borderColor='#e0e0e0'; this.style.background='white';"
onclick="selectStyle('one-page', '${taskId}')">
<input type="radio" name="generationStyle" value="one-page" style="margin-right: 16px; margin-top: 4px; width: 20px; height: 20px; cursor: pointer;">
<div style="flex: 1;">
<div style="font-size: 18px; font-weight: 600; color: #333; margin-bottom: 8px;">
一页纸
</div>
<div style="font-size: 14px; color: #666; line-height: 1.6;">
无大纲直接拆成一页。适合快速学习每页内容控制在5分钟阅读时间内。
</div>
</div>
</label>
</div>
</div>
</div>
`;
}
// 选择风格(新流程:第二步)
async function selectStyle(style, taskId) {
const messageDiv = document.getElementById('styleSelectionMessage');
hideMessage('styleSelectionMessage');
try {
// 1. 保存风格选择
const { response, result } = await apiRequest(
`${API_BASE}/api/ai/content/tasks/${taskId}/select-style`,
{
method: 'POST',
body: JSON.stringify({ style })
}
);
if (!response.ok || !result.success) {
throw new Error(result.error || '选择风格失败');
}
showMessage('styleSelectionMessage', '风格选择成功', 'success');
// 2. 显示生成课程表单
setTimeout(() => {
renderGenerateCourseForm(taskId, style);
}, 500);
} catch (error) {
console.error('选择风格失败:', error);
showMessage('styleSelectionMessage', '选择风格失败:' + error.message, 'error');
}
}
// 渲染生成课程表单(新流程:第三步)
function renderGenerateCourseForm(taskId, style) {
const appContent = document.getElementById('appContent');
const styleNames = {
'full': '完整版',
'essence': '精华版',
'one-page': '一页纸'
};
appContent.innerHTML = `
<div class="page-header">
<h2>生成课程</h2>
<button class="btn-secondary" onclick="renderStyleSelection('${taskId}')" style="margin-left: 12px;">
← 返回
</button>
</div>
<div id="generateCourseMessage" class="message" style="display: none;"></div>
<div style="margin-top: 20px; padding: 16px; background: #f8f9fa; border-radius: 8px; margin-bottom: 24px;">
<div style="font-size: 14px; color: #666;">
已选择风格:<strong style="color: #667eea;">${styleNames[style]}</strong>
</div>
</div>
<div class="form-group">
<label for="generateCourseTitle">
课程标题 <span class="required">*</span>
</label>
<input
type="text"
id="generateCourseTitle"
placeholder="请输入课程标题"
style="width: 100%; padding: 12px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px;"
>
</div>
<div class="form-group">
<label for="generateCourseSubtitle">副标题</label>
<input
type="text"
id="generateCourseSubtitle"
placeholder="请输入副标题(可选)"
style="width: 100%; padding: 12px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px;"
>
</div>
<div class="form-group">
<label for="generateCourseDescription">课程描述</label>
<textarea
id="generateCourseDescription"
placeholder="请输入课程描述(可选)"
rows="4"
style="width: 100%; padding: 12px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px; resize: vertical;"
></textarea>
</div>
<div class="form-group">
<label>课程类型</label>
<div style="margin-top: 8px;">
<label style="display: flex; align-items: center; margin-bottom: 8px;">
<input type="radio" name="generateCourseType" value="system" checked style="margin-right: 8px; width: 18px; height: 18px;">
<span>体系课(有章节结构,多个节点,按顺序学习)</span>
</label>
<label style="display: flex; align-items: center;">
<input type="radio" name="generateCourseType" value="single" style="margin-right: 8px; width: 18px; height: 18px;">
<span>小节课(单节点,直接进入学习)</span>
</label>
</div>
</div>
<div class="form-group" style="margin-top: 30px; padding-top: 20px; border-top: 2px solid #e0e0e0;">
<button class="btn-primary" onclick="handleGenerateByStyle('${taskId}')" id="generateByStyleBtn">
<span>生成课程</span>
</button>
<button class="btn-secondary" onclick="cancelAITask()" style="margin-left: 12px;">
取消
</button>
</div>
`;
}
// 根据风格生成课程(新流程:第三步)
async function handleGenerateByStyle(taskId) {
const titleInput = document.getElementById('generateCourseTitle');
const subtitleInput = document.getElementById('generateCourseSubtitle');
const descriptionInput = document.getElementById('generateCourseDescription');
const typeInputs = document.querySelectorAll('input[name="generateCourseType"]');
const generateBtn = document.getElementById('generateByStyleBtn');
const messageDiv = document.getElementById('generateCourseMessage');
if (!titleInput || !generateBtn) {
alert('页面元素未找到,请刷新页面重试');
return;
}
const courseTitle = titleInput.value.trim();
if (!courseTitle) {
showMessage('generateCourseMessage', '请输入课程标题', 'error');
return;
}
const courseSubtitle = subtitleInput ? subtitleInput.value.trim() : '';
const courseDescription = descriptionInput ? descriptionInput.value.trim() : '';
const courseType = Array.from(typeInputs).find((input) => input.checked)?.value || 'system';
generateBtn.disabled = true;
generateBtn.innerHTML = '<span>生成中...</span>';
hideMessage('generateCourseMessage');
try {
// 调用统一生成接口(新流程)
const { response, result } = await apiRequest(
`${API_BASE}/api/ai/content/tasks/${taskId}/generate`,
{
method: 'POST',
body: JSON.stringify({
courseTitle,
courseSubtitle: courseSubtitle || null,
courseDescription: courseDescription || null,
type: courseType
})
}
);
if (!response.ok || !result.success) {
throw new Error(result.error || '生成失败');
}
showMessage('generateCourseMessage', '正在生成课程内容,请稍后...', 'success');
// 开始轮询任务状态
currentAITaskId = taskId;
startTaskPolling(taskId);
} catch (error) {
console.error('生成失败:', error);
showMessage('generateCourseMessage', '生成失败:' + error.message, 'error');
generateBtn.disabled = false;
generateBtn.innerHTML = '<span>生成课程</span>';
}
}
// 开始轮询任务状态
function startTaskPolling(taskId) {
// 清除之前的轮询
if (pollingInterval) {
clearInterval(pollingInterval);
}
// 立即查询一次
checkTaskStatus(taskId);
// 每 3 秒轮询一次
pollingInterval = setInterval(() => {
checkTaskStatus(taskId);
}, 3000);
}
// 停止轮询
function stopTaskPolling() {
if (pollingInterval) {
clearInterval(pollingInterval);
pollingInterval = null;
}
}
// 检查任务状态
async function checkTaskStatus(taskId) {
try {
const { response, result } = await apiRequest(
`${API_BASE}/api/ai/courses/${taskId}/status`,
{ method: 'GET' }
);
if (!response.ok || !result.success) {
throw new Error(result.error?.message || '查询任务状态失败');
}
const taskStatus = result.data;
currentAITask = taskStatus;
// 根据状态更新UI
if (taskStatus.status === 'pending') {
renderAITaskStatus('任务已创建,等待处理...', 'loading');
} else if (taskStatus.status === 'content_generating') {
const progress = taskStatus.progress || 0;
renderAITaskStatus(`正在生成课程内容... ${progress}%`, 'loading');
} else if (taskStatus.status === 'completed') {
// 完成,停止轮询,显示成功信息
stopTaskPolling();
renderAITaskCompleted(taskStatus);
} else if (taskStatus.status === 'failed') {
// 失败,停止轮询,显示错误信息
stopTaskPolling();
renderAITaskError(taskStatus);
} else {
// 其他状态,继续轮询
renderAITaskStatus(`处理中... (${taskStatus.status})`, 'loading');
}
} catch (error) {
console.error('查询任务状态失败:', error);
// 不停止轮询,继续尝试
}
}
// 渲染生成中页面
function renderAIContentGeneratingPage() {
const appContent = document.getElementById('appContent');
appContent.innerHTML = `
<div class="page-header">
<h2>AI 生成课程</h2>
</div>
<div style="text-align: center; padding: 60px 20px;">
<div class="spinner" style="margin: 20px auto;"></div>
<p style="margin-top: 20px; color: #666; font-size: 16px;">正在生成大纲,请稍后...</p>
<button class="btn-secondary" onclick="cancelAITask()" style="margin-top: 20px;">
取消任务
</button>
</div>
`;
}
// 渲染任务状态(分析中/生成中)
function renderAITaskStatus(message, type = 'loading') {
const appContent = document.getElementById('appContent');
const spinner = type === 'loading' ? '<div class="spinner" style="margin: 20px auto;"></div>' : '';
const icon = type === 'info' ? '' : '';
appContent.innerHTML = `
<div class="page-header">
<h2>AI 生成课程</h2>
</div>
<div style="text-align: center; padding: 60px 20px;">
${spinner}
<p style="margin-top: 20px; color: #666; font-size: 16px;">${icon} ${message}</p>
<button class="btn-secondary" onclick="cancelAITask()" style="margin-top: 20px;">
取消任务
</button>
</div>
`;
}
// 渲染大纲界面
function renderAIOutline(task, isConfirmed = false) {
const outline = task.confirmedOutline || task.outline;
if (!outline || !outline.chapters || outline.chapters.length === 0) {
renderAITaskError({ errorMessage: '大纲数据为空' });
return;
}
const appContent = document.getElementById('appContent');
let chaptersHtml = '';
outline.chapters.forEach((chapter, chapterIndex) => {
let nodesHtml = '';
if (chapter.nodes && chapter.nodes.length > 0) {
chapter.nodes.forEach((node, nodeIndex) => {
nodesHtml += `
<div class="ai-outline-node" data-chapter-index="${chapterIndex}" data-node-index="${nodeIndex}">
<div style="display: flex; justify-content: space-between; align-items: start;">
<div style="flex: 1;">
<div style="font-weight: 600; color: #333; margin-bottom: 6px;">
${escapeHtml(node.title)}
</div>
<div style="font-size: 13px; color: #666; line-height: 1.5;">
${escapeHtml(node.suggestedContent || '无内容摘要')}
</div>
</div>
<button
class="btn-danger"
onclick="deleteAIOutlineNode(${chapterIndex}, ${nodeIndex})"
style="margin-left: 12px; padding: 6px 12px; font-size: 12px;"
title="删除小节"
>
删除
</button>
</div>
</div>
`;
});
} else {
nodesHtml = '<div style="color: #999; padding: 10px; font-size: 13px;">该章节下没有小节</div>';
}
chaptersHtml += `
<div class="ai-outline-chapter" style="margin-bottom: 30px; padding: 20px; background: #f8f9fa; border-radius: 8px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
<h3 style="margin: 0; font-size: 18px; color: #333;">
第${chapter.order}章:${escapeHtml(chapter.title)}
</h3>
<button
class="btn-danger"
onclick="deleteAIOutlineChapter(${chapterIndex})"
style="padding: 6px 12px; font-size: 12px;"
title="删除章节"
>
删除章节
</button>
</div>
<div class="ai-outline-nodes">
${nodesHtml}
</div>
</div>
`;
});
appContent.innerHTML = `
<div class="page-header">
<h2>课程大纲</h2>
<div style="margin-top: 12px;">
<button class="btn-secondary" onclick="renderAIContentMainPage()">← 返回</button>
<button class="btn-secondary" onclick="regenerateAIOutline()" style="margin-left: 12px;">
重新生成大纲
</button>
</div>
</div>
<div id="aiOutlineMessage" class="message" style="display: none;"></div>
<div class="ai-outline-content" style="margin-top: 30px;">
${chaptersHtml}
</div>
<div class="form-group" style="margin-top: 30px; padding-top: 20px; border-top: 2px solid #e0e0e0;">
${!isConfirmed ? `
<button class="btn-primary" onclick="confirmAIOutline()" id="aiConfirmBtn">
确认大纲
</button>
` : `
<button class="btn-primary" onclick="showGenerateCourseModal()" id="aiGenerateBtn">
生成课程
</button>
`}
</div>
`;
}
// 删除小节
async function deleteAIOutlineNode(chapterIndex, nodeIndex) {
if (!currentAITask || !currentAITask.outline) {
alert('任务数据不存在');
return;
}
if (!confirm('确定要删除这个小节吗?')) {
return;
}
const outline = currentAITask.confirmedOutline || currentAITask.outline;
// 删除节点
if (outline.chapters[chapterIndex] && outline.chapters[chapterIndex].nodes) {
outline.chapters[chapterIndex].nodes.splice(nodeIndex, 1);
// 如果章节下没有节点了,删除该章节
if (outline.chapters[chapterIndex].nodes.length === 0) {
outline.chapters.splice(chapterIndex, 1);
}
// 重新计算全局顺序
let globalOrder = 1;
outline.chapters.forEach(chapter => {
if (chapter.nodes) {
chapter.nodes.forEach(node => {
node.order = globalOrder++;
});
}
});
// 更新大纲
await updateAIOutline(outline);
}
}
// 删除章节
async function deleteAIOutlineChapter(chapterIndex) {
if (!currentAITask || !currentAITask.outline) {
alert('任务数据不存在');
return;
}
if (!confirm('确定要删除这个章节及其下所有小节吗?')) {
return;
}
const outline = currentAITask.confirmedOutline || currentAITask.outline;
// 删除章节
outline.chapters.splice(chapterIndex, 1);
// 重新计算章节顺序和全局顺序
let chapterOrder = 1;
let globalOrder = 1;
outline.chapters.forEach(chapter => {
chapter.order = chapterOrder++;
if (chapter.nodes) {
chapter.nodes.forEach(node => {
node.order = globalOrder++;
});
}
});
// 更新大纲
await updateAIOutline(outline);
}
// 更新大纲
async function updateAIOutline(outline) {
if (!currentAITaskId) {
alert('任务ID不存在');
return;
}
const messageDiv = document.getElementById('aiOutlineMessage');
showMessage('aiOutlineMessage', '正在更新大纲...', 'success');
try {
const { response, result } = await apiRequest(
`${API_BASE}/api/ai/content/tasks/${currentAITaskId}/outline`,
{
method: 'PUT',
body: JSON.stringify({ outline })
}
);
if (!response.ok || !result.success) {
throw new Error(result.error || '更新大纲失败');
}
// 更新当前任务数据
currentAITask = result.data;
// 重新渲染大纲
renderAIOutline(currentAITask, currentAITask.status === 'outline_confirmed');
showMessage('aiOutlineMessage', '大纲已更新', 'success');
} catch (error) {
console.error('更新大纲失败:', error);
showMessage('aiOutlineMessage', '更新失败:' + error.message, 'error');
}
}
// 确认大纲
async function confirmAIOutline() {
if (!currentAITask || !currentAITask.outline) {
alert('大纲数据不存在');
return;
}
const confirmBtn = document.getElementById('aiConfirmBtn');
if (confirmBtn) {
confirmBtn.disabled = true;
confirmBtn.innerHTML = '<span>确认中...</span>';
}
try {
await updateAIOutline(currentAITask.outline);
showMessage('aiOutlineMessage', '大纲已确认,可以开始生成课程', 'success');
} catch (error) {
console.error('确认大纲失败:', error);
if (confirmBtn) {
confirmBtn.disabled = false;
confirmBtn.innerHTML = '<span>确认大纲</span>';
}
}
}
// 重新生成大纲(支持更换风格)
async function regenerateAIOutline() {
if (!currentAITaskId) {
alert('任务ID不存在');
return;
}
// 显示风格选择对话框
const style = await showStyleSelectionDialog(currentAITask?.generationStyle || 'essence');
if (!style) {
return; // 用户取消
}
if (!confirm('确定要重新生成大纲吗?这将清除当前的大纲。')) {
return;
}
try {
const { response, result } = await apiRequest(
`${API_BASE}/api/ai/content/tasks/${currentAITaskId}/regenerate-outline`,
{
method: 'POST',
body: JSON.stringify({ style }) // 传递风格参数
}
);
if (!response.ok || !result.success) {
throw new Error(result.error?.message || '重新生成失败');
}
// 开始轮询
startTaskPolling(currentAITaskId);
renderAIContentGeneratingPage();
} catch (error) {
console.error('重新生成大纲失败:', error);
showMessage('aiOutlineMessage', '操作失败:' + error.message, 'error');
}
}
// 显示风格选择对话框
function showStyleSelectionDialog(currentStyle = 'essence') {
return new Promise((resolve) => {
const modal = document.createElement('div');
modal.style.cssText = 'position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 10000; display: flex; align-items: center; justify-content: center;';
modal.innerHTML = `
<div style="background: white; padding: 30px; border-radius: 12px; max-width: 500px; width: 90%; max-height: 90vh; overflow-y: auto;">
<h3 style="margin: 0 0 20px 0;">选择生成风格</h3>
<div style="display: flex; flex-direction: column; gap: 12px; margin-bottom: 20px;">
<label style="display: flex; align-items: start; padding: 16px; border: 2px solid ${currentStyle === 'full' ? '#667eea' : '#e0e0e0'}; border-radius: 8px; cursor: pointer; background: ${currentStyle === 'full' ? '#f8f9ff' : 'white'};">
<input type="radio" name="regenerateStyle" value="full" ${currentStyle === 'full' ? 'checked' : ''} style="margin-right: 12px; margin-top: 4px;">
<div>
<div style="font-weight: 600; margin-bottom: 4px;">完整版</div>
<div style="font-size: 13px; color: #666;">按原目录/结构拆大纲</div>
</div>
</label>
<label style="display: flex; align-items: start; padding: 16px; border: 2px solid ${currentStyle === 'essence' ? '#667eea' : '#e0e0e0'}; border-radius: 8px; cursor: pointer; background: ${currentStyle === 'essence' ? '#f8f9ff' : 'white'};">
<input type="radio" name="regenerateStyle" value="essence" ${currentStyle === 'essence' ? 'checked' : ''} style="margin-right: 12px; margin-top: 4px;">
<div>
<div style="font-weight: 600; margin-bottom: 4px;">精华版</div>
<div style="font-size: 13px; color: #666;">AI智能生成大纲</div>
</div>
</label>
<label style="display: flex; align-items: start; padding: 16px; border: 2px solid ${currentStyle === 'one-page' ? '#667eea' : '#e0e0e0'}; border-radius: 8px; cursor: pointer; background: ${currentStyle === 'one-page' ? '#f8f9ff' : 'white'};">
<input type="radio" name="regenerateStyle" value="one-page" ${currentStyle === 'one-page' ? 'checked' : ''} style="margin-right: 12px; margin-top: 4px;">
<div>
<div style="font-weight: 600; margin-bottom: 4px;">一页纸</div>
<div style="font-size: 13px; color: #666;">无大纲,直接拆分</div>
</div>
</label>
</div>
<div style="display: flex; gap: 12px; justify-content: flex-end;">
<button class="btn-secondary" onclick="this.closest('div[style*=\"position: fixed\"]').remove(); resolve(null);" style="padding: 8px 16px;">取消</button>
<button class="btn-primary" onclick="const selected = document.querySelector('input[name=\"regenerateStyle\"]:checked'); this.closest('div[style*=\"position: fixed\"]').remove(); resolve(selected ? selected.value : null);" style="padding: 8px 16px;">确定</button>
</div>
</div>
`;
document.body.appendChild(modal);
});
}
// 取消任务
async function cancelAITask() {
if (!currentAITaskId) {
return;
}
if (!confirm('确定要取消当前任务吗?')) {
return;
}
try {
const { response, result } = await apiRequest(
`${API_BASE}/api/ai/content/tasks/${currentAITaskId}/cancel`,
{ method: 'POST' }
);
if (!response.ok || !result.success) {
throw new Error(result.error || '取消任务失败');
}
stopTaskPolling();
currentAITaskId = null;
currentAITask = null;
renderAIContentMainPage();
} catch (error) {
console.error('取消任务失败:', error);
alert('取消任务失败:' + error.message);
}
}
// 显示生成课程模态框
function showGenerateCourseModal() {
const appContent = document.getElementById('appContent');
const modalHtml = `
<div class="modal-overlay" id="generateCourseModal">
<div class="modal-content">
<div class="modal-header">
<h3>生成课程</h3>
<button class="modal-close" onclick="closeGenerateCourseModal()">×</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>课程标题(自动生成)</label>
<div
id="generateCourseTitleDisplay"
style="width: 100%; padding: 12px; background: #f5f5f5; border: 2px solid #d0d0d0; border-radius: 8px; color: #333; font-weight: 500;"
>
${currentAITask?.suggestedTitle || '标题生成中...'}
</div>
</div>
<div class="form-group">
<label for="generateCourseSubtitle">副标题</label>
<input
type="text"
id="generateCourseSubtitle"
placeholder="请输入副标题(可选)"
style="width: 100%; padding: 10px; border: 2px solid #e0e0e0; border-radius: 8px;"
>
</div>
<div class="form-group">
<label for="generateCourseDescription">课程描述</label>
<textarea
id="generateCourseDescription"
placeholder="请输入课程描述(可选)"
rows="4"
style="width: 100%; padding: 10px; border: 2px solid #e0e0e0; border-radius: 8px; resize: vertical;"
></textarea>
</div>
<div class="form-group">
<label>课程类型</label>
<div style="margin-top: 8px;">
<label style="display: flex; align-items: center; margin-bottom: 8px;">
<input type="radio" name="generateCourseType" value="system" checked style="margin-right: 8px;">
<span>体系课(有章节结构,多个节点,按顺序学习)</span>
</label>
<label style="display: flex; align-items: center;">
<input type="radio" name="generateCourseType" value="single" style="margin-right: 8px;">
<span>小节课(单节点,直接进入学习)</span>
</label>
</div>
</div>
<div id="generateCourseMessage" class="message" style="display: none; margin-top: 16px;"></div>
</div>
<div class="modal-footer" style="margin-top: 20px; display: flex; justify-content: flex-end; gap: 12px;">
<button class="btn-secondary" onclick="closeGenerateCourseModal()">取消</button>
<button class="btn-primary" onclick="handleGenerateCourse()" id="generateCourseBtn">
生成课程
</button>
</div>
</div>
</div>
`;
appContent.insertAdjacentHTML('beforeend', modalHtml);
}
// 关闭生成课程模态框
function closeGenerateCourseModal() {
const modal = document.getElementById('generateCourseModal');
if (modal) {
modal.remove();
}
}
// 处理生成课程
async function handleGenerateCourse() {
const titleDisplay = document.getElementById('generateCourseTitleDisplay');
const subtitleInput = document.getElementById('generateCourseSubtitle');
const descriptionInput = document.getElementById('generateCourseDescription');
const typeInputs = document.querySelectorAll('input[name="generateCourseType"]');
const generateBtn = document.getElementById('generateCourseBtn');
const messageDiv = document.getElementById('generateCourseMessage');
if (!generateBtn) {
alert('页面元素未找到,请刷新页面重试');
return;
}
// ✅ 使用自动生成的标题
const courseTitle = currentAITask?.suggestedTitle;
if (!courseTitle) {
showMessage('generateCourseMessage', '课程标题未生成,请稍候...', 'error');
return;
}
const courseSubtitle = subtitleInput ? subtitleInput.value.trim() : '';
const courseDescription = descriptionInput ? descriptionInput.value.trim() : '';
const courseType = Array.from(typeInputs).find(input => input.checked)?.value || 'system';
generateBtn.disabled = true;
generateBtn.innerHTML = '<span>生成中...</span>';
hideMessage('generateCourseMessage');
try {
// ❌ 移除 courseTitle使用后端自动生成的标题
const requestBody = {};
if (courseSubtitle) {
requestBody.courseSubtitle = courseSubtitle;
}
if (courseDescription) {
requestBody.courseDescription = courseDescription;
}
if (courseType) {
requestBody.type = courseType;
}
const { response, result } = await apiRequest(
`${API_BASE}/api/ai/content/tasks/${currentAITaskId}/generate`,
{
method: 'POST',
body: JSON.stringify(requestBody)
}
);
if (!response.ok || !result.success) {
throw new Error(result.error || '生成课程失败');
}
// 关闭模态框
closeGenerateCourseModal();
// 开始轮询
startTaskPolling(currentAITaskId);
} catch (error) {
console.error('生成课程失败:', error);
showMessage('generateCourseMessage', '生成失败:' + error.message, 'error');
generateBtn.disabled = false;
generateBtn.innerHTML = '<span>生成课程</span>';
}
}
// 渲染任务完成界面
function renderAITaskCompleted(task) {
const appContent = document.getElementById('appContent');
appContent.innerHTML = `
<div class="page-header">
<h2>课程生成完成</h2>
</div>
<div style="text-align: center; padding: 60px 20px;">
<div style="font-size: 64px; margin-bottom: 20px;"></div>
<h3 style="color: #27ae60; margin-bottom: 12px;">课程已成功生成!</h3>
<p style="color: #666; margin-bottom: 30px; font-size: 16px;">
课程ID: <code style="background: #f5f5f5; padding: 4px 8px; border-radius: 4px;">${task.courseId || '未知'}</code>
</p>
<div style="display: flex; justify-content: center; gap: 12px;">
<button class="btn-primary" onclick="editCourse('${task.courseId}')">
编辑课程
</button>
<button class="btn-secondary" onclick="renderAIContentMainPage()">
继续生成
</button>
</div>
</div>
`;
// 清除任务状态
currentAITaskId = null;
currentAITask = null;
}
// 渲染任务错误界面
function renderAITaskError(task) {
const appContent = document.getElementById('appContent');
const errorMessage = task.errorMessage || '未知错误';
// 判断是否可以重试分析(失败且有源文本但没有大纲)
const canRetryAnalyze = task.status === 'failed' && task.sourceText && !task.outline;
// 判断是否可以重试生成失败且有课程ID
const canRetryGenerate = task.status === 'failed' && task.courseId;
appContent.innerHTML = `
<div class="page-header">
<h2>任务失败</h2>
</div>
<div style="text-align: center; padding: 60px 20px;">
<div style="font-size: 64px; margin-bottom: 20px;"></div>
<h3 style="color: #e74c3c; margin-bottom: 12px;">任务执行失败</h3>
<p style="color: #666; margin-bottom: 30px; font-size: 16px; max-width: 600px; margin-left: auto; margin-right: auto;">
${escapeHtml(errorMessage)}
</p>
<div style="display: flex; justify-content: center; gap: 12px; flex-wrap: wrap;">
${canRetryAnalyze ? `
<button class="btn-primary" onclick="handleRetryAnalyze('${task.id}')" id="retryAnalyzeBtn">
🔄 重新分析
</button>
` : ''}
${canRetryGenerate ? `
<button class="btn-primary" onclick="handleRetryGenerate('${task.id}')" id="retryGenerateBtn">
🔄 重新生成课程
</button>
` : ''}
<button class="btn-secondary" onclick="renderAIContentMainPage()">
重新开始
</button>
<button class="btn-secondary" onclick="loadAIContentTaskList()">
查看历史任务
</button>
</div>
</div>
`;
}
// 重试分析
async function handleRetryAnalyze(taskId) {
const retryBtn = document.getElementById('retryAnalyzeBtn');
if (!retryBtn) return;
retryBtn.disabled = true;
retryBtn.innerHTML = '<span>正在重新分析...</span>';
try {
const { response, result } = await apiRequest(
`${API_BASE}/api/ai/content/tasks/${taskId}/retry-analyze`,
{ method: 'POST' }
);
if (!response.ok || !result.success) {
throw new Error(result.error || '重试分析失败');
}
// 开始轮询任务状态
currentAITaskId = taskId;
startTaskPolling(taskId);
} catch (error) {
console.error('重试分析失败:', error);
alert('重试失败:' + error.message);
retryBtn.disabled = false;
retryBtn.innerHTML = '<span>🔄 重新分析</span>';
}
}
// 重试生成课程
async function handleRetryGenerate(taskId) {
const retryBtn = document.getElementById('retryGenerateBtn');
if (!retryBtn) return;
retryBtn.disabled = true;
retryBtn.innerHTML = '<span>正在重新生成...</span>';
try {
const { response, result } = await apiRequest(
`${API_BASE}/api/ai/content/tasks/${taskId}/retry-generate`,
{ method: 'POST' }
);
if (!response.ok || !result.success) {
throw new Error(result.error || '重试生成失败');
}
// 开始轮询任务状态
currentAITaskId = taskId;
startTaskPolling(taskId);
} catch (error) {
console.error('重试生成失败:', error);
alert('重试失败:' + error.message);
retryBtn.disabled = false;
retryBtn.innerHTML = '<span>🔄 重新生成课程</span>';
}
}
// 加载任务列表
async function loadAIContentTaskList() {
const appContent = document.getElementById('appContent');
const loadingIndicator = document.getElementById('loadingIndicator');
loadingIndicator.style.display = 'block';
appContent.innerHTML = '';
try {
const { response, result } = await apiRequest(
`${API_BASE}/api/ai/content/tasks?page=1&limit=50`,
{ method: 'GET' }
);
loadingIndicator.style.display = 'none';
if (!response.ok || !result.success) {
throw new Error(result.error || '加载任务列表失败');
}
const tasks = result.data.tasks || [];
if (tasks.length === 0) {
appContent.innerHTML = `
<div class="page-header">
<h2>历史任务</h2>
<button class="btn-secondary" onclick="renderAIContentMainPage()" style="margin-top: 12px;">
← 返回
</button>
</div>
<div style="text-align: center; padding: 60px 20px; color: #999;">
暂无历史任务
</div>
`;
return;
}
let tasksHtml = tasks.map(task => {
const statusText = {
'pending': '等待分析',
'style_selected': '风格已选择',
'analyzing': '分析中',
'outline_generated': '大纲已生成',
'outline_confirmed': '大纲已确认',
'generating': '生成中',
'completed': '已完成',
'failed': '失败',
'cancelled': '已取消'
}[task.status] || task.status;
const styleText = {
'full': '完整版',
'essence': '精华版',
'one-page': '一页纸'
}[task.generationStyle] || (task.generationStyle || '未选择');
const statusColor = {
'pending': '#999',
'style_selected': '#667eea',
'analyzing': '#667eea',
'outline_generated': '#27ae60',
'outline_confirmed': '#27ae60',
'generating': '#667eea',
'completed': '#27ae60',
'failed': '#e74c3c',
'cancelled': '#999'
}[task.status] || '#999';
return `
<div style="padding: 16px; border: 1px solid #e0e0e0; border-radius: 8px; margin-bottom: 12px;">
<div style="display: flex; justify-content: space-between; align-items: start;">
<div style="flex: 1;">
${task.suggestedTitle ? `
<div style="font-weight: 600; color: #333; margin-bottom: 8px; font-size: 16px;">
${escapeHtml(task.suggestedTitle)}
</div>
` : ''}
<div style="font-weight: 600; color: #666; margin-bottom: 8px; font-size: 12px;">
任务ID: <code style="background: #f5f5f5; padding: 2px 6px; border-radius: 4px; font-size: 11px;">${task.id}</code>
</div>
<div style="color: #666; font-size: 13px; margin-bottom: 4px;">
状态: <span style="color: ${statusColor}; font-weight: 600;">${statusText}</span>
</div>
<div style="color: #666; font-size: 13px; margin-bottom: 4px;">
风格: <span style="color: #667eea; font-weight: 600;">${styleText}</span>
</div>
${task.courseId ? `
<div style="color: #666; font-size: 13px; margin-bottom: 4px;">
课程ID: <code style="background: #f5f5f5; padding: 2px 6px; border-radius: 4px; font-size: 12px;">${task.courseId}</code>
</div>
` : ''}
<div style="color: #999; font-size: 12px; margin-top: 8px;">
创建时间: ${new Date(task.createdAt).toLocaleString('zh-CN')}
</div>
</div>
<div style="display: flex; gap: 8px;">
<button class="btn-secondary" onclick="viewAITaskLogs('${task.id}')" style="padding: 6px 12px; font-size: 12px;">
查看日志
</button>
${task.status === 'outline_generated' || task.status === 'outline_confirmed' ? `
<button class="btn-secondary" onclick="viewAITaskOutline('${task.id}')" style="padding: 6px 12px; font-size: 12px;">
查看大纲
</button>
` : ''}
${task.status === 'completed' && task.courseId ? `
<button class="btn-primary" onclick="editCourse('${task.courseId}')" style="padding: 6px 12px; font-size: 12px;">
编辑课程
</button>
` : ''}
</div>
</div>
</div>
`;
}).join('');
appContent.innerHTML = `
<div class="page-header">
<h2>历史任务</h2>
<button class="btn-secondary" onclick="renderAIContentMainPage()" style="margin-top: 12px;">
← 返回
</button>
</div>
<div style="margin-top: 30px;">
${tasksHtml}
</div>
`;
} catch (error) {
loadingIndicator.style.display = 'none';
console.error('加载任务列表失败:', error);
appContent.innerHTML = `
<div class="page-header">
<h2>历史任务</h2>
<button class="btn-secondary" onclick="renderAIContentMainPage()" style="margin-top: 12px;">
← 返回
</button>
</div>
<div class="message error">加载失败:${error.message}</div>
`;
}
}
// 查看任务日志
async function viewAITaskLogs(taskId) {
const appContent = document.getElementById('appContent');
const loadingIndicator = document.getElementById('loadingIndicator');
loadingIndicator.style.display = 'block';
appContent.innerHTML = '';
try {
const { response, result } = await apiRequest(
`${API_BASE}/api/ai/prompts/logs?taskId=${taskId}&limit=100`,
{ method: 'GET' }
);
loadingIndicator.style.display = 'none';
if (!response.ok || !result.success) {
throw new Error(result.error || '加载日志失败');
}
const logs = result.data.logs || [];
if (logs.length === 0) {
appContent.innerHTML = `
<div class="page-header">
<h2>任务日志</h2>
<button class="btn-secondary" onclick="loadAIContentTaskList()" style="margin-top: 12px;">
← 返回任务列表
</button>
</div>
<div style="text-align: center; padding: 60px 20px; color: #999;">
暂无日志记录任务ID: ${taskId}
</div>
`;
return;
}
// 按时间排序(最新的在前)
logs.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
// 渲染日志列表
renderAITaskLogs(taskId, logs);
} catch (error) {
loadingIndicator.style.display = 'none';
console.error('加载任务日志失败:', error);
appContent.innerHTML = `
<div class="page-header">
<h2>任务日志</h2>
<button class="btn-secondary" onclick="loadAIContentTaskList()" style="margin-top: 12px;">
← 返回任务列表
</button>
</div>
<div class="message error">加载失败:${error.message}</div>
`;
}
}
// 渲染任务日志列表
function renderAITaskLogs(taskId, logs) {
const appContent = document.getElementById('appContent');
const logsHtml = logs.map((log, index) => {
const promptTypeInfo = {
'summary': { icon: '🎯', label: '学习意图和摘要' },
'outline': { icon: '📋', label: '大纲生成' },
'content': { icon: '📝', label: '内容生成' }
};
const typeInfo = promptTypeInfo[log.promptType] || { icon: '📄', label: log.promptType };
const statusIcon = log.status === 'success' ? '✅' : '❌';
const statusText = log.status === 'success' ? '成功' : '失败';
const statusColor = log.status === 'success' ? '#27ae60' : '#e74c3c';
const durationText = log.duration ? `${log.duration}ms` : 'N/A';
const tokensText = log.tokensUsed ? log.tokensUsed.toString() : 'N/A';
const timeText = new Date(log.createdAt).toLocaleString('zh-CN');
return `
<div id="log-${log.id}" style="padding: 16px; border: 1px solid #e0e0e0; border-radius: 8px; margin-bottom: 12px; background: white;">
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 12px;">
<div style="flex: 1;">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
<span style="font-size: 18px;">${typeInfo.icon}</span>
<span style="font-weight: 600; color: #333;">${typeInfo.label}</span>
<span style="color: ${statusColor}; font-weight: 600;">${statusIcon} ${statusText}</span>
</div>
<div style="color: #666; font-size: 13px; margin-bottom: 4px;">
时间: ${timeText}
</div>
<div style="color: #666; font-size: 13px; margin-bottom: 4px;">
耗时: ${durationText} | Token: ${tokensText} ${log.model ? `| 模型: ${log.model}` : ''}
</div>
${log.nodeTitle ? `
<div style="color: #666; font-size: 13px; margin-bottom: 4px;">
节点: ${escapeHtml(log.nodeTitle)}
</div>
` : ''}
${log.generationStyle ? `
<div style="color: #666; font-size: 13px; margin-bottom: 4px;">
风格: <span style="color: #667eea; font-weight: 600;">${log.generationStyle === 'full' ? '完整版' : log.generationStyle === 'essence' ? '精华版' : '一页纸'}</span>
</div>
` : ''}
${log.errorMessage ? `
<div style="color: #e74c3c; font-size: 13px; margin-top: 8px; padding: 8px; background: #fee; border-radius: 4px;">
错误: ${escapeHtml(log.errorMessage)}
</div>
` : ''}
</div>
<button
class="btn-secondary"
onclick="toggleLogDetail('${log.id}')"
id="toggle-${log.id}"
style="padding: 6px 12px; font-size: 12px;"
>
展开详情 ▼
</button>
</div>
<div id="detail-${log.id}" style="display: none; margin-top: 16px; padding-top: 16px; border-top: 2px solid #f0f0f0;">
<!-- System Prompt -->
<div style="margin-bottom: 16px;">
<label style="display: block; font-weight: 600; color: #333; margin-bottom: 8px;">
System Prompt:
</label>
<div style="padding: 12px; background: #f8f9fa; border: 2px solid #667eea; border-radius: 8px; font-family: 'Monaco', 'Menlo', 'Courier New', monospace; font-size: 12px; line-height: 1.6; white-space: pre-wrap; color: #333; max-height: 300px; overflow-y: auto;">
${escapeHtml(log.systemPrompt || '')}
</div>
</div>
<!-- User Prompt -->
<div style="margin-bottom: 16px;">
<label style="display: block; font-weight: 600; color: #333; margin-bottom: 8px;">
User Prompt:
</label>
<div style="padding: 12px; background: #f8f9fa; border: 2px solid #d0d0d0; border-radius: 8px; font-family: 'Monaco', 'Menlo', 'Courier New', monospace; font-size: 12px; line-height: 1.6; white-space: pre-wrap; color: #666; max-height: 400px; overflow-y: auto;">
${escapeHtml(log.userPrompt || '')}
</div>
</div>
<!-- AI 响应 -->
${log.responseContent ? `
<div style="margin-bottom: 16px;">
<label style="display: block; font-weight: 600; color: #333; margin-bottom: 8px;">
AI 响应:
</label>
<div style="padding: 12px; background: #f8f9fa; border: 2px solid #d0d0d0; border-radius: 8px; font-family: 'Monaco', 'Menlo', 'Courier New', monospace; font-size: 12px; line-height: 1.6; white-space: pre-wrap; color: #333; max-height: 500px; overflow-y: auto;">
${formatJSONForDisplay(log.responseContent)}
</div>
</div>
` : ''}
<!-- 参数信息 -->
<div style="margin-top: 16px; padding-top: 16px; border-top: 1px solid #e0e0e0;">
<div style="display: flex; gap: 24px; flex-wrap: wrap; font-size: 12px; color: #666;">
${log.temperature !== null && log.temperature !== undefined ? `
<div>Temperature: ${log.temperature}</div>
` : ''}
${log.maxTokens ? `
<div>Max Tokens: ${log.maxTokens}</div>
` : ''}
${log.model ? `
<div>Model: ${log.model}</div>
` : ''}
</div>
</div>
</div>
</div>
`;
}).join('');
appContent.innerHTML = `
<div class="page-header">
<h2>任务日志</h2>
<button class="btn-secondary" onclick="loadAIContentTaskList()" style="margin-top: 12px;">
← 返回任务列表
</button>
</div>
<div style="margin-top: 20px; padding: 12px; background: #f8f9fa; border-radius: 8px; font-size: 13px; color: #666;">
任务ID: <code style="background: white; padding: 2px 6px; border-radius: 4px;">${taskId}</code> | 共 ${logs.length} 条日志记录
</div>
<div style="margin-top: 20px;">
${logsHtml}
</div>
`;
}
// 切换日志详情展开/收起
function toggleLogDetail(logId) {
const detailDiv = document.getElementById(`detail-${logId}`);
const toggleBtn = document.getElementById(`toggle-${logId}`);
if (!detailDiv || !toggleBtn) return;
const isExpanded = detailDiv.style.display !== 'none';
if (isExpanded) {
detailDiv.style.display = 'none';
toggleBtn.innerHTML = '展开详情 ▼';
} else {
detailDiv.style.display = 'block';
toggleBtn.innerHTML = '收起详情 ▲';
}
}
// 格式化JSON显示
function formatJSONForDisplay(jsonString) {
if (!jsonString) return '';
try {
const parsed = JSON.parse(jsonString);
return escapeHtml(JSON.stringify(parsed, null, 2));
} catch (e) {
// 如果不是有效的JSON直接显示原始字符串
return escapeHtml(jsonString);
}
}
// 查看任务大纲
async function viewAITaskOutline(taskId) {
currentAITaskId = taskId;
try {
const { response, result } = await apiRequest(
`${API_BASE}/api/ai/content/tasks/${taskId}`,
{ method: 'GET' }
);
if (!response.ok || !result.success) {
throw new Error(result.error || '查询任务失败');
}
const task = result.data.task;
currentAITask = task;
if (task.status === 'outline_generated' || task.status === 'outline_confirmed') {
renderAIOutline(task, task.status === 'outline_confirmed');
} else {
alert('该任务的大纲还未生成');
}
} catch (error) {
console.error('查看任务大纲失败:', error);
alert('查看失败:' + error.message);
}
}
// 工具函数转义HTML
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// 工具函数:隐藏消息
function hideMessage(messageId) {
const messageDiv = document.getElementById(messageId);
if (messageDiv) {
messageDiv.style.display = 'none';
}
}
// ==================== AI 生成课程功能结束 ====================
// ==================== Prompt 管理 V3.0 ====================
let promptV3ActiveTab = 'editor'; // 'editor' | 'logs'
let promptV3LogsOffset = 0;
let promptV3CurrentType = null; // 当前选中的Prompt类型
// Prompt类型配置含 iOS 端展示信息)
const PROMPT_V3_TYPES = [
{ type: 'course_title', flow: '系统', name: '课程标题生成 Prompt', desc: '用于生成课程标题仅使用材料前2000字输出JSON key为course_title' },
{ type: 'course_summary', flow: '系统', name: '课程知识点摘要 Prompt', desc: '课程生成完成后异步调用提取知识点摘要≤1000字用于续旧课记忆' },
{ type: 'direct_generation_lite', flow: '直接生成', name: '直接测试-豆包lite Prompt', desc: '直接生成流程,可配置模型', canConfigModel: true, iosTitle: '小红学姐', iosSlogan: '万粉博主使用钩子教学法适合重度学习困难者生成10张卡片' },
{ type: 'direct_generation_lite_outline', flow: '直接生成', name: '直接测试-豆包lite-大纲 Prompt', desc: '直接生成流程,侧重大纲,可配置模型', canConfigModel: true, iosTitle: '林老师(推荐)', iosSlogan: '温柔有耐心擅长用大白话和故事的方式讲解生成10卡片' },
{ type: 'direct_generation_lite_summary', flow: '直接生成', name: '直接测试-豆包lite-总结 Prompt', desc: '直接生成流程,侧重总结,可配置模型', canConfigModel: true, iosTitle: '丁老师', iosSlogan: '擅长有条理地讲解内容生成20张卡片' },
{ type: 'text_parse_xiaohongshu', flow: '文本解析', name: '文本解析-小红书 Prompt', desc: '文本解析流程,独立 Prompt可配置模型', canConfigModel: true, iosTitle: '小红学姐', iosSlogan: '万粉知识博主,使用钩子教学法,适合重度学习困难者' },
{ type: 'text_parse_xiaolin', flow: '文本解析', name: '文本解析-小林说 Prompt', desc: '文本解析流程,独立 Prompt可配置模型', canConfigModel: true, iosTitle: '林老师(推荐)', iosSlogan: '极其温柔有耐心,擅长用大白话和故事的方式讲解' },
{ type: 'text_parse_douyin', flow: '文本解析', name: '文本解析-抖音 Prompt', desc: '文本解析流程,独立 Prompt可配置模型', canConfigModel: true, iosTitle: '丁老师', iosSlogan: '擅长有条理地讲解内容' },
{ type: 'continue_course_xiaohongshu', flow: '续旧课', name: '续旧课-小红书 Prompt', desc: '续旧课流程,独立 Prompt可配置模型', canConfigModel: true, iosTitle: '小红学姐', iosSlogan: '万粉博主使用钩子教学法适合重度学习困难者生成10张卡片' },
{ type: 'continue_course_xiaolin', flow: '续旧课', name: '续旧课-小林说 Prompt', desc: '续旧课流程,独立 Prompt可配置模型', canConfigModel: true, iosTitle: '林老师(推荐)', iosSlogan: '温柔有耐心擅长用大白话和故事的方式讲解生成10卡片' },
{ type: 'continue_course_douyin', flow: '续旧课', name: '续旧课-抖音 Prompt', desc: '续旧课流程,独立 Prompt可配置模型', canConfigModel: true, iosTitle: '丁老师', iosSlogan: '擅长有条理地讲解内容生成20张卡片' },
];
// 当前模型配置和可用模型列表
let promptV3Models = {};
let availableModels = [];
async function loadPromptV3Page() {
const appContent = document.getElementById('appContent');
const loadingIndicator = document.getElementById('loadingIndicator');
loadingIndicator.style.display = 'block';
appContent.innerHTML = '';
try {
appContent.innerHTML = `
<div class="page-header">
<h2>📝 Prompt 管理 V3.0</h2>
</div>
<div style="display: flex; gap: 8px; margin-bottom: 20px; border-bottom: 2px solid #e0e0e0;">
<button class="btn-secondary" id="promptV3TabEditor" onclick="switchPromptV3Tab('editor')" style="border-radius: 8px 8px 0 0; border-bottom: none; background: #667eea; color: white;">
✏️ Prompt 配置
</button>
<button class="btn-secondary" id="promptV3TabLogs" onclick="switchPromptV3Tab('logs')" style="border-radius: 8px 8px 0 0; border-bottom: none;">
📋 调用记录
</button>
</div>
<div id="promptV3ContentArea">加载中…</div>
`;
loadingIndicator.style.display = 'none';
promptV3ActiveTab = 'editor';
await loadPromptV3EditorContent();
} catch (e) {
loadingIndicator.style.display = 'none';
const area = document.getElementById('promptV3ContentArea');
if (area) area.innerHTML = '<div class="message error">加载失败:' + escapeHtml(e && e.message ? e.message : String(e)) + '</div>';
}
}
function switchPromptV3Tab(tab) {
promptV3ActiveTab = tab;
const ed = document.getElementById('promptV3TabEditor');
const lo = document.getElementById('promptV3TabLogs');
if (ed) { ed.style.background = tab === 'editor' ? '#667eea' : ''; ed.style.color = tab === 'editor' ? 'white' : ''; }
if (lo) { lo.style.background = tab === 'logs' ? '#667eea' : ''; lo.style.color = tab === 'logs' ? 'white' : ''; }
const area = document.getElementById('promptV3ContentArea');
if (!area) return;
area.innerHTML = '<div style="text-align:center;padding:40px;color:#666;">加载中…</div>';
if (tab === 'editor') loadPromptV3EditorContent();
else { promptV3LogsOffset = 0; loadPromptV3LogsContent(); }
}
async function loadPromptV3EditorContent() {
const area = document.getElementById('promptV3ContentArea');
if (!area) return;
try {
// 加载所有Prompt配置
const { response, result } = await apiRequest(`${API_BASE}/api/ai/prompts/v3`, { method: 'GET' });
if (!response.ok || !result.success) throw new Error(result.error?.message || result.error || '加载失败');
const prompts = result.data?.prompts || {};
promptV3Models = result.data?.models || {};
availableModels = result.data?.availableModels || [];
// 生成Prompt的配置界面
const flowColorMap = { '系统': '#8b5cf6', '直接生成': '#f59e0b', '文本解析': '#10b981', '续旧课': '#3b82f6' };
const promptCards = PROMPT_V3_TYPES.map(p => {
const template = prompts[p.type] || '';
const modelId = promptV3Models[p.type];
const modelName = modelId ? (availableModels.find(m => m.id === modelId)?.name || modelId) : '默认';
const modelBadge = p.canConfigModel ? `<span style="display: inline-block; background: ${modelId ? '#e7f3ff' : '#f0f0f0'}; color: ${modelId ? '#667eea' : '#888'}; padding: 2px 8px; border-radius: 4px; font-size: 11px; margin-left: 8px;">模型: ${modelName}</span>` : '';
const flowColor = flowColorMap[p.flow] || '#888';
const flowBadge = `<span style="display: inline-block; background: ${flowColor}15; color: ${flowColor}; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 500; margin-right: 6px;">${p.flow}</span>`;
const iosInfo = p.iosTitle ? `
<div style="margin-top: 8px; padding: 8px 12px; background: #fafafa; border-radius: 6px; border: 1px dashed #e0e0e0;">
<div style="font-size: 12px; color: #999; margin-bottom: 4px;">📱 iOS 端展示</div>
<div style="font-size: 13px; color: #333; font-weight: 500;">名称:${p.iosTitle}</div>
<div style="font-size: 12px; color: #888; font-style: italic; margin-top: 2px;">Slogan${p.iosSlogan}</div>
</div>` : '';
return `
<div class="prompt-card" data-type="${p.type}">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<div>
<div style="margin-bottom: 6px;">${flowBadge}<code style="font-size: 11px; color: #999; background: #f0f0f0; padding: 2px 6px; border-radius: 3px;">${p.type}</code></div>
<h3 style="margin: 0; font-size: 16px; font-weight: 600; color: #333;">${p.name}${modelBadge}</h3>
<p style="margin: 4px 0 0 0; font-size: 13px; color: #666;">${p.desc}</p>
</div>
<button class="btn-secondary" onclick="loadPromptV3Editor('${p.type}')" style="padding: 8px 16px; font-size: 13px;">编辑</button>
</div>${iosInfo}
<div style="background: #f5f5f5; padding: 12px; border-radius: 8px; max-height: 120px; overflow-y: auto; font-family: 'Monaco', 'Menlo', monospace; font-size: 12px; color: #666; margin-top: 10px;">
${escapeHtml(template.length > 200 ? template.slice(0, 200) + '...' : template)}
</div>
</div>
`;
}).join('');
area.innerHTML = `
<div style="background: #e7f3ff; padding: 16px; border-radius: 8px; margin-bottom: 24px; border-left: 4px solid #667eea;">
<div style="font-weight: 600; color: #333; margin-bottom: 8px;">说明</div>
<div style="font-size: 13px; color: #666; line-height: 1.6;">
此处配置 Prompt课程标题、课程摘要、3种直接生成 Prompt豆包lite、豆包lite-大纲、豆包lite-总结、3种文本解析 Prompt小红书、小林说、抖音、3种续旧课 Prompt小红书、小林说、抖音<br>
每个 Prompt 可单独配置模型。修改后保存即可生效,无需重新部署。
</div>
</div>
<div id="promptV3Message" class="message" style="display: none;"></div>
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; margin-bottom: 24px;">
${promptCards}
</div>
<div id="promptV3EditorArea" style="display: none;">
<div class="form-group">
<label id="promptV3EditorLabel">Prompt 模板 <span class="required">*</span></label>
<textarea id="promptV3EditorTemplate" rows="20" style="width: 100%; padding: 12px; border: 2px solid #e0e0e0; border-radius: 8px; font-family: 'Monaco', 'Menlo', 'Courier New', monospace; font-size: 13px; line-height: 1.5; resize: vertical;"></textarea>
</div>
<div id="promptV3ModelConfig" class="form-group" style="display: none;">
<label>模型配置</label>
<div style="display: flex; gap: 12px; align-items: center;">
<select id="promptV3ModelSelect" style="padding: 8px 12px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px; min-width: 200px;">
<option value="">使用默认模型</option>
</select>
<button class="btn-secondary" onclick="savePromptV3Model()" style="padding: 8px 16px;">保存模型配置</button>
<button class="btn-secondary" onclick="deletePromptV3Model()" style="padding: 8px 16px; color: #e74c3c;">恢复默认</button>
</div>
<p style="margin: 8px 0 0 0; font-size: 12px; color: #888;">提示:模型配置与 Prompt 内容分开保存,修改模型后需单独点击"保存模型配置"</p>
</div>
<div style="display: flex; gap: 12px;">
<button class="btn-primary" id="savePromptV3Btn" onclick="savePromptV3()"><span>保存 Prompt</span></button>
<button class="btn-secondary" id="resetPromptV3Btn" onclick="resetPromptV3()"><span>重置为默认</span></button>
<button class="btn-secondary" onclick="closePromptV3Editor()"><span>取消</span></button>
</div>
</div>
`;
} catch (e) {
area.innerHTML = '<div class="message error">加载失败:' + escapeHtml(e && e.message ? e.message : String(e)) + '</div>';
}
}
async function loadPromptV3Editor(type) {
promptV3CurrentType = type;
const promptInfo = PROMPT_V3_TYPES.find(p => p.type === type);
if (!promptInfo) return;
try {
const { response, result } = await apiRequest(`${API_BASE}/api/ai/prompts/v3/${type}`, { method: 'GET' });
if (!response.ok || !result.success) throw new Error(result.error?.message || result.error || '加载失败');
const template = result.data?.template || '';
const label = document.getElementById('promptV3EditorLabel');
const textarea = document.getElementById('promptV3EditorTemplate');
const editorArea = document.getElementById('promptV3EditorArea');
const modelConfigArea = document.getElementById('promptV3ModelConfig');
const modelSelect = document.getElementById('promptV3ModelSelect');
if (label) label.innerHTML = `${promptInfo.name} <span class="required">*</span>`;
if (textarea) textarea.value = template;
// 处理模型配置区域
if (promptInfo.canConfigModel && modelConfigArea && modelSelect) {
modelConfigArea.style.display = 'block';
// 填充模型选项
modelSelect.innerHTML = '<option value="">使用默认模型</option>' +
availableModels.map(m => `<option value="${m.id}">${m.name}</option>`).join('');
// 设置当前值
const currentModel = promptV3Models[type] || '';
modelSelect.value = currentModel;
} else if (modelConfigArea) {
modelConfigArea.style.display = 'none';
}
if (editorArea) {
editorArea.style.display = 'block';
textarea?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
} catch (e) {
showPromptV3Message('加载失败:' + (e && e.message ? e.message : String(e)), 'error');
}
}
async function savePromptV3Model() {
if (!promptV3CurrentType) return;
const promptInfo = PROMPT_V3_TYPES.find(p => p.type === promptV3CurrentType);
if (!promptInfo || !promptInfo.canConfigModel) return;
const modelSelect = document.getElementById('promptV3ModelSelect');
if (!modelSelect) return;
const modelId = modelSelect.value;
hidePromptV3Message();
try {
if (modelId) {
// 设置模型
const { response, result } = await apiRequest(
`${API_BASE}/api/ai/prompts/v3/${promptV3CurrentType}/model`,
{ method: 'PUT', body: JSON.stringify({ modelId }) }
);
if (!response.ok || !result.success) throw new Error(result.error?.message || result.error || '保存失败');
promptV3Models[promptV3CurrentType] = modelId;
showPromptV3Message('模型配置已保存', 'success');
} else {
// 删除模型配置(恢复默认)
await deletePromptV3Model();
}
// 刷新列表显示
await loadPromptV3EditorContent();
} catch (e) {
showPromptV3Message('保存模型失败:' + (e && e.message ? e.message : String(e)), 'error');
}
}
async function deletePromptV3Model() {
if (!promptV3CurrentType) return;
const promptInfo = PROMPT_V3_TYPES.find(p => p.type === promptV3CurrentType);
if (!promptInfo || !promptInfo.canConfigModel) return;
hidePromptV3Message();
try {
const { response, result } = await apiRequest(
`${API_BASE}/api/ai/prompts/v3/${promptV3CurrentType}/model`,
{ method: 'DELETE' }
);
if (!response.ok || !result.success) throw new Error(result.error?.message || result.error || '删除失败');
delete promptV3Models[promptV3CurrentType];
const modelSelect = document.getElementById('promptV3ModelSelect');
if (modelSelect) modelSelect.value = '';
showPromptV3Message('已恢复使用默认模型', 'success');
// 刷新列表显示
await loadPromptV3EditorContent();
} catch (e) {
showPromptV3Message('删除模型配置失败:' + (e && e.message ? e.message : String(e)), 'error');
}
}
function closePromptV3Editor() {
promptV3CurrentType = null;
const editorArea = document.getElementById('promptV3EditorArea');
if (editorArea) editorArea.style.display = 'none';
}
async function savePromptV3() {
if (!promptV3CurrentType) return;
const textarea = document.getElementById('promptV3EditorTemplate');
const btn = document.getElementById('savePromptV3Btn');
if (!textarea || !btn) return;
const template = textarea.value.trim();
if (!template) {
showPromptV3Message('请输入 Prompt 模板', 'error');
return;
}
hidePromptV3Message();
btn.disabled = true;
btn.innerHTML = '<span>保存中...</span>';
try {
const { response, result } = await apiRequest(
`${API_BASE}/api/ai/prompts/v3/${promptV3CurrentType}`,
{ method: 'PUT', body: JSON.stringify({ template }) }
);
if (response.ok && result.success) {
showPromptV3Message('已保存,下次生成将使用新配置。', 'success');
closePromptV3Editor();
await loadPromptV3EditorContent();
} else {
throw new Error(result.error?.message || result.error || '保存失败');
}
} catch (e) {
showPromptV3Message('保存失败:' + (e && e.message ? e.message : String(e)), 'error');
} finally {
btn.disabled = false;
btn.innerHTML = '<span>保存</span>';
}
}
async function resetPromptV3() {
if (!promptV3CurrentType) return;
const btn = document.getElementById('resetPromptV3Btn');
if (btn) { btn.disabled = true; btn.innerHTML = '<span>重置中...</span>'; }
hidePromptV3Message();
try {
const { response, result } = await apiRequest(
`${API_BASE}/api/ai/prompts/v3/${promptV3CurrentType}/reset`,
{ method: 'POST' }
);
if (response.ok && result.success) {
showPromptV3Message('已重置为默认 Prompt', 'success');
await loadPromptV3Editor(promptV3CurrentType);
} else {
throw new Error(result.error?.message || result.error || '重置失败');
}
} catch (e) {
showPromptV3Message('重置失败:' + (e && e.message ? e.message : String(e)), 'error');
} finally {
if (btn) { btn.disabled = false; btn.innerHTML = '<span>重置为默认</span>'; }
}
}
async function loadPromptV3LogsContent() {
const area = document.getElementById('promptV3ContentArea');
if (!area) return;
try {
const { response, result } = await apiRequest(
`${API_BASE}/api/ai/prompts/v3/logs?limit=50&offset=${promptV3LogsOffset}`,
{ method: 'GET' }
);
if (!response.ok || !result.success) throw new Error(result.error?.message || result.error || '加载失败');
const items = result.data?.items || [];
const total = result.data?.total ?? 0;
const start = promptV3LogsOffset + 1;
const end = Math.min(promptV3LogsOffset + items.length, total);
const rows = items.map(r => {
const taskCreatedTime = r.taskCreatedAt ? new Date(r.taskCreatedAt).toLocaleString('zh-CN') : '-';
const aiCallTime = r.createdAt ? new Date(r.createdAt).toLocaleString('zh-CN') : '-';
const pre = (s) => (s && s.length > 80) ? escapeHtml(s.slice(0,80)) + '…' : escapeHtml(s || '');
const sourceTypeMap = { 'direct': '直接生成', 'document': '文本解析', 'continue': '续旧课' };
const personaMap = { 'direct_test_lite': '直接测试-豆包lite', 'direct_test_lite_outline': '豆包lite-大纲', 'direct_test_lite_summary': '豆包lite-总结', 'text_parse_xiaohongshu': '文本解析-小红书', 'text_parse_xiaolin': '文本解析-小林说', 'text_parse_douyin': '文本解析-抖音', 'continue_course_xiaohongshu': '续旧课-小红书', 'continue_course_xiaolin': '续旧课-小林说', 'continue_course_douyin': '续旧课-抖音' };
const sourceTypeText = r.sourceType ? (sourceTypeMap[r.sourceType] || r.sourceType) : '-';
const personaText = r.persona ? (personaMap[r.persona] || r.persona) : '-';
return `<tr>
<td>${taskCreatedTime}</td>
<td>${aiCallTime}</td>
<td><code style="font-size:11px;">${escapeHtml((r.taskId||'').slice(0,8))}</code></td>
<td><code style="font-size:11px;">${escapeHtml((r.courseId||'-').slice(0,8))}</code></td>
<td>${sourceTypeText}</td>
<td>${personaText}</td>
<td>${r.chunkIndex != null ? r.chunkIndex + 1 : '-'}</td>
<td><span style="color:${r.status==='success'?'#16a34a':'#dc2626'}">${r.status==='success'?'成功':'失败'}</span></td>
<td>${r.durationMs != null ? r.durationMs + ' ms' : '-'}</td>
<td style="max-width:180px;overflow:hidden;text-overflow:ellipsis;" title="${escapeHtml(r.promptPreview||'')}">${pre(r.promptPreview)}</td>
<td style="max-width:180px;overflow:hidden;text-overflow:ellipsis;" title="${escapeHtml(r.responsePreview||'')}">${pre(r.responsePreview)}</td>
<td><button class="btn-secondary" style="padding:6px 12px;font-size:12px;" data-id="${escapeHtml(r.id)}" onclick="openPromptV3LogDetail(this.dataset.id)">详情</button></td>
</tr>`;
}).join('');
area.innerHTML = `
<div style="margin-bottom:16px;display:flex;justify-content:space-between;align-items:center;">
<span style="color:#666;">第 ${start}-${end} 条 / 共 ${total} 条</span>
<div style="display:flex;gap:8px;">
<button class="btn-secondary" ${promptV3LogsOffset <= 0 ? 'disabled' : ''} onclick="promptV3LogsOffset=Math.max(0,promptV3LogsOffset-50);loadPromptV3LogsContent();">上一页</button>
<button class="btn-secondary" ${end >= total ? 'disabled' : ''} onclick="promptV3LogsOffset+=50;loadPromptV3LogsContent();">下一页</button>
</div>
</div>
<div style="overflow-x:auto;">
<table class="structure-header" style="width:100%; border-collapse:collapse;">
<thead><tr>
<th style="padding:8px;text-align:left;">任务创建时间</th>
<th style="padding:8px;text-align:left;">AI调用时间</th>
<th>taskId</th>
<th>courseId</th>
<th>来源类型</th>
<th>导师类型</th>
<th></th>
<th>状态</th>
<th>耗时</th>
<th>请求预览</th>
<th>响应预览</th>
<th></th>
</tr></thead>
<tbody>${rows.length ? rows : '<tr><td colspan="12" style="text-align:center;padding:40px;color:#999;">暂无记录</td></tr>'}</tbody>
</table>
</div>
`;
} catch (e) {
area.innerHTML = '<div class="message error">加载失败:' + escapeHtml(e && e.message ? e.message : String(e)) + '</div>';
}
}
async function openPromptV3LogDetail(id) {
try {
const { response, result } = await apiRequest(`${API_BASE}/api/ai/prompts/v3/logs/${id}`, { method: 'GET' });
if (!response.ok || !result.success) { alert('加载详情失败'); return; }
const d = result.data;
const html = `
<div id="promptV3LogModalOverlay" style="position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:9999;display:flex;align-items:center;justify-content:center;padding:20px;" onclick="if(event.target===this)closePromptV3LogModal();">
<div style="background:#fff;border-radius:12px;max-width:900px;width:100%;max-height:90vh;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.3);">
<div style="padding:16px 20px;border-bottom:1px solid #eee;display:flex;justify-content:space-between;align-items:center;">
<strong>📋 调用详情</strong>
<button class="btn-secondary" onclick="closePromptV3LogModal()">关闭</button>
</div>
<div style="padding:16px 20px;overflow-y:auto;flex:1;">
<div style="margin-bottom:16px;">
<div style="margin-bottom:8px;"><strong>任务创建时间</strong> ${d.taskCreatedAt ? new Date(d.taskCreatedAt).toLocaleString('zh-CN') : '-'}</div>
<div style="margin-bottom:8px;"><strong>AI调用时间</strong> ${d.createdAt ? new Date(d.createdAt).toLocaleString('zh-CN') : '-'}</div>
<div style="margin-bottom:8px;"><strong>来源类型</strong> ${d.sourceType ? (d.sourceType === 'direct' ? '直接生成' : d.sourceType === 'document' ? '文本解析' : d.sourceType === 'continue' ? '续旧课' : d.sourceType) : '-'} &nbsp; <strong>Prompt类型</strong> ${d.persona ? ({'direct_test_lite':'直接测试-豆包lite','direct_test_lite_outline':'豆包lite-大纲','direct_test_lite_summary':'豆包lite-总结','text_parse_xiaohongshu':'文本解析-小红书','text_parse_xiaolin':'文本解析-小林说','text_parse_douyin':'文本解析-抖音','continue_course_xiaohongshu':'续旧课-小红书','continue_course_xiaolin':'续旧课-小林说','continue_course_douyin':'续旧课-抖音'}[d.persona] || d.persona) : '-'}</div>
<div style="margin-bottom:8px;"><strong>状态</strong> <span style="color:${d.status==='success'?'#16a34a':'#dc2626'}">${d.status==='success'?'成功':'失败'}</span> &nbsp; <strong>耗时</strong> ${d.durationMs != null ? d.durationMs + ' ms' : '-'}</div>
</div>
${d.errorMessage ? '<div class="message error" style="margin-bottom:16px;">' + escapeHtml(d.errorMessage) + '</div>' : ''}
<div style="margin-bottom:12px;"><strong>完整请求(发往模型)</strong></div>
<pre style="background:#f5f5f5;padding:12px;border-radius:8px;max-height:280px;overflow:auto;white-space:pre-wrap;word-break:break-all;font-size:12px;">${escapeHtml(d.promptFull || '')}</pre>
<div style="margin:16px 0 12px 0;"><strong>完整响应(模型返回)</strong></div>
<pre style="background:#f5f5f5;padding:12px;border-radius:8px;max-height:280px;overflow:auto;white-space:pre-wrap;word-break:break-all;font-size:12px;">${escapeHtml(d.responseFull || '(无)')}</pre>
</div>
</div>
</div>
`;
const div = document.createElement('div');
div.innerHTML = html;
document.body.appendChild(div.firstElementChild);
} catch (e) {
alert('加载详情失败:' + (e && e.message ? e.message : String(e)));
}
}
function closePromptV3LogModal() {
document.getElementById('promptV3LogModalOverlay')?.remove();
}
function showPromptV3Message(msg, type) {
const el = document.getElementById('promptV3Message');
if (!el) return;
el.textContent = msg;
el.className = 'message ' + (type === 'success' ? 'success' : 'error');
el.style.display = 'block';
}
function hidePromptV3Message() {
const el = document.getElementById('promptV3Message');
if (el) el.style.display = 'none';
}
// ==================== Prompt 管理 V3.0 结束 ====================
</script>
</body>
</html>