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

2176 lines
80 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📚</text></svg>">
<title>课程完整同步工具 - Wild Growth</title>
<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;
}
.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;
}
/* 标签页导航样式 */
.tabs {
display: flex;
gap: 8px;
margin-bottom: 30px;
border-bottom: 2px solid #e0e0e0;
}
.tab {
padding: 12px 24px;
background: transparent;
border: none;
border-bottom: 3px solid transparent;
font-size: 16px;
font-weight: 600;
color: #666;
cursor: pointer;
transition: all 0.3s;
}
.tab:hover {
color: #667eea;
}
.tab.active {
color: #667eea;
border-bottom-color: #667eea;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
/* 封面图管理样式 */
.cover-manager {
margin-top: 20px;
}
.courses-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
margin-top: 20px;
}
.course-card {
border: 2px solid #e0e0e0;
border-radius: 12px;
padding: 20px;
background: #f9f9f9;
transition: all 0.3s;
}
.course-card:hover {
border-color: #667eea;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.2);
}
.course-header {
display: flex;
justify-content: space-between;
align-items: start;
margin-bottom: 15px;
}
.course-title {
font-size: 18px;
font-weight: 600;
color: #333;
margin-bottom: 5px;
}
.course-id {
font-size: 12px;
color: #999;
font-family: monospace;
}
.course-cover-preview {
width: 100%;
aspect-ratio: 3/4;
object-fit: cover;
border-radius: 8px;
margin-bottom: 15px;
background: #e0e0e0;
display: flex;
align-items: center;
justify-content: center;
color: #999;
font-size: 14px;
}
.course-cover-preview img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 8px;
}
.cover-upload-area {
border: 2px dashed #ccc;
border-radius: 8px;
padding: 15px;
text-align: center;
cursor: pointer;
transition: all 0.3s;
margin-bottom: 10px;
}
.cover-upload-area:hover {
border-color: #667eea;
background: #f0f7ff;
}
.cover-upload-area input {
display: none;
}
.cover-upload-text {
font-size: 14px;
color: #666;
margin-top: 8px;
}
.cover-status {
font-size: 12px;
padding: 4px 8px;
border-radius: 4px;
margin-top: 10px;
display: inline-block;
}
.cover-status.has-cover {
background: #d4edda;
color: #155724;
}
.cover-status.no-cover {
background: #f8d7da;
color: #721c24;
}
.cover-actions {
display: flex;
gap: 8px;
margin-top: 10px;
}
.btn-update-cover {
flex: 1;
padding: 8px 16px;
background: #667eea;
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background 0.3s;
}
.btn-update-cover:hover {
background: #5568d3;
}
.btn-update-cover:disabled {
background: #ccc;
cursor: not-allowed;
}
.cover-url-display {
font-size: 12px;
color: #666;
margin-top: 8px;
word-break: break-all;
font-family: monospace;
background: #f5f5f5;
padding: 8px;
border-radius: 4px;
}
.form-group {
margin-bottom: 25px;
}
label {
display: block;
font-weight: 600;
margin-bottom: 8px;
color: #333;
font-size: 14px;
}
input, textarea {
width: 100%;
padding: 12px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 14px;
transition: border-color 0.3s;
}
input:focus, textarea:focus {
outline: none;
border-color: #667eea;
}
textarea {
min-height: 500px;
font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
font-size: 13px;
line-height: 1.6;
}
.button-group {
display: flex;
gap: 12px;
margin-top: 20px;
flex-wrap: wrap;
}
button {
flex: 1;
min-width: 150px;
padding: 14px 24px;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4);
}
.btn-secondary {
background: #f5f5f5;
color: #333;
}
.btn-secondary:hover {
background: #e0e0e0;
}
.info-box {
background: #f0f7ff;
border-left: 4px solid #667eea;
padding: 20px;
margin-bottom: 25px;
border-radius: 8px;
}
.info-box strong {
color: #667eea;
display: block;
margin-bottom: 10px;
}
.info-box ul {
margin-left: 20px;
color: #555;
}
.info-box li {
margin-bottom: 8px;
}
.example {
background: #f9f9f9;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 15px;
margin-bottom: 20px;
}
.example h3 {
margin-bottom: 10px;
color: #333;
font-size: 16px;
}
.example code {
display: block;
background: #2d2d2d;
color: #f8f8f2;
padding: 15px;
border-radius: 6px;
overflow-x: auto;
font-size: 12px;
line-height: 1.5;
}
.result {
margin-top: 25px;
padding: 20px;
border-radius: 8px;
display: none;
}
.result.success {
background: #d4edda;
border: 1px solid #c3e6cb;
color: #155724;
display: block;
}
.result.error {
background: #f8d7da;
border: 1px solid #f5c6cb;
color: #721c24;
display: block;
}
.result.loading {
background: #d1ecf1;
border: 1px solid #bee5eb;
color: #0c5460;
display: block;
}
.result h3 {
margin-bottom: 10px;
}
.result pre {
background: rgba(0, 0, 0, 0.1);
padding: 10px;
border-radius: 4px;
overflow-x: auto;
margin-top: 10px;
font-size: 12px;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 15px;
margin-top: 15px;
}
.stat-item {
background: white;
padding: 15px;
border-radius: 8px;
text-align: center;
}
.stat-item .number {
font-size: 24px;
font-weight: bold;
color: #667eea;
}
.stat-item .label {
font-size: 12px;
color: #666;
margin-top: 5px;
}
/* 图片上传区域样式 */
.upload-area {
border: 2px dashed #ccc;
border-radius: 8px;
padding: 20px;
text-align: center;
transition: all 0.3s;
}
.upload-area:hover {
border-color: #667eea;
background: #f0f7ff;
}
.upload-placeholder {
cursor: pointer;
}
.upload-icon {
font-size: 48px;
margin-bottom: 10px;
}
.upload-text {
font-size: 16px;
color: #333;
margin-bottom: 5px;
}
.upload-hint {
font-size: 12px;
color: #999;
}
.upload-preview {
display: flex;
align-items: center;
gap: 15px;
padding: 15px;
background: #f9f9f9;
border-radius: 8px;
margin-top: 10px;
}
.upload-preview img {
max-width: 120px;
max-height: 120px;
border-radius: 8px;
object-fit: cover;
}
.upload-info {
flex: 1;
}
.upload-url {
background: white;
padding: 10px;
border-radius: 6px;
font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
font-size: 12px;
word-break: break-all;
margin-bottom: 10px;
border: 1px solid #e0e0e0;
}
.upload-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.btn-copy, .btn-auto-fill, .btn-remove {
padding: 8px 16px;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
}
.btn-copy {
background: #667eea;
color: white;
}
.btn-copy:hover {
background: #5568d3;
}
.btn-auto-fill {
background: #28a745;
color: white;
}
.btn-auto-fill:hover {
background: #218838;
}
.btn-remove {
background: #f5f5f5;
color: #333;
}
.btn-remove:hover {
background: #e0e0e0;
}
.upload-status {
margin-top: 10px;
padding: 10px;
border-radius: 6px;
font-size: 14px;
display: none;
}
.upload-status.success {
background: #d4edda;
color: #155724;
display: block;
}
.upload-status.error {
background: #f8d7da;
color: #721c24;
display: block;
}
.upload-status.loading {
background: #d1ecf1;
color: #0c5460;
display: block;
}
/* 可视化图片匹配面板 */
.json-container {
position: relative;
}
.image-match-panel {
margin-top: 30px;
background: #f9f9f9;
border: 2px solid #667eea;
border-radius: 12px;
padding: 20px;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 2px solid #e0e0e0;
}
.panel-header h3 {
margin: 0;
color: #333;
font-size: 20px;
}
.btn-close-panel {
background: #f5f5f5;
border: none;
border-radius: 6px;
padding: 8px 16px;
cursor: pointer;
font-size: 18px;
color: #666;
}
.btn-close-panel:hover {
background: #e0e0e0;
}
.panel-stats {
background: white;
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
display: flex;
gap: 20px;
flex-wrap: wrap;
}
.stat-badge {
padding: 8px 16px;
border-radius: 6px;
font-size: 14px;
font-weight: 600;
}
.stat-badge.total {
background: #e3f2fd;
color: #1976d2;
}
.stat-badge.need-image {
background: #fff3e0;
color: #f57c00;
}
.stat-badge.has-image {
background: #e8f5e9;
color: #388e3c;
}
.slides-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
}
.slide-card {
background: white;
border: 2px solid #e0e0e0;
border-radius: 8px;
padding: 15px;
transition: all 0.3s;
}
.slide-card:hover {
border-color: #667eea;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.15);
}
.slide-card.has-image {
border-color: #4caf50;
}
.slide-card-header {
display: flex;
justify-content: space-between;
align-items: start;
margin-bottom: 10px;
}
.slide-info {
flex: 1;
}
.slide-node-title {
font-weight: 600;
color: #333;
font-size: 14px;
margin-bottom: 4px;
}
.slide-order {
font-size: 12px;
color: #999;
}
.slide-image-section {
margin-top: 10px;
}
.slide-image-preview {
width: 100%;
max-height: 150px;
object-fit: cover;
border-radius: 6px;
margin-bottom: 10px;
border: 1px solid #e0e0e0;
}
.slide-image-url {
font-size: 11px;
color: #666;
word-break: break-all;
margin-bottom: 10px;
padding: 8px;
background: #f5f5f5;
border-radius: 4px;
}
.slide-upload-btn {
width: 100%;
padding: 10px;
background: #667eea;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
}
.slide-upload-btn:hover {
background: #5568d3;
}
.slide-upload-btn.has-image {
background: #4caf50;
}
.slide-upload-input {
display: none;
}
.slide-position-selector {
margin-top: 12px;
}
.position-select {
width: 100%;
padding: 8px 12px;
border: 1px solid #e0e0e0;
border-radius: 6px;
font-size: 14px;
background: white;
cursor: pointer;
transition: border-color 0.3s;
}
.position-select:hover {
border-color: #667eea;
}
.position-select:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.slide-text-preview {
margin-top: 12px;
margin-bottom: 12px;
padding: 12px;
background: #f9f9f9;
border-radius: 6px;
border-left: 3px solid #667eea;
}
.slide-text-title {
font-size: 14px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
line-height: 1.4;
}
.slide-text-paragraphs {
font-size: 12px;
color: #666;
line-height: 1.5;
max-height: 120px;
overflow-y: auto;
}
.slide-text-paragraph {
margin-bottom: 6px;
}
.slide-text-empty {
font-size: 12px;
color: #999;
font-style: italic;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>📚 课程完整同步工具</h1>
<p>Wild Growth - 一次性同步整个课程(课程信息 + 章节 + 节点 + 内容)</p>
</div>
<div class="content">
<!-- 标签页导航 -->
<div class="tabs">
<button class="tab active" onclick="switchTab('sync')">📚 课程同步</button>
<button class="tab" onclick="switchTab('cover')">📸 封面图管理</button>
</div>
<!-- 课程同步标签页 -->
<div id="tab-sync" class="tab-content active">
<div class="info-box">
<strong>📋 使用说明:</strong>
<ul>
<li>1. 填写 API 地址默认https://api.muststudy.xin</li>
<li>2. 填写 JWT Token从登录接口获取</li>
<li>3. 在 JSON 编辑器中输入完整的课程数据(包括课程信息、章节、节点、幻灯片)</li>
<li>4. 点击"同步课程"按钮,工具会自动依次创建:课程 → 章节和节点 → 节点内容</li>
<li>5. 查看同步结果和统计信息</li>
</ul>
</div>
<div class="form-group">
<label for="apiUrl">API 地址</label>
<input type="text" id="apiUrl" value="https://api.muststudy.xin" placeholder="https://api.muststudy.xin">
</div>
<div class="form-group">
<label for="token">JWT Token *</label>
<div style="display: flex; gap: 8px;">
<input type="text" id="token" value="" placeholder="从登录接口获取的 token" required style="flex: 1;">
<button type="button" onclick="getTestToken()" style="padding: 8px 16px; background: #4CAF50; color: white; border: none; border-radius: 4px; cursor: pointer; white-space: nowrap;">🔑 快速获取 Token</button>
</div>
<div style="font-size: 12px; color: #999; margin-top: 4px;">💡 点击"快速获取 Token"按钮自动获取并填写</div>
</div>
<div class="form-group">
<label>📷 图片上传工具</label>
<div class="upload-area" id="uploadArea">
<input type="file" id="imageInput" accept="image/jpeg,image/jpg,image/png,image/webp" style="display: none;" onchange="handleImageUpload(event)">
<div class="upload-placeholder" onclick="document.getElementById('imageInput').click()">
<div class="upload-icon">📤</div>
<div class="upload-text">点击选择图片JPG/PNG/WebP< 2MB</div>
<div class="upload-hint">或拖拽图片到此处</div>
</div>
<div class="upload-preview" id="uploadPreview" style="display: none;">
<img id="previewImage" src="" alt="预览">
<div class="upload-info">
<div class="upload-url" id="uploadUrl"></div>
<div class="upload-actions">
<button class="btn-copy" onclick="copyImageUrl()">复制 URL</button>
<button class="btn-auto-fill" onclick="autoFillImageUrl()">自动填充到 JSON</button>
<button class="btn-remove" onclick="removeUploadedImage()">移除</button>
</div>
</div>
</div>
</div>
<div class="upload-status" id="uploadStatus"></div>
</div>
<div class="example">
<h3>📝 JSON 数据格式示例:</h3>
<code id="exampleCode">{
"course": {
"id": "course_001",
"title": "认知觉醒",
"subtitle": "开启你的元认知之旅",
"description": "情绪急救与心理复原力构建。",
"coverImage": "brain.head.goforward.ar"
},
"chapters": [
{
"title": "第一章:重新认识大脑",
"order": 1,
"nodes": [
{
"id": "node_01_01",
"title": "我们为什么会痛苦?",
"subtitle": "理解痛苦的根源",
"duration": 5,
"slides": [
{
"type": "text",
"order": 1,
"content": {
"title": "思维的杠杆",
"paragraphs": [
"阿基米德说:给我一个支点,我能撬动地球。"
],
"highlightKeywords": ["杠杆率"]
}
}
]
}
]
}
]
}</code>
</div>
<div class="form-group">
<label for="jsonData">完整课程数据 (JSON) *</label>
<div class="json-container">
<textarea id="jsonData" placeholder='请输入完整的课程 JSON 数据...'></textarea>
</div>
</div>
<div class="button-group">
<button class="btn-secondary" onclick="loadExample()">加载示例数据</button>
<button class="btn-secondary" onclick="formatJSON()">格式化 JSON</button>
<button class="btn-secondary" onclick="validateJSON()">验证格式</button>
<button class="btn-secondary" onclick="parseSlidesForImages()">📷 解析幻灯片(可视化匹配)</button>
<button class="btn-primary" onclick="syncCourse()">🚀 同步课程</button>
</div>
<!-- 可视化图片匹配区域 -->
<div id="imageMatchPanel" class="image-match-panel" style="display: none;">
<div class="panel-header">
<h3>📷 可视化图片匹配</h3>
<button class="btn-close-panel" onclick="closeImageMatchPanel()"></button>
</div>
<div class="panel-content">
<div class="panel-stats" id="panelStats"></div>
<div class="slides-grid" id="slidesGrid"></div>
</div>
</div>
<div id="result" class="result"></div>
</div>
<!-- 封面图管理标签页 -->
<div id="tab-cover" class="tab-content">
<div class="info-box">
<strong>📸 封面图管理说明:</strong>
<ul>
<li>1. 填写 API 地址和 JWT Token与课程同步共用</li>
<li>2. 点击"加载课程列表"获取所有课程</li>
<li>3. 为每个课程上传封面图750x10003:4 比例)</li>
<li>4. 上传后自动更新到数据库</li>
</ul>
</div>
<div class="cover-manager">
<div class="form-group" style="margin-bottom: 16px;">
<label for="coverApiUrl">API 地址</label>
<input type="text" id="coverApiUrl" value="https://api.muststudy.xin" placeholder="https://api.muststudy.xin" style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;">
</div>
<div class="form-group" style="margin-bottom: 16px;">
<label for="coverToken">JWT Token *</label>
<div style="display: flex; gap: 8px;">
<input type="text" id="coverToken" value="" placeholder="从登录接口获取的 token" required style="flex: 1; padding: 8px; border: 1px solid #ddd; border-radius: 4px;">
<button type="button" onclick="getCoverTestToken()" style="padding: 8px 16px; background: #4CAF50; color: white; border: none; border-radius: 4px; cursor: pointer; white-space: nowrap;">🔑 快速获取 Token</button>
</div>
<div style="font-size: 12px; color: #999; margin-top: 4px;">💡 点击"快速获取 Token"按钮自动获取并填写</div>
</div>
<div class="button-group">
<button class="btn-primary" onclick="loadCoursesList()">📋 加载课程列表</button>
<button class="btn-secondary" onclick="refreshCoursesList()">🔄 刷新列表</button>
</div>
<div id="coursesList" class="courses-grid"></div>
<div id="coverResult" class="result"></div>
</div>
</div>
</div>
</div>
<script>
function loadExample() {
const example = {
course: {
id: "course_example",
title: "示例课程",
subtitle: "这是一个示例课程",
description: "这是课程描述",
coverImage: "book.fill"
},
chapters: [
{
title: "第一章:入门",
order: 1,
nodes: [
{
id: "node_example_01",
title: "第一个节点",
subtitle: "节点副标题",
duration: 5,
slides: [
{
type: "text",
order: 1,
content: {
title: "幻灯片标题",
paragraphs: [
"这是第一段内容。",
"这是第二段内容。"
],
highlightKeywords: ["关键词1", "关键词2"]
},
effect: "fade_in"
},
{
type: "image",
order: 2,
content: {
paragraphs: [
"这是一张图片幻灯片。"
],
imageUrl: "https://example.com/image.jpg",
imagePosition: "bottom"
},
effect: "slide_up"
}
]
}
]
}
]
};
document.getElementById('jsonData').value = JSON.stringify(example, null, 2);
}
function formatJSON() {
const textarea = document.getElementById('jsonData');
try {
const data = JSON.parse(textarea.value);
textarea.value = JSON.stringify(data, null, 2);
showResult('success', '✅ JSON 格式化成功!');
} catch (e) {
showResult('error', '❌ JSON 格式错误:' + e.message);
}
}
function validateJSON() {
const textarea = document.getElementById('jsonData');
try {
const data = JSON.parse(textarea.value);
// 验证必要字段
const errors = [];
if (!data.course) errors.push('缺少 course 对象');
if (!data.course?.id) errors.push('course.id 不能为空');
if (!data.course?.title) errors.push('course.title 不能为空');
if (!data.chapters || !Array.isArray(data.chapters)) errors.push('chapters 必须是数组');
if (errors.length > 0) {
showResult('error', '❌ 验证失败:\n' + errors.join('\n'));
return false;
}
// 统计信息
let totalNodes = 0;
let totalSlides = 0;
data.chapters.forEach(ch => {
if (ch.nodes) {
totalNodes += ch.nodes.length;
ch.nodes.forEach(node => {
if (node.slides) {
totalSlides += node.slides.length;
}
});
}
});
showResult('success', `✅ JSON 格式正确!\n\n统计信息:\n- 章节数:${data.chapters.length}\n- 节点数:${totalNodes}\n- 幻灯片数:${totalSlides}`);
return true;
} catch (e) {
showResult('error', '❌ JSON 格式错误:' + e.message);
return false;
}
}
async function syncCourse() {
const apiUrl = document.getElementById('apiUrl').value.trim();
const token = document.getElementById('token').value.trim();
const jsonData = document.getElementById('jsonData').value.trim();
if (!token) {
showResult('error', '❌ 请填写 JWT Token');
return;
}
if (!jsonData) {
showResult('error', '❌ 请填写课程数据');
return;
}
let data;
try {
data = JSON.parse(jsonData);
} catch (e) {
showResult('error', '❌ JSON 格式错误:' + e.message);
return;
}
// 验证格式
if (!validateJSON()) {
return;
}
showResult('loading', '⏳ 正在同步课程,请稍候...');
try {
const courseId = data.course.id;
const results = {
course: null,
chapters: null,
slides: []
};
// 步骤1: 创建或更新课程(通过 Prisma 直接操作,这里先跳过,在创建章节时会自动创建)
showResult('loading', '⏳ 步骤 1/3: 准备课程信息...');
// 注意:课程会在创建章节时自动创建(如果不存在),或者需要手动在数据库中创建
// 这里我们假设课程已经存在,如果不存在,会在创建章节时失败
console.log('课程信息:', data.course);
// 步骤2: 创建章节和节点(同时创建课程,如果不存在)
showResult('loading', '⏳ 步骤 2/3: 创建章节和节点...');
const chaptersResponse = await fetch(`${apiUrl}/api/courses/${courseId}/chapters-nodes`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
course: data.course, // 传递课程信息,如果课程不存在会自动创建
chapters: data.chapters.map(ch => ({
title: ch.title,
order: ch.order,
nodes: ch.nodes.map(node => ({
id: node.id,
title: node.title,
subtitle: node.subtitle,
duration: node.duration
}))
}))
})
});
if (!chaptersResponse.ok) {
const error = await chaptersResponse.json();
throw new Error(error.message || '创建章节和节点失败');
}
results.chapters = await chaptersResponse.json();
// 步骤3: 创建节点内容(幻灯片)
showResult('loading', '⏳ 步骤 3/3: 创建节点内容...');
let slidesCreated = 0;
let slidesFailed = 0;
for (const chapter of data.chapters) {
for (const node of chapter.nodes) {
if (node.slides && node.slides.length > 0) {
try {
const slidesResponse = await fetch(`${apiUrl}/api/courses/nodes/${node.id}/slides`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
slides: node.slides.map(slide => ({
id: `${node.id}_slide_${slide.order}`,
slideType: slide.type,
order: slide.order,
title: slide.content?.title,
paragraphs: slide.content?.paragraphs,
imageUrl: slide.content?.imageUrl,
highlightKeywords: slide.content?.highlightKeywords,
imagePosition: slide.content?.imagePosition,
effect: slide.effect,
interaction: slide.interaction
}))
})
});
if (slidesResponse.ok) {
slidesCreated += node.slides.length;
} else {
slidesFailed += node.slides.length;
}
} catch (e) {
console.error(`创建节点 ${node.id} 的内容失败:`, e);
slidesFailed += node.slides.length;
}
}
}
}
// 显示结果
const stats = results.chapters.data;
// 统计图片关联情况
let imageSlidesCount = 0;
let imageSlidesWithUrl = 0;
for (const chapter of data.chapters) {
for (const node of chapter.nodes) {
if (node.slides && Array.isArray(node.slides)) {
node.slides.forEach(slide => {
if (slide.type === 'image' || (slide.content && slide.content.imageUrl !== undefined)) {
imageSlidesCount++;
if (slide.content?.imageUrl && slide.content.imageUrl !== 'placeholder' && slide.content.imageUrl !== '') {
imageSlidesWithUrl++;
}
}
});
}
}
}
const message = `
✅ 课程同步完成!
📊 统计信息:
- 章节数:${stats.chapters_created}
- 节点数:${stats.nodes_created}
- 幻灯片数:${slidesCreated}(失败:${slidesFailed}
${imageSlidesCount > 0 ? `- 图片幻灯片:${imageSlidesCount} 张,已关联图片:${imageSlidesWithUrl}` : ''}
📝 详细信息:
${JSON.stringify(results.chapters, null, 2)}
`;
showResult('success', message);
} catch (error) {
showResult('error', '❌ 同步失败:' + error.message);
console.error('同步错误:', error);
}
}
function showResult(type, message) {
const resultDiv = document.getElementById('result');
resultDiv.className = `result ${type}`;
resultDiv.innerHTML = `<pre>${message}</pre>`;
}
// 图片上传相关变量
let uploadedImageUrl = null;
// 处理图片上传
async function handleImageUpload(event) {
const file = event.target.files[0];
if (!file) return;
// 验证文件大小
if (file.size > 2 * 1024 * 1024) {
showUploadStatus('error', '❌ 图片大小不能超过 2MB');
return;
}
// 验证文件类型
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
if (!allowedTypes.includes(file.type)) {
showUploadStatus('error', '❌ 不支持的图片格式,仅支持 JPG、PNG、WebP');
return;
}
const apiUrl = document.getElementById('apiUrl').value;
const token = document.getElementById('token').value;
if (!token) {
showUploadStatus('error', '❌ 请先填写 JWT Token');
return;
}
// 显示上传中状态
showUploadStatus('loading', '⏳ 正在上传图片...');
try {
const formData = new FormData();
formData.append('image', file);
const response = await fetch(`${apiUrl}/api/upload/image`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
},
body: formData
});
const result = await response.json();
if (result.success && result.data) {
uploadedImageUrl = result.data.imageUrl;
showUploadPreview(file, uploadedImageUrl);
showUploadStatus('success', `✅ 图片上传成功!文件名:${result.data.filename},大小:${(result.data.size / 1024).toFixed(2)} KB`);
} else {
showUploadStatus('error', `❌ 上传失败:${result.message || '未知错误'}`);
}
} catch (error) {
showUploadStatus('error', `❌ 上传失败:${error.message}`);
}
}
// 显示上传预览
function showUploadPreview(file, imageUrl) {
const placeholder = document.querySelector('.upload-placeholder');
const preview = document.getElementById('uploadPreview');
const previewImage = document.getElementById('previewImage');
const uploadUrl = document.getElementById('uploadUrl');
// 创建预览 URL
const previewUrl = URL.createObjectURL(file);
previewImage.src = previewUrl;
uploadUrl.textContent = imageUrl;
placeholder.style.display = 'none';
preview.style.display = 'flex';
}
// 移除上传的图片
function removeUploadedImage() {
const placeholder = document.querySelector('.upload-placeholder');
const preview = document.getElementById('uploadPreview');
const previewImage = document.getElementById('previewImage');
const uploadUrl = document.getElementById('uploadUrl');
const imageInput = document.getElementById('imageInput');
// 清理预览 URL
if (previewImage.src.startsWith('blob:')) {
URL.revokeObjectURL(previewImage.src);
}
placeholder.style.display = 'block';
preview.style.display = 'none';
uploadUrl.textContent = '';
imageInput.value = '';
uploadedImageUrl = null;
showUploadStatus('', '');
}
// 复制图片 URL
function copyImageUrl() {
if (!uploadedImageUrl) return;
navigator.clipboard.writeText(uploadedImageUrl).then(() => {
const btn = event.target;
const originalText = btn.textContent;
btn.textContent = '✅ 已复制';
btn.style.background = '#28a745';
setTimeout(() => {
btn.textContent = originalText;
btn.style.background = '#667eea';
}, 2000);
}).catch(err => {
alert('复制失败,请手动复制');
});
}
// 自动填充图片 URL 到 JSON
function autoFillImageUrl() {
if (!uploadedImageUrl) {
showUploadStatus('error', '❌ 请先上传图片');
return;
}
const textarea = document.getElementById('jsonData');
let jsonData;
try {
jsonData = JSON.parse(textarea.value);
} catch (e) {
showUploadStatus('error', '❌ JSON 格式错误,请先格式化 JSON');
return;
}
// 查找需要填充的 imageUrl 位置
let filledCount = 0;
const placeholders = ['placeholder', 'https://example.com/image.jpg', 'http://example.com/image.jpg', ''];
function fillImageUrl(obj) {
if (Array.isArray(obj)) {
obj.forEach(item => fillImageUrl(item));
} else if (obj && typeof obj === 'object') {
// 如果是 content 对象,检查 imageUrl
if (obj.content && typeof obj.content === 'object') {
const imageUrl = obj.content.imageUrl;
// 如果是占位符或空值,自动填充
if (!imageUrl || placeholders.includes(imageUrl)) {
obj.content.imageUrl = uploadedImageUrl;
filledCount++;
}
}
// 递归处理所有属性
Object.values(obj).forEach(value => {
if (value && typeof value === 'object') {
fillImageUrl(value);
}
});
}
}
fillImageUrl(jsonData);
if (filledCount > 0) {
textarea.value = JSON.stringify(jsonData, null, 2);
showUploadStatus('success', `✅ 已自动填充 ${filledCount} 个图片 URL`);
} else {
showUploadStatus('error', '❌ 未找到需要填充的 imageUrl 字段(已填写或不存在)');
}
}
// 显示上传状态
function showUploadStatus(type, message) {
const statusDiv = document.getElementById('uploadStatus');
statusDiv.className = `upload-status ${type}`;
statusDiv.textContent = message;
}
// 拖拽上传支持
const uploadArea = document.getElementById('uploadArea');
uploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
uploadArea.style.borderColor = '#667eea';
uploadArea.style.background = '#f0f7ff';
});
uploadArea.addEventListener('dragleave', (e) => {
e.preventDefault();
uploadArea.style.borderColor = '#ccc';
uploadArea.style.background = 'transparent';
});
uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
uploadArea.style.borderColor = '#ccc';
uploadArea.style.background = 'transparent';
const files = e.dataTransfer.files;
if (files.length > 0) {
const file = files[0];
const input = document.getElementById('imageInput');
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
input.files = dataTransfer.files;
handleImageUpload({ target: input });
}
});
// ========== 可视化图片匹配功能 ==========
let slidesData = []; // 存储解析出的幻灯片数据
// 解析JSON提取所有需要图片的幻灯片
function parseSlidesForImages() {
const textarea = document.getElementById('jsonData');
let jsonData;
try {
jsonData = JSON.parse(textarea.value);
} catch (e) {
showResult('error', '❌ JSON 格式错误,请先格式化 JSON' + e.message);
return;
}
slidesData = [];
// 遍历所有章节、节点、幻灯片
if (jsonData.chapters && Array.isArray(jsonData.chapters)) {
jsonData.chapters.forEach((chapter, chapterIndex) => {
if (chapter.nodes && Array.isArray(chapter.nodes)) {
chapter.nodes.forEach((node, nodeIndex) => {
if (node.slides && Array.isArray(node.slides)) {
node.slides.forEach((slide, slideIndex) => {
// 只处理 type 为 "image" 的幻灯片,或者 content 中有 imageUrl 字段的
if (slide.type === 'image' || (slide.content && slide.content.imageUrl !== undefined)) {
slidesData.push({
chapterIndex,
chapterTitle: chapter.title,
nodeIndex,
nodeId: node.id,
nodeTitle: node.title,
slideIndex,
slideOrder: slide.order,
slideType: slide.type,
currentImageUrl: slide.content?.imageUrl || null,
currentImagePosition: slide.content?.imagePosition || 'bottom', // 默认 bottom
// 保存文字内容,用于预览
title: slide.content?.title || null,
paragraphs: slide.content?.paragraphs || [],
// 保存路径用于更新JSON
path: {
chapterIndex,
nodeIndex,
slideIndex
}
});
}
});
}
});
}
});
}
if (slidesData.length === 0) {
showResult('error', '❌ 未找到需要图片的幻灯片type 为 "image" 或包含 imageUrl 字段)');
return;
}
// 渲染可视化面板
renderImageMatchPanel();
}
// 渲染可视化匹配面板
function renderImageMatchPanel() {
const panel = document.getElementById('imageMatchPanel');
const statsDiv = document.getElementById('panelStats');
const gridDiv = document.getElementById('slidesGrid');
// 统计信息
const total = slidesData.length;
const hasImage = slidesData.filter(s => s.currentImageUrl && s.currentImageUrl !== 'placeholder' && s.currentImageUrl !== '').length;
const needImage = total - hasImage;
statsDiv.innerHTML = `
<div class="stat-badge total">总计:${total} 张</div>
<div class="stat-badge has-image">已匹配:${hasImage} 张</div>
<div class="stat-badge need-image">待匹配:${needImage} 张</div>
`;
// 渲染幻灯片卡片
gridDiv.innerHTML = slidesData.map((slide, index) => {
const hasImage = slide.currentImageUrl && slide.currentImageUrl !== 'placeholder' && slide.currentImageUrl !== '';
const imagePreview = hasImage ?
`<img src="${getImageFullUrl(slide.currentImageUrl)}" class="slide-image-preview" onerror="this.style.display='none'">` :
'<div style="height: 150px; background: #f5f5f5; border-radius: 6px; display: flex; align-items: center; justify-content: center; color: #999;">暂无图片</div>';
// 构建文字内容预览
let textPreview = '';
if (slide.title || (slide.paragraphs && slide.paragraphs.length > 0)) {
textPreview = '<div class="slide-text-preview">';
if (slide.title) {
textPreview += `<div class="slide-text-title">${escapeHtml(slide.title)}</div>`;
}
if (slide.paragraphs && slide.paragraphs.length > 0) {
textPreview += '<div class="slide-text-paragraphs">';
slide.paragraphs.forEach(p => {
if (p && p.trim()) {
const truncated = p.length > 100 ? p.substring(0, 100) + '...' : p;
textPreview += `<div class="slide-text-paragraph">${escapeHtml(truncated)}</div>`;
}
});
textPreview += '</div>';
}
if (!slide.title && (!slide.paragraphs || slide.paragraphs.length === 0)) {
textPreview += '<div class="slide-text-empty">暂无文字内容</div>';
}
textPreview += '</div>';
} else {
textPreview = '<div class="slide-text-preview"><div class="slide-text-empty">暂无文字内容</div></div>';
}
return `
<div class="slide-card ${hasImage ? 'has-image' : ''}" data-index="${index}">
<div class="slide-card-header">
<div class="slide-info">
<div class="slide-node-title">${slide.nodeTitle}</div>
<div class="slide-order">幻灯片 #${slide.slideOrder} | ${slide.chapterTitle}</div>
</div>
</div>
${textPreview}
<div class="slide-image-section">
${imagePreview}
<div class="slide-image-url">${slide.currentImageUrl || '未设置'}</div>
<input type="file" class="slide-upload-input" id="slideUpload_${index}" accept="image/jpeg,image/jpg,image/png,image/webp" onchange="handleSlideImageUpload(${index}, event)">
<button class="slide-upload-btn ${hasImage ? 'has-image' : ''}" onclick="document.getElementById('slideUpload_${index}').click()">
${hasImage ? '🔄 更换图片' : '📷 上传图片'}
</button>
${hasImage ? `
<div class="slide-position-selector">
<label for="positionSelect_${index}" style="font-size: 12px; color: #666; margin-bottom: 4px; display: block;">排版位置:</label>
<select id="positionSelect_${index}" class="position-select" onchange="updateSlideImagePosition(${index}, this.value)">
<option value="top" ${slide.currentImagePosition === 'top' ? 'selected' : ''}>顶部(封面模式)</option>
<option value="middle" ${slide.currentImagePosition === 'middle' ? 'selected' : ''}>中间(插图模式)</option>
<option value="bottom" ${slide.currentImagePosition === 'bottom' ? 'selected' : ''}>底部(总结模式)</option>
</select>
</div>
` : ''}
</div>
</div>
`;
}).join('');
// 显示面板
panel.style.display = 'block';
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
// 获取完整图片URL
function getImageFullUrl(imageUrl) {
if (!imageUrl) return '';
if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {
return imageUrl;
}
const apiUrl = document.getElementById('apiUrl').value || 'https://api.muststudy.xin';
return `${apiUrl}/${imageUrl}`;
}
// 为特定幻灯片上传图片
async function handleSlideImageUpload(slideIndex, event) {
const file = event.target.files[0];
if (!file) return;
// 验证文件大小
if (file.size > 2 * 1024 * 1024) {
alert('❌ 图片大小不能超过 2MB');
return;
}
// 验证文件类型
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
if (!allowedTypes.includes(file.type)) {
alert('❌ 不支持的图片格式,仅支持 JPG、PNG、WebP');
return;
}
const apiUrl = document.getElementById('apiUrl').value;
const token = document.getElementById('token').value;
if (!token) {
alert('❌ 请先填写 JWT Token');
return;
}
const slide = slidesData[slideIndex];
const card = document.querySelector(`.slide-card[data-index="${slideIndex}"]`);
const btn = card.querySelector('.slide-upload-btn');
// 显示上传中状态
btn.textContent = '⏳ 上传中...';
btn.disabled = true;
try {
const formData = new FormData();
formData.append('image', file);
const response = await fetch(`${apiUrl}/api/upload/image`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
},
body: formData
});
const result = await response.json();
if (result.success && result.data) {
const imageUrl = result.data.imageUrl;
// 更新JSON中的imageUrl
updateSlideImageUrl(slideIndex, imageUrl);
// 更新UI
slide.currentImageUrl = imageUrl;
renderImageMatchPanel();
showResult('success', `✅ 图片上传成功!已匹配到:${slide.nodeTitle} - 幻灯片 #${slide.slideOrder}`);
} else {
alert(`❌ 上传失败:${result.message || '未知错误'}`);
btn.textContent = slide.currentImageUrl ? '🔄 更换图片' : '📷 上传图片';
btn.disabled = false;
}
} catch (error) {
alert(`❌ 上传失败:${error.message}`);
btn.textContent = slide.currentImageUrl ? '🔄 更换图片' : '📷 上传图片';
btn.disabled = false;
}
}
// 更新JSON中的imageUrl
function updateSlideImageUrl(slideIndex, imageUrl) {
const textarea = document.getElementById('jsonData');
let jsonData;
try {
jsonData = JSON.parse(textarea.value);
} catch (e) {
console.error('JSON解析失败:', e);
return;
}
const slide = slidesData[slideIndex];
const path = slide.path;
// 根据路径更新JSON
const targetSlide = jsonData.chapters[path.chapterIndex]
.nodes[path.nodeIndex]
.slides[path.slideIndex];
if (targetSlide.content) {
targetSlide.content.imageUrl = imageUrl;
// 如果已经有 imagePosition保持不变如果没有设置默认值 bottom
if (!targetSlide.content.imagePosition) {
targetSlide.content.imagePosition = slide.currentImagePosition || 'bottom';
}
} else {
targetSlide.content = {
imageUrl,
imagePosition: slide.currentImagePosition || 'bottom'
};
}
// 更新textarea
textarea.value = JSON.stringify(jsonData, null, 2);
}
// 更新JSON中的imagePosition
function updateSlideImagePosition(slideIndex, imagePosition) {
const textarea = document.getElementById('jsonData');
let jsonData;
try {
jsonData = JSON.parse(textarea.value);
} catch (e) {
console.error('JSON解析失败:', e);
return;
}
const slide = slidesData[slideIndex];
const path = slide.path;
// 根据路径更新JSON
const targetSlide = jsonData.chapters[path.chapterIndex]
.nodes[path.nodeIndex]
.slides[path.slideIndex];
if (targetSlide.content) {
targetSlide.content.imagePosition = imagePosition;
} else {
targetSlide.content = { imagePosition };
}
// 更新内存中的数据
slide.currentImagePosition = imagePosition;
// 更新textarea
textarea.value = JSON.stringify(jsonData, null, 2);
// 显示成功提示
showResult('success', `✅ 排版位置已更新:${getPositionLabel(imagePosition)}`);
}
// HTML 转义函数(防止 XSS
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// 获取排版位置的标签
function getPositionLabel(position) {
const labels = {
'top': '顶部(封面模式)',
'middle': '中间(插图模式)',
'bottom': '底部(总结模式)'
};
return labels[position] || position;
}
// 关闭可视化面板
function closeImageMatchPanel() {
const panel = document.getElementById('imageMatchPanel');
panel.style.display = 'none';
}
// ==================== 封面图管理功能 ====================
// 标签页切换
function switchTab(tabName) {
// 隐藏所有标签页内容
document.querySelectorAll('.tab-content').forEach(tab => {
tab.classList.remove('active');
});
// 移除所有标签按钮的 active 状态
document.querySelectorAll('.tab').forEach(btn => {
btn.classList.remove('active');
});
// 显示选中的标签页
document.getElementById(`tab-${tabName}`).classList.add('active');
event.target.classList.add('active');
}
// 快速获取测试 Token课程同步用
async function getTestToken() {
const apiUrl = document.getElementById('apiUrl').value;
const tokenInput = document.getElementById('token');
tokenInput.disabled = true;
tokenInput.value = '正在获取...';
try {
const response = await fetch(`${apiUrl}/api/auth/test-token`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
const result = await response.json();
if (result.success && result.data.token) {
tokenInput.value = result.data.token;
tokenInput.disabled = false;
showResult('success', '✅ Token 获取成功!已自动填写');
} else {
tokenInput.value = '';
tokenInput.disabled = false;
showResult('error', `❌ 获取失败:${result.error?.message || '未知错误'}`);
}
} catch (error) {
tokenInput.value = '';
tokenInput.disabled = false;
showResult('error', `❌ 获取失败:${error.message}`);
}
}
// 快速获取测试 Token封面图管理专用
async function getCoverTestToken() {
const apiUrl = document.getElementById('coverApiUrl').value || document.getElementById('apiUrl').value;
const tokenInput = document.getElementById('coverToken');
tokenInput.disabled = true;
tokenInput.value = '正在获取...';
try {
const response = await fetch(`${apiUrl}/api/auth/test-token`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
const result = await response.json();
if (result.success && result.data.token) {
tokenInput.value = result.data.token;
tokenInput.disabled = false;
showCoverResult('success', '✅ Token 获取成功!已自动填写');
} else {
tokenInput.value = '';
tokenInput.disabled = false;
showCoverResult('error', `❌ 获取失败:${result.error?.message || '未知错误'}`);
}
} catch (error) {
tokenInput.value = '';
tokenInput.disabled = false;
showCoverResult('error', `❌ 获取失败:${error.message}`);
}
}
// 加载课程列表
async function loadCoursesList() {
const apiUrl = document.getElementById('coverApiUrl').value || document.getElementById('apiUrl').value;
const token = document.getElementById('coverToken').value || document.getElementById('token').value;
if (!token) {
showCoverResult('error', '❌ 请先填写 JWT Token');
return;
}
showCoverResult('loading', '⏳ 正在加载课程列表...');
try {
const response = await fetch(`${apiUrl}/api/courses`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
const result = await response.json();
if (result.success && result.data.courses) {
displayCoursesList(result.data.courses);
showCoverResult('success', `✅ 成功加载 ${result.data.courses.length} 个课程`);
} else {
showCoverResult('error', `❌ 加载失败:${result.error?.message || '未知错误'}`);
}
} catch (error) {
showCoverResult('error', `❌ 加载失败:${error.message}`);
}
}
// 刷新课程列表
function refreshCoursesList() {
loadCoursesList();
}
// 判断是否是有效的图片 URL
function isValidImageUrl(url) {
if (!url || !url.trim()) return false;
// 检查是否是 HTTP/HTTPS URL
return url.startsWith('http://') || url.startsWith('https://');
}
// 显示课程列表
function displayCoursesList(courses) {
const container = document.getElementById('coursesList');
container.innerHTML = '';
courses.forEach(course => {
// 只有有效的 URL 才认为是"已有封面"
const hasCover = isValidImageUrl(course.cover_image);
const coverStatus = hasCover ? 'has-cover' : 'no-cover';
const coverStatusText = hasCover ? '✅ 已有封面' : '❌ 无封面';
// 如果 cover_image 存在但不是 URL可能是 SF Symbol显示提示
const hasInvalidCover = course.cover_image && course.cover_image.trim() !== '' && !isValidImageUrl(course.cover_image);
const card = document.createElement('div');
card.className = 'course-card';
card.innerHTML = `
<div class="course-header">
<div>
<div class="course-title">${course.title}</div>
<div class="course-id">ID: ${course.id}</div>
</div>
<span class="cover-status ${coverStatus}">${coverStatusText}</span>
</div>
<div class="course-cover-preview" id="preview-${course.id}">
${hasCover ? `<img src="${course.cover_image}" alt="${course.title}" onerror="this.parentElement.innerHTML='图片加载失败'">` : (hasInvalidCover ? `<div style="padding: 20px; text-align: center; color: #999;">当前是 SF Symbol:<br>${course.cover_image}<br><small>请上传真实图片</small></div>` : '暂无封面图')}
</div>
${hasCover ? `<div class="cover-url-display">${course.cover_image}</div>` : (hasInvalidCover ? `<div class="cover-url-display" style="background: #fff3cd; color: #856404;">⚠️ 当前值:${course.cover_image}SF Symbol不是图片 URL</div>` : '')}
<div class="cover-upload-area" onclick="document.getElementById('coverInput-${course.id}').click()">
<input type="file" id="coverInput-${course.id}" accept="image/jpeg,image/jpg,image/png,image/webp" onchange="handleCoverUpload('${course.id}', event)">
<div>📤 点击上传封面图</div>
<div class="cover-upload-text">推荐尺寸750x1000 (3:4)</div>
</div>
<div class="cover-actions">
<button class="btn-update-cover" id="updateBtn-${course.id}" onclick="updateCourseCover('${course.id}')" ${!hasCover && !hasInvalidCover ? 'disabled' : ''}>
${hasCover ? '更新封面图' : (hasInvalidCover ? '替换 SF Symbol' : '更新封面图')}
</button>
</div>
`;
container.appendChild(card);
});
}
// 处理封面图上传
async function handleCoverUpload(courseId, event) {
const file = event.target.files[0];
if (!file) return;
// 验证文件大小
if (file.size > 2 * 1024 * 1024) {
showCoverResult('error', '❌ 图片大小不能超过 2MB');
return;
}
// 验证文件类型
const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
if (!validTypes.includes(file.type)) {
showCoverResult('error', '❌ 不支持的图片格式,仅支持 JPG、PNG、WebP');
return;
}
const apiUrl = document.getElementById('apiUrl').value;
const token = document.getElementById('coverToken').value || document.getElementById('token').value;
if (!token) {
showCoverResult('error', '❌ 请先填写 JWT Token');
return;
}
showCoverResult('loading', `⏳ 正在上传封面图...`);
try {
const formData = new FormData();
formData.append('image', file);
const response = await fetch(`${apiUrl}/api/upload/image`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
},
body: formData,
});
const result = await response.json();
if (result.success && result.data.imageUrl) {
// 显示预览
const preview = document.getElementById(`preview-${courseId}`);
preview.innerHTML = `<img src="${result.data.imageUrl}" alt="预览">`;
// 显示 URL
const card = preview.closest('.course-card');
let urlDisplay = card.querySelector('.cover-url-display');
if (!urlDisplay) {
urlDisplay = document.createElement('div');
urlDisplay.className = 'cover-url-display';
preview.parentElement.insertBefore(urlDisplay, preview.nextSibling);
}
urlDisplay.textContent = result.data.imageUrl;
// 更新状态
const statusSpan = card.querySelector('.cover-status');
statusSpan.className = 'cover-status has-cover';
statusSpan.textContent = '✅ 已有封面';
// 启用更新按钮
const updateBtn = document.getElementById(`updateBtn-${courseId}`);
updateBtn.disabled = false;
updateBtn.dataset.imageUrl = result.data.imageUrl;
showCoverResult('success', `✅ 图片上传成功!正在自动更新到数据库...`);
// 自动更新到数据库
await updateCourseCoverToDatabase(courseId, result.data.imageUrl);
} else {
showCoverResult('error', `❌ 上传失败:${result.message || '未知错误'}`);
}
} catch (error) {
showCoverResult('error', `❌ 上传失败:${error.message}`);
}
}
// 更新课程封面图到数据库(内部函数)
async function updateCourseCoverToDatabase(courseId, imageUrl) {
const apiUrl = document.getElementById('apiUrl').value;
const token = document.getElementById('coverToken').value || document.getElementById('token').value;
const updateBtn = document.getElementById(`updateBtn-${courseId}`);
if (!imageUrl) {
showCoverResult('error', '❌ 图片 URL 为空');
return;
}
updateBtn.disabled = true;
updateBtn.textContent = '更新中...';
try {
console.log('🔄 更新封面图:', { courseId, imageUrl, apiUrl: `${apiUrl}/api/courses/${courseId}/cover` });
const response = await fetch(`${apiUrl}/api/courses/${courseId}/cover`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ coverImage: imageUrl }),
});
console.log('📥 API 响应状态:', response.status, response.statusText);
const result = await response.json();
console.log('📥 API 响应数据:', result);
if (result.success) {
showCoverResult('success', `✅ 封面图已成功更新到数据库!课程 ID: ${courseId}`);
updateBtn.textContent = '✅ 已更新';
updateBtn.disabled = false;
// 更新状态显示
const card = updateBtn.closest('.course-card');
const statusSpan = card.querySelector('.cover-status');
statusSpan.className = 'cover-status has-cover';
statusSpan.textContent = '✅ 已有封面';
} else {
const errorMsg = result.error?.message || result.message || '未知错误';
console.error('❌ 更新失败:', errorMsg, result);
showCoverResult('error', `❌ 更新失败:${errorMsg}`);
updateBtn.textContent = '更新封面图';
updateBtn.disabled = false;
}
} catch (error) {
console.error('❌ 更新异常:', error);
showCoverResult('error', `❌ 更新失败:${error.message}`);
updateBtn.textContent = '更新封面图';
updateBtn.disabled = false;
}
}
// 更新课程封面图到数据库
async function updateCourseCover(courseId) {
console.log('🖱️ 点击更新按钮,课程 ID:', courseId);
const apiUrl = document.getElementById('apiUrl').value;
const token = document.getElementById('coverToken').value || document.getElementById('token').value;
const updateBtn = document.getElementById(`updateBtn-${courseId}`);
if (!updateBtn) {
console.error('❌ 找不到更新按钮:', `updateBtn-${courseId}`);
showCoverResult('error', '❌ 找不到更新按钮');
return;
}
// 尝试多种方式获取图片 URL
let imageUrl = updateBtn.dataset.imageUrl;
if (!imageUrl) {
const previewImg = document.querySelector(`#preview-${courseId} img`);
if (previewImg) {
imageUrl = previewImg.src;
}
}
// 如果还是没有,尝试从 URL 显示区域获取
if (!imageUrl) {
const urlDisplay = document.querySelector(`#preview-${courseId}`).closest('.course-card')?.querySelector('.cover-url-display');
if (urlDisplay) {
const urlText = urlDisplay.textContent.trim();
// 过滤掉警告文本只提取实际的URL
if (urlText && !urlText.startsWith('⚠️') && !urlText.includes('SF Symbol')) {
// 检查是否是有效的HTTP/HTTPS URL
if (urlText.startsWith('http://') || urlText.startsWith('https://')) {
imageUrl = urlText;
}
}
}
}
console.log('🔍 获取到的图片 URL:', imageUrl);
if (!imageUrl) {
showCoverResult('error', '❌ 请先上传封面图');
return;
}
updateBtn.disabled = true;
updateBtn.textContent = '更新中...';
showCoverResult('loading', `⏳ 正在更新封面图...`);
try {
console.log('🔄 发送更新请求:', { courseId, imageUrl, apiUrl: `${apiUrl}/api/courses/${courseId}/cover` });
const response = await fetch(`${apiUrl}/api/courses/${courseId}/cover`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ coverImage: imageUrl }),
});
console.log('📥 API 响应状态:', response.status, response.statusText);
const result = await response.json();
console.log('📥 API 响应数据:', result);
if (result.success) {
showCoverResult('success', `✅ 封面图更新成功!课程 ID: ${courseId}`);
updateBtn.textContent = '✅ 已更新';
updateBtn.disabled = false;
// 更新状态显示
const card = updateBtn.closest('.course-card');
const statusSpan = card.querySelector('.cover-status');
if (statusSpan) {
statusSpan.className = 'cover-status has-cover';
statusSpan.textContent = '✅ 已有封面';
}
} else {
const errorMsg = result.error?.message || result.message || '未知错误';
console.error('❌ 更新失败:', errorMsg, result);
showCoverResult('error', `❌ 更新失败:${errorMsg}`);
updateBtn.textContent = '更新封面图';
updateBtn.disabled = false;
}
} catch (error) {
console.error('❌ 更新异常:', error);
showCoverResult('error', `❌ 更新失败:${error.message}`);
updateBtn.textContent = '更新封面图';
updateBtn.disabled = false;
}
}
// 显示封面图管理结果
function showCoverResult(type, message) {
const resultDiv = document.getElementById('coverResult');
resultDiv.className = `result ${type}`;
resultDiv.innerHTML = message || '';
}
</script>
</body>
</html>