新增内容管理页面:上传/删除/API文档一体化

- content-manager.html: 替代原有Vue内容管理页
- 支持章节列表、搜索、编辑、删除功能
- 集成上传表单和API接口文档
- 解决原页面"加载中..."问题(CORS已修复)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
卡若
2026-02-21 15:11:29 +08:00
parent 934f7c7988
commit 76d90a0397

519
content-manager.html Normal file
View File

@@ -0,0 +1,519 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>内容管理 - Soul创业派对</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:#0a0e17;color:#e0e6ed;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;min-height:100vh}
a{color:#2dd4a8;text-decoration:none}
a:hover{text-decoration:underline}
.header{background:#111827;border-bottom:1px solid #1e293b;padding:16px 24px;display:flex;justify-content:space-between;align-items:center}
.header h1{font-size:20px;font-weight:600}
.header .back{color:#94a3b8;font-size:14px}
.container{max-width:1200px;margin:0 auto;padding:24px}
.tabs{display:flex;gap:8px;margin-bottom:24px;flex-wrap:wrap}
.tab{padding:8px 20px;border-radius:8px;cursor:pointer;font-size:14px;border:1px solid #1e293b;background:#111827;color:#94a3b8;transition:all .2s}
.tab.active{background:#2dd4a8;color:#0a0e17;border-color:#2dd4a8;font-weight:600}
.tab:hover:not(.active){background:#1e293b}
.card{background:#111827;border:1px solid #1e293b;border-radius:12px;padding:20px;margin-bottom:16px}
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin-bottom:24px}
.stat{background:#111827;border:1px solid #1e293b;border-radius:10px;padding:16px;text-align:center}
.stat .num{font-size:28px;font-weight:700;color:#2dd4a8}
.stat .label{font-size:12px;color:#64748b;margin-top:4px}
.part-header{display:flex;justify-content:space-between;align-items:center;padding:12px 0;cursor:pointer;border-bottom:1px solid #1e293b}
.part-title{font-size:16px;font-weight:600;color:#2dd4a8}
.part-count{font-size:12px;color:#64748b;background:#1e293b;padding:2px 10px;border-radius:10px}
.chapter-group{padding:8px 0 8px 16px}
.chapter-title{font-size:14px;color:#94a3b8;margin:12px 0 8px;font-weight:500}
.section-item{display:flex;justify-content:space-between;align-items:center;padding:10px 12px;border-radius:8px;transition:background .15s}
.section-item:hover{background:#1e293b}
.section-left{display:flex;align-items:center;gap:10px;flex:1;min-width:0}
.section-id{font-size:12px;color:#64748b;min-width:40px}
.section-title{font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.section-right{display:flex;align-items:center;gap:8px;flex-shrink:0}
.badge{font-size:11px;padding:2px 8px;border-radius:4px;font-weight:500}
.badge-free{background:rgba(45,212,168,.15);color:#2dd4a8}
.badge-paid{background:rgba(234,179,8,.15);color:#eab308}
.btn{padding:5px 12px;border-radius:6px;font-size:12px;cursor:pointer;border:1px solid #1e293b;background:#1e293b;color:#e0e6ed;transition:all .15s}
.btn:hover{background:#334155}
.btn-danger{border-color:#7f1d1d;color:#ef4444}
.btn-danger:hover{background:#7f1d1d}
.btn-primary{background:#2dd4a8;color:#0a0e17;border-color:#2dd4a8;font-weight:600}
.btn-primary:hover{background:#22b896}
.form-group{margin-bottom:16px}
.form-group label{display:block;font-size:13px;color:#94a3b8;margin-bottom:6px;font-weight:500}
.form-group input,.form-group select,.form-group textarea{width:100%;padding:10px 12px;background:#0a0e17;border:1px solid #1e293b;border-radius:8px;color:#e0e6ed;font-size:14px;outline:none;transition:border .2s}
.form-group input:focus,.form-group select:focus,.form-group textarea:focus{border-color:#2dd4a8}
.form-group textarea{min-height:200px;font-family:monospace;resize:vertical}
.form-row{display:grid;grid-template-columns:1fr 1fr;gap:16px}
.api-doc{font-family:monospace;font-size:13px;line-height:1.7}
.api-doc pre{background:#0a0e17;border:1px solid #1e293b;border-radius:8px;padding:14px;overflow-x:auto;margin:8px 0 16px}
.api-doc code{color:#2dd4a8}
.api-doc h3{color:#e0e6ed;font-size:15px;margin:20px 0 8px;padding-top:12px;border-top:1px solid #1e293b}
.api-doc h3:first-child{border-top:none;margin-top:0}
.toast{position:fixed;top:20px;right:20px;padding:12px 20px;border-radius:8px;font-size:14px;z-index:9999;animation:slideIn .3s}
.toast-success{background:#065f46;color:#6ee7b7}
.toast-error{background:#7f1d1d;color:#fca5a5}
@keyframes slideIn{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}
.loading{text-align:center;padding:40px;color:#64748b}
.empty{text-align:center;padding:60px;color:#475569}
.search-bar{display:flex;gap:12px;margin-bottom:20px}
.search-bar input{flex:1}
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:100;display:flex;align-items:center;justify-content:center}
.modal{background:#111827;border:1px solid #1e293b;border-radius:16px;width:90%;max-width:700px;max-height:85vh;overflow-y:auto;padding:24px}
.modal h2{font-size:18px;margin-bottom:16px}
.modal-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:20px}
.hidden{display:none}
</style>
</head>
<body>
<div class="header">
<h1>内容管理 · Soul创业派对</h1>
<a class="back" href="/">← 返回管理后台</a>
</div>
<div class="container">
<div class="tabs">
<div class="tab active" data-tab="chapters" onclick="switchTab('chapters')">章节管理</div>
<div class="tab" data-tab="upload" onclick="switchTab('upload')">上传内容</div>
<div class="tab" data-tab="api" onclick="switchTab('api')">API 接口文档</div>
</div>
<!-- 章节管理 -->
<div id="tab-chapters">
<div class="stats" id="stats"></div>
<div class="search-bar">
<input type="text" id="searchInput" placeholder="搜索章节标题..." oninput="filterSections()">
<button class="btn btn-primary" onclick="loadChapters()">刷新</button>
</div>
<div id="chapterList"><div class="loading">加载中...</div></div>
</div>
<!-- 上传内容 -->
<div id="tab-upload" class="hidden">
<div class="card">
<h2 style="margin-bottom:16px">上传新章节</h2>
<div class="form-row">
<div class="form-group">
<label>章节ID (如 1.6,留空自动生成)</label>
<input type="text" id="up_id" placeholder="自动生成">
</div>
<div class="form-group">
<label>定价 (0=免费)</label>
<input type="number" id="up_price" value="1" step="0.1" min="0">
</div>
</div>
<div class="form-group">
<label>标题 *</label>
<input type="text" id="up_title" placeholder="章节标题">
</div>
<div class="form-row">
<div class="form-group">
<label>所属篇</label>
<select id="up_part">
<option value="part-1">第一篇|真实的人</option>
<option value="part-2">第二篇|真实的行业</option>
<option value="part-3">第三篇|真实的错误</option>
<option value="part-4">第四篇|真实的赚钱</option>
<option value="part-5">第五篇|真实的社会</option>
<option value="appendix">附录</option>
<option value="intro">序言</option>
<option value="outro">尾声</option>
</select>
</div>
<div class="form-group">
<label>所属章</label>
<select id="up_chapter">
<option value="chapter-1">第1章人与人之间的底层逻辑</option>
<option value="chapter-2">第2章人性困境案例</option>
<option value="chapter-3">第3章电商篇</option>
<option value="chapter-4">第4章内容商业篇</option>
<option value="chapter-5">第5章传统行业篇</option>
<option value="chapter-6">第6章我人生错过的4件大钱</option>
<option value="chapter-7">第7章别人犯的错误</option>
<option value="chapter-8">第8章底层结构</option>
<option value="chapter-9">第9章我在Soul上亲访的赚钱案例</option>
<option value="chapter-10">第10章未来职业的变化趋势</option>
<option value="chapter-11">第11章中国社会商业生态的未来</option>
<option value="appendix">附录</option>
<option value="preface">序言</option>
<option value="epilogue">尾声</option>
</select>
</div>
</div>
<div class="form-group">
<label>内容 (Markdown格式) *</label>
<textarea id="up_content" placeholder="# 标题&#10;&#10;正文内容...&#10;&#10;图片用 {{image_1}} 占位"></textarea>
</div>
<div class="form-group">
<label>图片URL (每行一个,替换 {{image_1}}, {{image_2}}...)</label>
<textarea id="up_images" style="min-height:80px" placeholder="https://example.com/img1.png&#10;https://example.com/img2.png"></textarea>
</div>
<button class="btn btn-primary" style="width:100%;padding:12px;font-size:15px" onclick="uploadContent()">上传章节</button>
</div>
</div>
<!-- API 接口文档 -->
<div id="tab-api" class="hidden">
<div class="card api-doc">
<h2 style="margin-bottom:16px;font-family:sans-serif">内容管理 API 接口文档</h2>
<p style="color:#94a3b8;margin-bottom:20px;font-family:sans-serif">基础域名:<code>https://soulapi.quwanzhi.com</code>(正式)/ <code>https://souldev.quwanzhi.com</code>(开发)</p>
<h3>1. 获取所有章节</h3>
<pre>GET /api/book/all-chapters
# 无需认证,返回全部章节
curl https://soulapi.quwanzhi.com/api/book/all-chapters</pre>
<p>响应:<code>{"success": true, "data": [{"id":"1.1", "sectionTitle":"...", "isFree":true, "price":0, ...}]}</code></p>
<h3>2. 获取单章内容</h3>
<pre>GET /api/book/chapter/:id
curl https://soulapi.quwanzhi.com/api/book/chapter/1.1</pre>
<p>响应:<code>{"success": true, "data": {"id":"1.1", "content":"# 正文...", ...}}</code></p>
<h3>3. 管理员登录获取Token</h3>
<pre>POST /api/admin
Content-Type: application/json
{"username": "admin", "password": "admin123"}
# 响应包含 token后续请求需带 Authorization: Bearer {token}</pre>
<h3>4. 章节列表(管理员)</h3>
<pre>GET /api/db/book?action=list
Authorization: Bearer {token}
# 返回所有章节的元数据(不含正文)</pre>
<h3>5. 读取章节内容(管理员)</h3>
<pre>GET /api/db/book?action=read&id={section_id}
Authorization: Bearer {token}</pre>
<h3>6. 创建/更新章节(管理员)</h3>
<pre>POST /api/db/book
Authorization: Bearer {token}
Content-Type: application/json
{
"id": "1.6", // 章节ID不传则自动生成
"title": "章节标题",
"content": "Markdown正文",
"price": 1.0, // 定价0=免费
"partId": "part-1", // 所属篇
"chapterId": "chapter-1" // 所属章
}</pre>
<h3>7. 上传内容(数据库直写)</h3>
<p style="color:#94a3b8;font-family:sans-serif">支持从 Cursor Skill / 命令行 直接写入数据库:</p>
<pre># 命令行方式
python3 content_upload.py \
--title "标题" \
--price 1.0 \
--content "正文内容" \
--part part-1 \
--chapter chapter-1 \
--format markdown
# JSON方式
python3 content_upload.py --json '{
"title": "标题",
"price": 1.0,
"content": "正文...",
"part_id": "part-1",
"chapter_id": "chapter-1",
"images": ["https://img.com/1.png"]
}'
# 查看篇章结构
python3 content_upload.py --list-structure
# 列出所有章节
python3 content_upload.py --list-chapters</pre>
<h3>8. 删除章节</h3>
<pre>DELETE /api/admin/content/:id
Authorization: Bearer {token}
curl -X DELETE https://soulapi.quwanzhi.com/api/admin/content/1.6 \
-H "Authorization: Bearer {token}"</pre>
<h3>9. 数据库连接信息</h3>
<pre># 如需直连数据库
Host: 56b4c23f6853c.gz.cdb.myqcloud.com
Port: 14413
User: cdb_outerroot
DB: soul_miniprogram
表: chapters (mid自增主键, id章节号唯一索引)</pre>
</div>
</div>
</div>
<!-- 编辑弹窗 -->
<div id="editModal" class="modal-overlay hidden">
<div class="modal">
<h2 id="editTitle">编辑章节</h2>
<div class="form-group">
<label>标题</label>
<input type="text" id="edit_title">
</div>
<div class="form-row">
<div class="form-group">
<label>定价</label>
<input type="number" id="edit_price" step="0.1" min="0">
</div>
<div class="form-group">
<label>免费</label>
<select id="edit_free"><option value="1"></option><option value="0"></option></select>
</div>
</div>
<div class="form-group">
<label>内容 (Markdown)</label>
<textarea id="edit_content" style="min-height:300px"></textarea>
</div>
<div class="modal-actions">
<button class="btn" onclick="closeModal()">取消</button>
<button class="btn btn-primary" onclick="saveEdit()">保存</button>
</div>
</div>
</div>
<script>
const API_PROD = 'https://soulapi.quwanzhi.com';
const API_DEV = 'https://souldev.quwanzhi.com';
const DB_API = 'https://souldev.quwanzhi.com';
let token = localStorage.getItem('admin_token') || '';
let allSections = [];
let editingId = null;
async function api(method, path, body, base) {
const url = (base || DB_API) + path;
const opts = {method, headers: {'Content-Type':'application/json'}};
if (token) opts.headers['Authorization'] = 'Bearer ' + token;
if (body) opts.body = JSON.stringify(body);
const r = await fetch(url, opts);
return r.json();
}
async function ensureAuth() {
if (token) {
const r = await api('GET', '/api/admin');
if (r.success) return true;
}
const r = await api('POST', '/api/admin', {username:'admin', password:'admin123'});
if (r.success && r.token) {
token = r.token;
localStorage.setItem('admin_token', token);
return true;
}
showToast('登录失败', 'error');
return false;
}
async function loadChapters() {
document.getElementById('chapterList').innerHTML = '<div class="loading">加载中...</div>';
if (!await ensureAuth()) return;
const r = await api('GET', '/api/db/book?action=list');
let items = r.sections || r.data || r.chapters || [];
allSections = items;
const parts = {};
items.forEach(s => {
const pk = s.partId || s.part_id || 'unknown';
const pt = s.partTitle || s.part_title || pk;
const ck = s.chapterId || s.chapter_id || 'unknown';
const ct = s.chapterTitle || s.chapter_title || ck;
if (!parts[pk]) parts[pk] = {title: pt, chapters: {}};
if (!parts[pk].chapters[ck]) parts[pk].chapters[ck] = {title: ct, sections: []};
parts[pk].chapters[ck].sections.push(s);
});
const partOrder = ['intro','part-1','part-2','part-3','part-4','part-5','outro','appendix'];
const sortedParts = Object.entries(parts).sort((a,b) => {
const ia = partOrder.indexOf(a[0]), ib = partOrder.indexOf(b[0]);
return (ia===-1?99:ia) - (ib===-1?99:ib);
});
const totalParts = sortedParts.length;
const freeCount = items.filter(s => s.isFree || s.is_free).length;
const paidCount = items.length - freeCount;
document.getElementById('stats').innerHTML = `
<div class="stat"><div class="num">${totalParts}</div><div class="label">篇</div></div>
<div class="stat"><div class="num">${items.length}</div><div class="label">节</div></div>
<div class="stat"><div class="num">${freeCount}</div><div class="label">免费</div></div>
<div class="stat"><div class="num">${paidCount}</div><div class="label">付费</div></div>
`;
let html = '';
let partIdx = 0;
sortedParts.forEach(([pk, pv]) => {
partIdx++;
const totalSec = Object.values(pv.chapters).reduce((s,c) => s + c.sections.length, 0);
html += `<div class="card">
<div class="part-header" onclick="this.nextElementSibling.classList.toggle('hidden')">
<span class="part-title">${String(partIdx).padStart(2,'0')} ${pv.title}</span>
<span class="part-count">${totalSec} 节</span>
</div>
<div class="chapter-group">`;
Object.entries(pv.chapters).forEach(([ck, cv]) => {
html += `<div class="chapter-title">${cv.title}</div>`;
cv.sections.forEach(s => {
const isFree = s.isFree || s.is_free;
const price = s.price || 0;
const title = s.sectionTitle || s.section_title || s.title || '';
html += `<div class="section-item" data-title="${title.toLowerCase()}" data-id="${s.id}">
<div class="section-left">
<span class="section-id">${s.id}</span>
<span class="section-title">${title}</span>
</div>
<div class="section-right">
<span class="badge ${isFree?'badge-free':'badge-paid'}">${isFree?'免费':'¥'+price}</span>
<button class="btn" onclick="editSection('${s.id}')">编辑</button>
<button class="btn btn-danger" onclick="deleteSection('${s.id}','${title.replace(/'/g,"\\'")}')">删除</button>
</div>
</div>`;
});
});
html += '</div></div>';
});
document.getElementById('chapterList').innerHTML = html || '<div class="empty">暂无内容</div>';
}
function filterSections() {
const q = document.getElementById('searchInput').value.toLowerCase();
document.querySelectorAll('.section-item').forEach(el => {
el.style.display = el.dataset.title.includes(q) ? '' : 'none';
});
}
async function editSection(id) {
if (!await ensureAuth()) return;
showToast('加载中...');
const r = await api('GET', `/api/db/book?action=read&id=${id}`);
const s = r.data || r.section || r;
editingId = id;
document.getElementById('editTitle').textContent = `编辑: ${id}`;
document.getElementById('edit_title').value = s.sectionTitle || s.section_title || s.title || '';
document.getElementById('edit_price').value = s.price || 0;
document.getElementById('edit_free').value = (s.isFree || s.is_free) ? '1' : '0';
document.getElementById('edit_content').value = s.content || '';
document.getElementById('editModal').classList.remove('hidden');
}
function closeModal() {
document.getElementById('editModal').classList.add('hidden');
editingId = null;
}
async function saveEdit() {
if (!editingId) return;
const data = {
id: editingId,
title: document.getElementById('edit_title').value,
content: document.getElementById('edit_content').value,
price: parseFloat(document.getElementById('edit_price').value) || 0,
isFree: document.getElementById('edit_free').value === '1'
};
const r = await api('POST', '/api/db/book', data);
if (r.success !== false) {
showToast('保存成功');
closeModal();
loadChapters();
} else {
showToast(r.error || '保存失败', 'error');
}
}
async function deleteSection(id, title) {
if (!confirm(`确定删除章节「${title}」(${id})?此操作不可恢复!`)) return;
if (!await ensureAuth()) return;
let r = await api('DELETE', `/api/admin/content/${id}`);
if (r.success === false && r.error) {
r = await api('POST', '/api/db/book', {action:'delete', id});
}
if (r.success !== false) {
showToast('已删除');
loadChapters();
} else {
const ok = confirm('API删除失败是否通过数据库直接删除');
if (ok) {
showToast('正在通过数据库删除...');
try {
const resp = await fetch(DB_API + `/api/db/book?action=delete&id=${id}`, {
method: 'DELETE',
headers: {'Authorization': 'Bearer ' + token}
});
const d = await resp.json();
if (d.success !== false) { showToast('已删除'); loadChapters(); }
else showToast('删除失败: ' + (d.error||''), 'error');
} catch(e) { showToast('删除失败', 'error'); }
}
}
}
async function uploadContent() {
const title = document.getElementById('up_title').value.trim();
const content = document.getElementById('up_content').value.trim();
if (!title) return showToast('请填写标题', 'error');
if (!content) return showToast('请填写内容', 'error');
const images = document.getElementById('up_images').value.trim().split('\n').filter(Boolean);
let processedContent = content;
images.forEach((url, i) => {
processedContent = processedContent.replace(`{{image_${i+1}}}`, `![图片${i+1}](${url.trim()})`);
});
const price = parseFloat(document.getElementById('up_price').value) || 0;
const data = {
id: document.getElementById('up_id').value.trim() || undefined,
title: title,
content: processedContent,
price: price,
isFree: price === 0,
partId: document.getElementById('up_part').value,
chapterId: document.getElementById('up_chapter').value
};
if (!await ensureAuth()) return;
showToast('上传中...');
const r = await api('POST', '/api/db/book', data);
if (r.success !== false) {
showToast('上传成功!');
document.getElementById('up_title').value = '';
document.getElementById('up_content').value = '';
document.getElementById('up_images').value = '';
document.getElementById('up_id').value = '';
switchTab('chapters');
loadChapters();
} else {
showToast('上传失败: ' + (r.error || ''), 'error');
}
}
function switchTab(name) {
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === name));
['chapters','upload','api'].forEach(t => {
document.getElementById('tab-' + t).classList.toggle('hidden', t !== name);
});
}
function showToast(msg, type='success') {
const t = document.createElement('div');
t.className = `toast toast-${type}`;
t.textContent = msg;
document.body.appendChild(t);
setTimeout(() => t.remove(), 3000);
}
loadChapters();
</script>
</body>
</html>