10787 lines
505 KiB
HTML
10787 lines
505 KiB
HTML
<!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>
|
||
幻灯片标题1<br>
|
||
内容段落1<br>
|
||
1、列表项1<br>
|
||
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, '"').replace(/'/g, ''')})">确认匹配</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) : '-'} <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> <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>
|
||
|