2176 lines
80 KiB
HTML
2176 lines
80 KiB
HTML
<!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. 为每个课程上传封面图(750x1000,3: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>
|
||
|