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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-21 15:11:29 +08:00

520 lines
21 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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>