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

2176 lines
80 KiB
HTML
Raw Normal View History

2026-02-11 15:26:03 +08:00
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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>