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
// 获取最新章节列表
// 获取最新章节有2日内更新则取最新3章否则随机取免费章节
import { NextRequest, NextResponse } from 'next/server'
import { getBookStructure } from '@/lib/book-file-system'
import { NextResponse } from 'next/server'
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 {
const bookStructure = getBookStructure()
// 获取所有章节并按时间排序
const allChapters: any[] = []
bookStructure.forEach((part: any) => {
part.chapters.forEach((chapter: any) => {
allChapters.push({
id: chapter.slug,
title: chapter.title,
part: part.title,
words: Math.floor(Math.random() * 3000) + 1500, // 模拟字数
updateTime: getRelativeTime(new Date(Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000)),
readTime: Math.ceil((Math.random() * 3000 + 1500) / 300)
})
let allChapters: Array<{
id: string
title: string
part: string
isFree: boolean
price: number
updatedAt: Date | string | null
createdAt: Date | string | null
}> = []
try {
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[]
if (dbRows?.length > 0) {
allChapters = dbRows.map((row: any) => ({
id: row.id,
title: row.section_title || row.title || '',
part: row.part_title || '真实的行业',
isFree: !!row.is_free,
price: row.price || 0,
updatedAt: row.updated_at || row.created_at,
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 latestChapters = allChapters.slice(0, 3)
const mostRecentTime = sorted[0]?.updatedAt ? new Date(sorted[0].updatedAt).getTime() : 0
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({
success: true,
chapters: latestChapters,
total: allChapters.length
banner,
label,
chapters: chapters.map((c) => ({ id: c.id, title: c.title, part: c.part, isFree: c.isFree })),
hasNewUpdates
})
} catch (error) {
console.error('获取章节失败:', error)
console.error('[latest-chapters] Error:', error)
return NextResponse.json(
{ error: '获取章节失败' },
{ success: false, error: '获取失败' },
{ 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 })
try {
// 获取书籍数据
await this.loadBookData()
// 计算推荐章节
this.computeLatestSection()
await this.loadLatestSection()
} catch (e) {
console.error('初始化失败:', e)
this.computeLatestSectionFallback()
} finally {
this.setData({ loading: false })
}
},
// 计算推荐章节根据用户ID随机、优先未付款
computeLatestSection() {
const { hasFullBook, purchasedSections } = app.globalData
const userId = app.globalData.userInfo?.id || wx.getStorageSync('userId') || 'guest'
// 所有章节列表
const allSections = [
{ id: '9.14', title: '大健康私域一个月150万的70后', part: '真实的赚钱' },
{ id: '9.13', title: 'AI工具推广一个隐藏的高利润赛道', part: '真实的赚钱' },
{ id: '9.12', title: '美业整合:一个人的公司如何月入十万', part: '真实的赚钱' },
{ id: '8.6', title: '云阿米巴:分不属于自己的钱', part: '真实的赚钱' },
{ id: '8.1', title: '流量杠杆:抖音、Soul、飞书', part: '真实的赚钱' },
{ id: '3.1', title: '3000万流水如何跑出来', part: '真实的行业' },
{ id: '5.1', title: '拍卖行抱朴一天240万的摇号生意', part: '真实的行业' },
{ id: '4.1', title: '旅游号:30天10万粉的真实逻辑', part: '真实的行业' }
]
// 用户ID生成的随机种子同一用户每天看到的不同
const today = new Date().toISOString().split('T')[0]
const seed = (userId + today).split('').reduce((a, b) => a + b.charCodeAt(0), 0)
// 筛选未付款章节
let candidates = allSections
if (!hasFullBook) {
const purchased = purchasedSections || []
const unpurchased = allSections.filter(s => !purchased.includes(s.id))
if (unpurchased.length > 0) {
candidates = unpurchased
// 从后端获取最新章节2日内有新章取最新3章否则随机免费章
async loadLatestSection() {
try {
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 计算(随机选免费章节
computeLatestSectionFallback() {
const bookData = app.globalData.bookData || this.data.bookData || []
let sections = []
if (Array.isArray(bookData)) {
sections = bookData.map(s => ({
id: s.id,
title: s.title || s.sectionTitle,
part: s.part || s.sectionTitle || '真实的行业',
isFree: s.isFree,
price: s.price
}))
} else if (bookData && typeof bookData === 'object') {
const parts = bookData.parts || (Array.isArray(bookData) ? bookData : [])
if (Array.isArray(parts)) {
parts.forEach(p => {
(p.chapters || p.sections || []).forEach(c => {
(c.sections || [c]).forEach(s => {
sections.push({
id: s.id,
title: s.title || s.section_title,
part: p.title || p.part_title || c.title || '',
isFree: s.isFree,
price: s.price
})
})
})
})
}
}
// 根据种子选择章节
const index = seed % candidates.length
const selected = candidates[index]
// 设置标签(如果有新增章节显示"最新更新",否则显示"推荐阅读"
const label = candidates === allSections ? '推荐阅读' : '为你推荐'
this.setData({
latestSection: selected,
latestLabel: label
})
const free = sections.filter(s => s.isFree !== false && (s.price === 0 || !s.price))
const candidates = free.length > 0 ? free : sections
if (candidates.length === 0) {
this.setData({ latestSection: { id: '1.1', title: '开始阅读', part: '真实的人' }, latestLabel: '为你推荐' })
return
}
const idx = Math.floor(Math.random() * candidates.length)
const selected = { id: candidates[idx].id, title: candidates[idx].title, part: candidates[idx].part || '真实的行业' }
this.setData({ latestSection: selected, latestLabel: '为你推荐' })
},
// 加载书籍数据

View File

@@ -39,7 +39,7 @@
<!-- Banner卡片 - 最新章节 -->
<view class="banner-card" bindtap="goToRead" data-id="{{latestSection.id}}">
<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-part">{{latestSection.part}}</view>
<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 name="viewport" content="width=device-width, initial-scale=1.0" />
<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">
</head>
<body>
@@ -13,9 +13,23 @@
(function(){
var CSS=document.createElement('style');
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;
border:1px solid #7f1d1d;color:#ef4444;margin-left:6px;transition:all .15s}
.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 h3{font-size:15px;margin:0 0 14px;color:#e0e6ed}
.si-panel label{display:block;font-size:12px;color:#94a3b8;margin:10px 0 4px}
@@ -76,46 +90,39 @@
var done=false;
function hideRedundantButtons(){
['初始化数据库','同步到数据库','导入','导出','同步飞书','上传内容'].forEach(function(t){
var b=findBtn(t);if(b)b.style.display='none';
});
}
function run(){
if(done)return;
if(!location.pathname.includes('content'))return;
if(!location.pathname.includes('content')&&!location.hash.includes('content'))return;
var initBtn=findBtn('初始化数据库');
if(!initBtn)return;
done=true;
// === 1. 改造顶部按钮 ===
var syncBtn=findBtn('同步到数据库');
var importBtn=findBtn('导入');
var exportBtn=findBtn('导出');
var feishuBtn=findBtn('同步飞书');
// === 1. 移除5个按钮+上传内容,只保留一个"API 接口"(持续执行防重复页)===
hideRedundantButtons();
setInterval(hideRedundantButtons,800);
// 把前两个按钮改成"上传内容"和"API接口"
initBtn.textContent='上传内容';
initBtn.onclick=function(e){e.preventDefault();e.stopPropagation();togglePanel('upload')};
// 去掉原来的事件
var newInit=initBtn.cloneNode(true);
newInit.onclick=function(e){e.preventDefault();e.stopPropagation();togglePanel('upload')};
initBtn.parentNode.replaceChild(newInit,initBtn);
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);
var btnParent=initBtn&&initBtn.parentElement;
if(btnParent&&!btnParent.querySelector('.si-api-only-btn')){
var apiBtn=document.createElement('button');
apiBtn.className='si-api-only-btn '+initBtn.className;apiBtn.style.display='inline-flex';
apiBtn.textContent='API 接口';
apiBtn.onclick=function(e){e.preventDefault();e.stopPropagation();togglePanel('api')};
btnParent.appendChild(apiBtn);
}
// 隐藏其余按钮
if(importBtn)importBtn.style.display='none';
if(exportBtn)exportBtn.style.display='none';
if(feishuBtn)feishuBtn.style.display='none';
// === 2. 创建面板(插入到 tabs 之前) ===
var tabBar=document.querySelector('[role="tablist"]');
if(!tabBar){
var tabs=findBtn('章节管理');
if(tabs)tabBar=tabs.parentElement;
}
var insertTarget=tabBar||newInit.parentElement;
var insertTarget=tabBar||(initBtn&&initBtn.parentElement);
// 上传面板
var upP=document.createElement('div');
@@ -180,28 +187,54 @@
};
document.getElementById('si-token-input').onclick=function(){this.select()};
// === 3. 给每个章节添加删除按钮 ===
addDelBtns();
new MutationObserver(function(){addDelBtns()}).observe(document.getElementById('root'),{childList:true,subtree:true});
// === 3. 内容操作:删除(hover)、免费/付费、加号在章节、拖拽 ===
addContentActions();
addChapterPlus();
addDragDrop();
new MutationObserver(function(){addContentActions();addChapterPlus();addDragDrop();}).observe(document.getElementById('root'),{childList:true,subtree:true});
}
var activePanel='';
function togglePanel(name){
var siPrefill={};
function togglePanel(name,prefill){
var up=document.getElementById('si-upload');
var ap=document.getElementById('si-apidoc');
if(!up||!ap)return;
if(activePanel===name){up.style.display='none';ap.style.display='none';activePanel='';return}
up.style.display=name==='upload'?'block':'none';
ap.style.display=name==='api'?'block':'none';
activePanel=name;
if(prefill)siPrefill=prefill;
if(activePanel===name&&name!=='upload'){ap.style.display='none';activePanel='';return}
if(name==='upload'){up.style.display='block';ap.style.display='none';applyPrefill();activePanel='upload';return}
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');
for(var i=0;i<all.length;i++){
var b=all[i];
if(b.textContent.trim()==='编辑'&&!b.dataset.sid){
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');
del.className='si-del';
del.textContent='删除';
@@ -212,7 +245,7 @@
var txt=row?row.textContent:'';
var m=txt.match(/([\d]+\.[\d]+|appendix-[\w]+|preface|epilogue)/);
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;
auth().then(function(ok){
if(!ok){toast('认证失败',false);return}
@@ -228,7 +261,140 @@
})
}
})(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` 的更新逻辑 |