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

10787 lines
505 KiB
HTML
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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>