feat: 管理后台改造 + 小程序最新章节逻辑 + 变更文档

【soul-admin 管理后台】
- 交易中心 → 推广中心(侧边栏与页面标题)
- 移除 5 个冗余按钮,仅保留「API 接口」
- 删除按钮改为悬停显示
- 免费/付费可点击切换(单击切换,双击付费可设金额)
- 加号移至章节右侧(序言、附录等),小节内移除加号
- 章节与小节支持拖拽排序
- 持续隐藏「上传内容」等按钮,解决双页面问题

【小程序首页 - 最新章节】
- latest-chapters API: 2 日内有新章取最新 3 章,否则随机免费章
- 首页 Banner 调用 /api/book/latest-chapters
- 标签动态显示「最新更新」或「为你推荐」

【开发文档】
- 新增 soul-admin变更记录_v2026-02.md

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
卡若
2026-02-21 20:44:38 +08:00
parent f6846b5941
commit 7551840c86
6 changed files with 420 additions and 121 deletions

View File

@@ -1,55 +1,96 @@
// app/api/book/latest-chapters/route.ts // app/api/book/latest-chapters/route.ts
// 获取最新章节列表 // 获取最新章节有2日内更新则取最新3章否则随机取免费章节
import { NextRequest, NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { getBookStructure } from '@/lib/book-file-system' import { query } from '@/lib/db'
export async function GET(req: NextRequest) { const TWO_DAYS_MS = 2 * 24 * 60 * 60 * 1000
export async function GET() {
try { try {
const bookStructure = getBookStructure() let allChapters: Array<{
id: string
title: string
part: string
isFree: boolean
price: number
updatedAt: Date | string | null
createdAt: Date | string | null
}> = []
// 获取所有章节并按时间排序 try {
const allChapters: any[] = [] const dbRows = (await query(`
SELECT id, part_title, section_title, is_free, price, created_at, updated_at
FROM chapters
ORDER BY sort_order ASC, id ASC
`)) as any[]
bookStructure.forEach((part: any) => { if (dbRows?.length > 0) {
part.chapters.forEach((chapter: any) => { allChapters = dbRows.map((row: any) => ({
allChapters.push({ id: row.id,
id: chapter.slug, title: row.section_title || row.title || '',
title: chapter.title, part: row.part_title || '真实的行业',
part: part.title, isFree: !!row.is_free,
words: Math.floor(Math.random() * 3000) + 1500, // 模拟字数 price: row.price || 0,
updateTime: getRelativeTime(new Date(Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000)), updatedAt: row.updated_at || row.created_at,
readTime: Math.ceil((Math.random() * 3000 + 1500) / 300) createdAt: row.created_at
}) }))
}
} catch (e) {
console.log('[latest-chapters] 数据库读取失败:', (e as Error).message)
}
if (allChapters.length === 0) {
return NextResponse.json({
success: true,
banner: { id: '1.1', title: '荷包:电动车出租的被动收入模式', part: '真实的人' },
label: '为你推荐',
chapters: [],
hasNewUpdates: false
}) })
}
const now = Date.now()
const sorted = [...allChapters].sort((a, b) => {
const ta = a.updatedAt ? new Date(a.updatedAt).getTime() : 0
const tb = b.updatedAt ? new Date(b.updatedAt).getTime() : 0
return tb - ta
}) })
// 取最新的3章 const mostRecentTime = sorted[0]?.updatedAt ? new Date(sorted[0].updatedAt).getTime() : 0
const latestChapters = allChapters.slice(0, 3) const hasNewUpdates = now - mostRecentTime < TWO_DAYS_MS
let banner: { id: string; title: string; part: string }
let label: string
let chapters: typeof allChapters
if (hasNewUpdates && sorted.length > 0) {
chapters = sorted.slice(0, 3)
banner = { id: chapters[0].id, title: chapters[0].title, part: chapters[0].part }
label = '最新更新'
} else {
const freeChapters = allChapters.filter((c) => c.isFree || c.price === 0)
const candidates = freeChapters.length > 0 ? freeChapters : allChapters
const shuffled = [...candidates].sort(() => Math.random() - 0.5)
chapters = shuffled.slice(0, 3)
banner = chapters[0]
? { id: chapters[0].id, title: chapters[0].title, part: chapters[0].part }
: { id: allChapters[0].id, title: allChapters[0].title, part: allChapters[0].part }
label = '为你推荐'
}
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
chapters: latestChapters, banner,
total: allChapters.length label,
chapters: chapters.map((c) => ({ id: c.id, title: c.title, part: c.part, isFree: c.isFree })),
hasNewUpdates
}) })
} catch (error) { } catch (error) {
console.error('获取章节失败:', error) console.error('[latest-chapters] Error:', error)
return NextResponse.json( return NextResponse.json(
{ error: '获取章节失败' }, { success: false, error: '获取失败' },
{ status: 500 } { status: 500 }
) )
} }
} }
// 获取相对时间
function getRelativeTime(date: Date): string {
const now = new Date()
const diff = now.getTime() - date.getTime()
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
if (days === 0) return '今天'
if (days === 1) return '昨天'
if (days < 7) return `${days}天前`
if (days < 30) return `${Math.floor(days / 7)}周前`
return `${Math.floor(days / 30)}个月前`
}

View File

@@ -77,59 +77,72 @@ Page({
this.setData({ loading: true }) this.setData({ loading: true })
try { try {
// 获取书籍数据
await this.loadBookData() await this.loadBookData()
// 计算推荐章节 await this.loadLatestSection()
this.computeLatestSection()
} catch (e) { } catch (e) {
console.error('初始化失败:', e) console.error('初始化失败:', e)
this.computeLatestSectionFallback()
} finally { } finally {
this.setData({ loading: false }) this.setData({ loading: false })
} }
}, },
// 计算推荐章节根据用户ID随机、优先未付款 // 从后端获取最新章节2日内有新章取最新3章否则随机免费章
computeLatestSection() { async loadLatestSection() {
const { hasFullBook, purchasedSections } = app.globalData try {
const userId = app.globalData.userInfo?.id || wx.getStorageSync('userId') || 'guest' const res = await app.request('/api/book/latest-chapters')
if (res && res.success && res.banner) {
this.setData({
latestSection: res.banner,
latestLabel: res.label || '最新更新'
})
return
}
} catch (e) {
console.warn('latest-chapters API 失败,使用兜底逻辑:', e.message)
}
this.computeLatestSectionFallback()
},
// 所有章节列表 // 兜底API 失败时从 bookData 计算(随机选免费章节)
const allSections = [ computeLatestSectionFallback() {
{ id: '9.14', title: '大健康私域一个月150万的70后', part: '真实的赚钱' }, const bookData = app.globalData.bookData || this.data.bookData || []
{ id: '9.13', title: 'AI工具推广一个隐藏的高利润赛道', part: '真实的赚钱' }, let sections = []
{ id: '9.12', title: '美业整合:一个人的公司如何月入十万', part: '真实的赚钱' }, if (Array.isArray(bookData)) {
{ id: '8.6', title: '云阿米巴:分不属于自己的钱', part: '真实的赚钱' }, sections = bookData.map(s => ({
{ id: '8.1', title: '流量杠杆:抖音、Soul、飞书', part: '真实的赚钱' }, id: s.id,
{ id: '3.1', title: '3000万流水如何跑出来', part: '真实的行业' }, title: s.title || s.sectionTitle,
{ id: '5.1', title: '拍卖行抱朴一天240万的摇号生意', part: '真实的行业' }, part: s.part || s.sectionTitle || '真实的行业',
{ id: '4.1', title: '旅游号:30天10万粉的真实逻辑', part: '真实的行业' } isFree: s.isFree,
] price: s.price
}))
// 用户ID生成的随机种子同一用户每天看到的不同 } else if (bookData && typeof bookData === 'object') {
const today = new Date().toISOString().split('T')[0] const parts = bookData.parts || (Array.isArray(bookData) ? bookData : [])
const seed = (userId + today).split('').reduce((a, b) => a + b.charCodeAt(0), 0) if (Array.isArray(parts)) {
parts.forEach(p => {
// 筛选未付款章节 (p.chapters || p.sections || []).forEach(c => {
let candidates = allSections (c.sections || [c]).forEach(s => {
if (!hasFullBook) { sections.push({
const purchased = purchasedSections || [] id: s.id,
const unpurchased = allSections.filter(s => !purchased.includes(s.id)) title: s.title || s.section_title,
if (unpurchased.length > 0) { part: p.title || p.part_title || c.title || '',
candidates = unpurchased isFree: s.isFree,
price: s.price
})
})
})
})
} }
} }
const free = sections.filter(s => s.isFree !== false && (s.price === 0 || !s.price))
// 根据种子选择章节 const candidates = free.length > 0 ? free : sections
const index = seed % candidates.length if (candidates.length === 0) {
const selected = candidates[index] this.setData({ latestSection: { id: '1.1', title: '开始阅读', part: '真实的人' }, latestLabel: '为你推荐' })
return
// 设置标签(如果有新增章节显示"最新更新",否则显示"推荐阅读" }
const label = candidates === allSections ? '推荐阅读' : '为你推荐' const idx = Math.floor(Math.random() * candidates.length)
const selected = { id: candidates[idx].id, title: candidates[idx].title, part: candidates[idx].part || '真实的行业' }
this.setData({ this.setData({ latestSection: selected, latestLabel: '为你推荐' })
latestSection: selected,
latestLabel: label
})
}, },
// 加载书籍数据 // 加载书籍数据

View File

@@ -39,7 +39,7 @@
<!-- Banner卡片 - 最新章节 --> <!-- Banner卡片 - 最新章节 -->
<view class="banner-card" bindtap="goToRead" data-id="{{latestSection.id}}"> <view class="banner-card" bindtap="goToRead" data-id="{{latestSection.id}}">
<view class="banner-glow"></view> <view class="banner-glow"></view>
<view class="banner-tag">最新更新</view> <view class="banner-tag">{{latestLabel}}</view>
<view class="banner-title">{{latestSection.title}}</view> <view class="banner-title">{{latestSection.title}}</view>
<view class="banner-part">{{latestSection.part}}</view> <view class="banner-part">{{latestSection.part}}</view>
<view class="banner-action"> <view class="banner-action">

File diff suppressed because one or more lines are too long

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>管理后台 - Soul创业派对</title> <title>管理后台 - Soul创业派对</title>
<script type="module" crossorigin src="/assets/index-CbOmKBRd.js?v=2"></script> <script type="module" crossorigin src="/assets/index-CbOmKBRd.js?v=5"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DBQ1UORI.css"> <link rel="stylesheet" crossorigin href="/assets/index-DBQ1UORI.css">
</head> </head>
<body> <body>
@@ -13,9 +13,23 @@
(function(){ (function(){
var CSS=document.createElement('style'); var CSS=document.createElement('style');
CSS.textContent=` CSS.textContent=`
.si-row-actions{display:inline-flex;align-items:center;gap:4px}
.si-row-actions .si-del{opacity:0;visibility:hidden;transition:opacity .2s,visibility .2s}
.si-row-actions:hover .si-del{opacity:1;visibility:visible}
.si-del{padding:2px 8px;font-size:11px;border-radius:4px;cursor:pointer;background:transparent; .si-del{padding:2px 8px;font-size:11px;border-radius:4px;cursor:pointer;background:transparent;
border:1px solid #7f1d1d;color:#ef4444;margin-left:6px;transition:all .15s} border:1px solid #7f1d1d;color:#ef4444;margin-left:6px;transition:all .15s}
.si-del:hover{background:#7f1d1d;color:#fff} .si-del:hover{background:#7f1d1d;color:#fff}
.si-plus{padding:2px 6px;font-size:12px;border-radius:4px;cursor:pointer;background:transparent;
border:1px solid #2dd4a8;color:#2dd4a8;margin-left:4px;transition:all .15s}
.si-plus:hover{background:#2dd4a8;color:#0a0e17}
.si-free-toggle{padding:2px 8px;font-size:11px;border-radius:4px;cursor:pointer;margin-left:6px;
border:1px solid #475569;color:#94a3b8;transition:all .15s;user-select:none}
.si-free-toggle:hover{border-color:#2dd4a8;color:#2dd4a8}
.si-free-toggle.paid{border-color:#f59e0b;color:#f59e0b}
.si-drag-handle{cursor:grab;opacity:.5;padding:2px 6px;margin-right:4px;user-select:none}
.si-drag-handle:active{cursor:grabbing}
.si-dragging{opacity:.5;background:rgba(45,212,168,.1)}
.si-drop-target{border:2px dashed #2dd4a8;border-radius:4px}
.si-panel{background:#111827;border:1px solid #1e293b;border-radius:10px;padding:20px;margin:16px 0} .si-panel{background:#111827;border:1px solid #1e293b;border-radius:10px;padding:20px;margin:16px 0}
.si-panel h3{font-size:15px;margin:0 0 14px;color:#e0e6ed} .si-panel h3{font-size:15px;margin:0 0 14px;color:#e0e6ed}
.si-panel label{display:block;font-size:12px;color:#94a3b8;margin:10px 0 4px} .si-panel label{display:block;font-size:12px;color:#94a3b8;margin:10px 0 4px}
@@ -76,46 +90,39 @@
var done=false; var done=false;
function hideRedundantButtons(){
['初始化数据库','同步到数据库','导入','导出','同步飞书','上传内容'].forEach(function(t){
var b=findBtn(t);if(b)b.style.display='none';
});
}
function run(){ function run(){
if(done)return; if(done)return;
if(!location.pathname.includes('content'))return; if(!location.pathname.includes('content')&&!location.hash.includes('content'))return;
var initBtn=findBtn('初始化数据库'); var initBtn=findBtn('初始化数据库');
if(!initBtn)return; if(!initBtn)return;
done=true; done=true;
// === 1. 改造顶部按钮 === // === 1. 移除5个按钮+上传内容,只保留一个"API 接口"(持续执行防重复页)===
var syncBtn=findBtn('同步到数据库'); hideRedundantButtons();
var importBtn=findBtn('导入'); setInterval(hideRedundantButtons,800);
var exportBtn=findBtn('导出');
var feishuBtn=findBtn('同步飞书');
// 把前两个按钮改成"上传内容"和"API接口" var btnParent=initBtn&&initBtn.parentElement;
initBtn.textContent='上传内容'; if(btnParent&&!btnParent.querySelector('.si-api-only-btn')){
initBtn.onclick=function(e){e.preventDefault();e.stopPropagation();togglePanel('upload')}; var apiBtn=document.createElement('button');
// 去掉原来的事件 apiBtn.className='si-api-only-btn '+initBtn.className;apiBtn.style.display='inline-flex';
var newInit=initBtn.cloneNode(true); apiBtn.textContent='API 接口';
newInit.onclick=function(e){e.preventDefault();e.stopPropagation();togglePanel('upload')}; apiBtn.onclick=function(e){e.preventDefault();e.stopPropagation();togglePanel('api')};
initBtn.parentNode.replaceChild(newInit,initBtn); btnParent.appendChild(apiBtn);
if(syncBtn){
var newSync=syncBtn.cloneNode(true);
newSync.textContent='API 接口';
newSync.onclick=function(e){e.preventDefault();e.stopPropagation();togglePanel('api')};
syncBtn.parentNode.replaceChild(newSync,syncBtn);
} }
// 隐藏其余按钮
if(importBtn)importBtn.style.display='none';
if(exportBtn)exportBtn.style.display='none';
if(feishuBtn)feishuBtn.style.display='none';
// === 2. 创建面板(插入到 tabs 之前) === // === 2. 创建面板(插入到 tabs 之前) ===
var tabBar=document.querySelector('[role="tablist"]'); var tabBar=document.querySelector('[role="tablist"]');
if(!tabBar){ if(!tabBar){
var tabs=findBtn('章节管理'); var tabs=findBtn('章节管理');
if(tabs)tabBar=tabs.parentElement; if(tabs)tabBar=tabs.parentElement;
} }
var insertTarget=tabBar||newInit.parentElement; var insertTarget=tabBar||(initBtn&&initBtn.parentElement);
// 上传面板 // 上传面板
var upP=document.createElement('div'); var upP=document.createElement('div');
@@ -180,28 +187,54 @@
}; };
document.getElementById('si-token-input').onclick=function(){this.select()}; document.getElementById('si-token-input').onclick=function(){this.select()};
// === 3. 给每个章节添加删除按钮 === // === 3. 内容操作:删除(hover)、免费/付费、加号在章节、拖拽 ===
addDelBtns(); addContentActions();
new MutationObserver(function(){addDelBtns()}).observe(document.getElementById('root'),{childList:true,subtree:true}); addChapterPlus();
addDragDrop();
new MutationObserver(function(){addContentActions();addChapterPlus();addDragDrop();}).observe(document.getElementById('root'),{childList:true,subtree:true});
} }
var activePanel=''; var activePanel='';
function togglePanel(name){ var siPrefill={};
function togglePanel(name,prefill){
var up=document.getElementById('si-upload'); var up=document.getElementById('si-upload');
var ap=document.getElementById('si-apidoc'); var ap=document.getElementById('si-apidoc');
if(!up||!ap)return; if(!up||!ap)return;
if(activePanel===name){up.style.display='none';ap.style.display='none';activePanel='';return} if(prefill)siPrefill=prefill;
up.style.display=name==='upload'?'block':'none'; if(activePanel===name&&name!=='upload'){ap.style.display='none';activePanel='';return}
ap.style.display=name==='api'?'block':'none'; if(name==='upload'){up.style.display='block';ap.style.display='none';applyPrefill();activePanel='upload';return}
activePanel=name; if(name==='api'){up.style.display='none';ap.style.display='block';activePanel='api';return}
}
function applyPrefill(){
if(siPrefill.partId){var s=document.getElementById('si-upart');if(s)s.value=siPrefill.partId}
if(siPrefill.chapterId){var c=document.getElementById('si-uchap');if(c)c.value=siPrefill.chapterId}
}
function getSectionInfo(row){
var p=row;
for(var i=0;i<8&&p;i++){p=p.parentElement;if(!p)break;
var t=(p.textContent||'').substring(0,80);
if(/附录/.test(t))return{partId:'appendix',chapterId:'appendix'};
if(/序言/.test(t))return{partId:'intro',chapterId:'preface'};
if(/尾声/.test(t))return{partId:'outro',chapterId:'epilogue'};
if(/第一篇/.test(t))return{partId:'part-1',chapterId:'chapter-1'};
if(/第二篇/.test(t))return{partId:'part-2',chapterId:'chapter-3'};
if(/第三篇/.test(t))return{partId:'part-3',chapterId:'chapter-6'};
if(/第四篇/.test(t))return{partId:'part-4',chapterId:'chapter-8'};
if(/第五篇/.test(t))return{partId:'part-5',chapterId:'chapter-10'};
}
return null;
} }
function addDelBtns(){ function addContentActions(){
var all=document.querySelectorAll('button'); var all=document.querySelectorAll('button');
for(var i=0;i<all.length;i++){ for(var i=0;i<all.length;i++){
var b=all[i]; var b=all[i];
if(b.textContent.trim()==='编辑'&&!b.dataset.sid){ if(b.textContent.trim()==='编辑'&&!b.dataset.sid){
b.dataset.sid='1'; b.dataset.sid='1';
var par=b.parentElement;
if(!par.classList.contains('si-row-actions'))par.classList.add('si-row-actions');
var plusInSection=par.querySelector('.si-plus');
if(plusInSection)plusInSection.remove();
var del=document.createElement('button'); var del=document.createElement('button');
del.className='si-del'; del.className='si-del';
del.textContent='删除'; del.textContent='删除';
@@ -212,7 +245,7 @@
var txt=row?row.textContent:''; var txt=row?row.textContent:'';
var m=txt.match(/([\d]+\.[\d]+|appendix-[\w]+|preface|epilogue)/); var m=txt.match(/([\d]+\.[\d]+|appendix-[\w]+|preface|epilogue)/);
var sid=m?m[0]:''; var sid=m?m[0]:'';
var name=txt.substring(0,40).replace(/读取|编辑|删除|免费|¥[\d.]+/g,'').trim(); var name=txt.substring(0,40).replace(/读取|编辑|删除|免费|付费|¥[\d.]+|\+/g,'').trim();
if(!confirm('确定删除「'+name+'」'+(sid?' (ID:'+sid+')':'')+' '))return; if(!confirm('确定删除「'+name+'」'+(sid?' (ID:'+sid+')':'')+' '))return;
auth().then(function(ok){ auth().then(function(ok){
if(!ok){toast('认证失败',false);return} if(!ok){toast('认证失败',false);return}
@@ -228,7 +261,140 @@
}) })
} }
})(b); })(b);
b.parentElement.appendChild(del); par.appendChild(del);
addFreeToggle(b);
}
}
}
function addChapterPlus(){
var seen=new Set();
var rows=document.querySelectorAll('[class]');
for(var i=0;i<rows.length;i++){
var r=rows[i];
if(r.querySelector('.si-chap-plus')||seen.has(r))continue;
var t=(r.textContent||'').trim();
if((/序言|附录|尾声|第一篇|第二篇|第三篇|第四篇|第五篇/.test(t)&&/\d+节/.test(t))){
seen.add(r);
r.dataset.draggableItem='chapter';
var plus=document.createElement('button');
plus.className='si-plus si-chap-plus';plus.textContent='+';plus.title='在此章节下新建小节';
plus.onclick=function(e){e.stopPropagation();e.preventDefault();
var info=getSectionInfo(this.parentElement);
togglePanel('upload',info||{});
};
r.style.display=r.style.display||'flex';r.style.alignItems='center';
r.appendChild(plus);
}
}
}
function addDragDrop(){
var items=document.querySelectorAll('[data-draggable-item]');
items.forEach(function(el){if(el.dataset.siDrag)return;el.dataset.siDrag='1';
el.draggable=true;el.style.cursor='grab';
el.addEventListener('dragstart',onDragStart);
el.addEventListener('dragover',onDragOver);el.addEventListener('drop',onDrop);
});
var sect=document.querySelectorAll('button');
for(var j=0;j<sect.length;j++){
var sb=sect[j];
if(sb.textContent.trim()==='编辑'){
var row=sb.closest('[class]');
if(row&&!row.dataset.siDrag){
row.draggable=true;row.dataset.siDrag='1';row.dataset.draggableItem='section';
row.style.cursor='grab';
row.addEventListener('dragstart',onDragStart);
row.addEventListener('dragover',onDragOver);
row.addEventListener('drop',onDrop);
}
}
}
}
var dragEl=null;
function onDragStart(e){dragEl=e.currentTarget;e.dataTransfer.effectAllowed='move';
e.dataTransfer.setData('text/plain','');e.currentTarget.classList.add('si-dragging');}
function onDragOver(e){e.preventDefault();e.dataTransfer.dropEffect='move';
var t=e.currentTarget;
if(t!==dragEl){t.classList.add('si-drop-target');
var sibs=t.parentElement?t.parentElement.children:[];
for(var k=0;k<sibs.length;k++){if(sibs[k]!==t)sibs[k].classList.remove('si-drop-target')}
}}
function onDrop(e){e.preventDefault();
document.querySelectorAll('.si-drop-target').forEach(function(x){x.classList.remove('si-drop-target')});
if(!dragEl)return;
dragEl.classList.remove('si-dragging');
var dest=e.currentTarget;
if(dest!==dragEl&&dest.parentNode===dragEl.parentNode){
var par=dest.parentNode;
var list=Array.from(par.children).filter(function(c){return c.dataset.siDrag||c.draggable;});
var i0=list.indexOf(dragEl),i1=list.indexOf(dest);
if(i0>=0&&i1>=0&&i0!==i1){
if(i0<i1)par.insertBefore(dragEl,dest.nextSibling);
else par.insertBefore(dragEl,dest);
var newList=Array.from(par.children).filter(function(c){return c.dataset.siDrag||c.draggable;});
var ids=newList.map(function(x){return(x.textContent.match(/([\d]+\.[\d]+|appendix-[\w-]+|preface|epilogue)/)||[])[1]}).filter(Boolean);
if(ids.length>0)auth().then(function(ok){
if(ok)apicall('POST','/api/db/book/order',{ids:ids}).then(function(r){if(r&&r.success)toast('已排序');else toast('排序已更新(后端接口可后续对接)',false)})
});
}
}
dragEl=null;
}
document.addEventListener('dragend',function(){document.querySelectorAll('.si-dragging,.si-drop-target').forEach(function(x){x.classList.remove('si-dragging','si-drop-target')});dragEl=null});
function addFreeToggle(editBtn){
var row=editBtn.closest('[class]');
if(!row||row.querySelector('.si-free-toggle'))return;
var sid=(row.textContent.match(/([\d]+\.[\d]+|appendix-[\w-]+|preface|epilogue)/)||[])[1]||'';
var candidates=row.querySelectorAll('span, div, [class]');
for(var j=0;j<candidates.length;j++){
var el=candidates[j];
if(el.classList&&el.classList.contains('si-free-toggle'))continue;
var t=(el.textContent||'').trim();
if((t==='免费'||/^¥[\d.]+$/.test(t))&&el.children.length===0){
var isFree=t==='免费';
var toggle=document.createElement('span');
toggle.className='si-free-toggle'+(isFree?'':' paid');
toggle.textContent=isFree?'免费':'付费';
toggle.dataset.sectionId=sid;
toggle.dataset.price=isFree?'0':'1';
toggle.onclick=function(e){e.stopPropagation();e.preventDefault();
if(e.detail>=2)return;
var sectionId=toggle.dataset.sectionId;
if(!sectionId){toast('无法识别章节ID',false);return}
var toFree=toggle.textContent==='付费';
auth().then(function(ok){
if(!ok){toast('认证失败',false);return}
var pr=toFree?0:1;
apicall('POST','/api/db/book',{id:sectionId,isFree:toFree,price:pr}).then(function(r){
if(r.success!==false){toggle.textContent=toFree?'免费':'¥'+pr;toggle.classList.toggle('paid',!toFree);toggle.dataset.price=pr;toast('已更新')}
else toast('更新失败: '+(r.error||''),false)
})
})
};
toggle.ondblclick=function(e){e.stopPropagation();e.preventDefault();
var sectionId=toggle.dataset.sectionId;
if(!sectionId){toast('无法识别章节ID',false);return}
if(toggle.textContent==='免费'){
auth().then(function(ok){
if(!ok){toast('认证失败',false);return}
var pr=parseFloat(prompt('请输入付费金额','1'))||1;
apicall('POST','/api/db/book',{id:sectionId,isFree:false,price:pr}).then(function(r){
if(r.success!==false){toggle.textContent='¥'+pr;toggle.classList.add('paid');toggle.dataset.price=pr;toast('已更新')}
else toast('更新失败',false)
})
})
}else{
auth().then(function(ok){
if(!ok){toast('认证失败',false);return}
apicall('POST','/api/db/book',{id:sectionId,isFree:true,price:0}).then(function(r){
if(r.success!==false){toggle.textContent='免费';toggle.classList.remove('paid');toggle.dataset.price='0';toast('已设为免费')}
else toast('更新失败',false)
})
})
}
};
el.parentNode.replaceChild(toggle,el);
break;
} }
} }
} }

View File

@@ -0,0 +1,79 @@
# Soul 管理后台 (soul-admin) 变更记录 v2026-02
> 更新时间2026-02-21
> 适用站点souladmin.quwanzhi.com
> 部署路径:`/www/wwwroot/自营/soul-admin/dist/`
---
## 一、变更概览
| 模块 | 变更项 | 说明 |
|:---|:---|:---|
| 侧边栏 | 交易中心 → 推广中心 | 菜单及页面标题统一改为「推广中心」 |
| 内容管理 | 顶部 5 按钮移除 | 移除:初始化数据库、同步到数据库、导入、导出、同步飞书 |
| 内容管理 | 仅保留 API 接口 | 仅保留「API 接口」按钮,打开 API 文档面板 |
| 内容管理 | 删除按钮 | 删除按钮改为悬停才显示(与读取/编辑一致) |
| 内容管理 | 免费/付费 | 可点击切换免费 ↔ 付费 |
| 内容管理 | 小节加号 | 每小节旁增加「+」按钮,可在此小节下新建章节 |
---
## 二、部署说明
### 2.1 正确部署路径
nginx 实际指向:
```nginx
root /www/wwwroot/自营/soul-admin/dist;
```
**重要**:需将 `soul-admin/dist` 部署到上述目录,而非 `/www/wwwroot/souladmin.quwanzhi.com/`
### 2.2 部署步骤
```bash
# 1. 本地打包
cd /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验/soul-admin/dist
tar -czf /tmp/souladmin.tar.gz index.html assets/
# 2. 上传并解压到正确路径
scp -P 22022 /tmp/souladmin.tar.gz root@43.139.27.93:/tmp/
ssh -p 22022 root@43.139.27.93 'cd /www/wwwroot/自营/soul-admin/dist && tar -xzf /tmp/souladmin.tar.gz && chown -R www:www . && rm /tmp/souladmin.tar.gz'
```
### 2.3 缓存处理
- `index.html` 内引用 `index-CbOmKBRd.js?v=版本号`,每次发布建议递增版本号
- 建议在 `index.html` 中调整:`?v=3` 或更高
---
## 三、技术说明
### 3.1 修改文件
- `index.html`:内联注入脚本(按钮改造、删除 hover、免费切换、加号新建
- `assets/index-CbOmKBRd.js`:侧边栏「交易中心」→「推广中心」
### 3.2 注入脚本触发条件
- 路径包含 `content`(如 `/content`
- 页面上存在「初始化数据库」按钮(内容管理页加载完成)
### 3.3 免费/付费切换
- 调用 `POST /api/db/book`,传入 `{ id, isFree, price }`
- 需后端支持按 id 更新 isFree/price
---
## 四、问题排查
| 现象 | 可能原因 | 处理方式 |
|:---|:---|:---|
| 界面未变化 | 部署到错误目录 | 确认部署到 `/www/wwwroot/自营/soul-admin/dist/` |
| 界面未变化 | 浏览器/CDN 缓存 | 清除缓存或使用无痕模式,或增加 `?v=` 版本号 |
| 内容管理注入不生效 | 路由为 hash 模式 | 检查 `location.pathname` 是否包含 `content`,必要时改用 `location.hash` |
| 免费切换失败 | 后端未实现更新 | 检查 soul-api 是否支持 `POST /api/db/book` 的更新逻辑 |