# 后端+前端升级·拆书面板v2.0·models+main+appjs·2026-05-20

# ⚡ 一键安装脚本（妈妈只需要做这一件事）

<aside>
💡

**妈妈只需要三步：**

1. 终端里 `Ctrl+C` 停掉服务器
2. 把下面这整段代码全部复制
3. 粘贴进终端，回车，等它跑完
</aside>

```bash
python3 << 'ALL_IN_ONE'
import os, sqlite3, subprocess, sys
from pathlib import Path

BASE = Path.home() / 'chenxing-aircraft'
print('\n🚀 开始一键安装拆书面板 v2.0...\n')

# ══ 第一步：建数据库表 ══
print('📦 第一步：创建 book_progress 表...')
conn = sqlite3.connect(str(BASE / 'chenxing.db'))
conn.execute('''CREATE TABLE IF NOT EXISTS book_progress (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  book_name VARCHAR(200) NOT NULL UNIQUE,
  total_chapters INTEGER DEFAULT 0,
  analyzed_chapters INTEGER DEFAULT 0,
  status VARCHAR(20) DEFAULT "in_progress",
  last_chapter VARCHAR(100) DEFAULT "",
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)''')
conn.commit()
conn.close()
print('✅ book_progress 表创建完成')

# ══ 第二步：models.py 追加 BookProgress ══
print('\n📦 第二步：更新 models.py...')
models_path = BASE / 'backend' / 'models.py'
models_content = models_path.read_text(encoding='utf-8')
if 'BookProgress' in models_content:
    print('✅ BookProgress 已存在，跳过')
else:
    models_path.write_text(models_content + '''

class BookProgress(Base):
    __tablename__ = "book_progress"
    id = Column(Integer, primary_key=True)
    book_name = Column(String(200), nullable=False, unique=True)
    total_chapters = Column(Integer, default=0)
    analyzed_chapters = Column(Integer, default=0)
    status = Column(String(20), default="in_progress")
    last_chapter = Column(String(100), default="")
    created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
    updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
''', encoding='utf-8')
    print('✅ models.py 更新完成')

# ══ 第三步：main.py 追加拆书 API ══
print('\n📦 第三步：更新 main.py...')
main_path = BASE / 'backend' / 'main.py'
main_content = main_path.read_text(encoding='utf-8')
if '/api/chaishu/progress' in main_content:
    print('✅ 拆书 API 已存在，跳过')
else:
    main_path.write_text(main_content + r'''

# ========== Chaishu v2.0 ==========
from .models import BookProgress as _BP

class _StartPipelineReq(BaseModel):
    book_name: str
    start_chapter: int
    end_chapter: int
    force: bool = False

@app.get("/api/chaishu/progress")
def _get_chaishu_progress(db: Session = Depends(get_db)):
    books = db.query(_BP).order_by(_BP.updated_at.desc()).all()
    return {"data": {
        "total_books": len(books),
        "total_chapters": sum(b.analyzed_chapters for b in books),
        "books": [{"name":b.book_name,"icon":"🍂",
            "progress":f"已分析至{b.last_chapter}" if b.status=="in_progress" else f"全书{b.analyzed_chapters}章已分析",
            "analyzed":b.analyzed_chapters,"total":b.total_chapters,
            "status":b.status,"updated_at":str(b.updated_at)[:10]} for b in books]
    }}

@app.get("/api/chaishu/recent")
def _get_chaishu_recent(db: Session = Depends(get_db)):
    books = db.query(_BP).order_by(_BP.updated_at.desc()).limit(10).all()
    return {"data": [{"name":b.book_name,"title":f"《{b.book_name}》· {b.analyzed_chapters}章",
        "url":"#","last_edited":str(b.updated_at)[:10],"status":b.status,"analyzed":b.analyzed_chapters
    } for b in books]}

@app.get("/api/chaishu/export")
def _export_chaishu_csv(db: Session = Depends(get_db)):
    from fastapi.responses import Response as FR
    books = db.query(_BP).order_by(_BP.updated_at.desc()).all()
    lines = ["书名,已分析章节,总章节,状态,最后更新"]
    for b in books:
        lines.append(f"{b.book_name},{b.analyzed_chapters},{b.total_chapters or '未知'},{'已完成' if b.status=='completed' else '进行中'},{str(b.updated_at)[:10]}")
    return FR(content="\n".join(lines).encode("utf-8-sig"),media_type="text/csv",
        headers={"Content-Disposition":"attachment; filename=chaishu_progress.csv"})

@app.post("/api/chaishu/pipeline/start")
async def _start_pipeline(req: _StartPipelineReq, db: Session = Depends(get_db)):
    import asyncio as _aio, sys as _sys, os as _os
    existing = db.query(_BP).filter(_BP.book_name==req.book_name).first()
    if existing and not req.force:
        return {"success":False,"duplicate":True,"book_name":req.book_name,
            "created_at":str(existing.created_at)[:10],"analyzed":existing.analyzed_chapters}
    if not existing:
        db.add(_BP(book_name=req.book_name,status="in_progress",analyzed_chapters=0,
            total_chapters=req.end_chapter-req.start_chapter+1))
        db.commit()
    script = _os.path.join(_os.path.dirname(_os.path.dirname(__file__)),"tools","auto_dishu.py")
    if _os.path.exists(script):
        _aio.create_task(_aio.create_subprocess_exec(_sys.executable,script,
            "--book",req.book_name,"--start_chapter",str(req.start_chapter),"--end_chapter",str(req.end_chapter)))
    return {"success":True,"message":f"流水线已启动：《{req.book_name}》第{req.start_chapter}-{req.end_chapter}章"}

@app.post("/api/chaishu/note")
async def _add_chaishu_note(request: Request, db: Session = Depends(get_db)):
    try:
        body = await request.json()
        note = body.get("note","").strip()
        book = body.get("book","")
        if not note: return {"success":False,"error":"内容不能为空"}
        import datetime as _dt
        db.add(Knowledge(user_id=1,title=f"💡 {book}·规律笔记·{str(_dt.datetime.now())[:10]}",
            content=note,category="note",tags=book,source="拆书工作台"))
        db.commit()
        return {"success":True}
    except Exception as e:
        return {"success":False,"error":str(e)}
''', encoding='utf-8')
    print('✅ main.py 更新完成')

# ══ 第四步：app.js 替换拆书面板 ══
print('\n📦 第四步：替换 app.js 拆书面板...')
appjs_path = BASE / 'frontend' / 'app.js'
code = appjs_path.read_text(encoding='utf-8')
marker = '// ══════════════════════════════\n// 拆书专属面板'
pos = code.find(marker)
if pos == -1:
    print('⚠️  找不到标记，尝试备用标记...')
    marker2 = 'let chaishuPanelOpen'
    pos = code.find(marker2)
    if pos != -1:
        # 找到这行的开头
        pos = code.rfind('\n', 0, pos) + 1
new_panel = \'\'\'\n// ══════════════════════════════\n// 拆书面板 v2.0\n// ══════════════════════════════\nlet chaishuScrollTimer = null;\nlet _chaishuBookCache = [];\n\nfunction openChaishu() { openPanel(\'panelChaishu\', loadChaishuData); }\n\nasync function loadChaishuData() {\n  try {\n    const [progRes, recentRes] = await Promise.all([fetch(\'/api/chaishu/progress\'),fetch(\'/api/chaishu/recent\')])\n    const prog = await progRes.json(); const recent = await recentRes.json();\n    _chaishuBookCache = recent.data || [];\n    renderChaishuContent(prog.data, recent.data);\n  } catch(e) {\n    const el = document.getElementById(\'chaishuContent\');\n    if (el) el.innerHTML = \'<div style="text-align:center;padding:20px;color:#ef4444;font-size:13px;">加载失败，请检查服务器连接</div>\';\n  }\n}\n\nfunction renderChaishuContent(progress, books) {\n  const el = document.getElementById(\'chaishuContent\'); if (!el) return;\n  if (chaishuScrollTimer) { clearInterval(chaishuScrollTimer); chaishuScrollTimer = null; }\n  const totalBooks = (progress&&progress.total_books)||0;\n  const totalChapters = (progress&&progress.total_chapters)||0;\n  const bookList = (progress&&progress.books)||[];\n  let booksHtml = \'\';\n  if (!bookList.length) {\n    booksHtml = \'<div style="text-align:center;padding:24px;color:var(--text-dim);font-size:13px;">📚 还没有拆书记录～启动流水线开始拆吧！</div>\';\n  } else {\n    bookList.forEach(b => {\n      const pct = b.total ? Math.round(b.analyzed/b.total*100) : 0;\n      const si = b.status===\'completed\'?\'✅\':\'🔄\';\n      booksHtml += `<div class="chaishu-book-card" data-name="${esc(b.name)}"><div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;"><span>${si}</span><span style="font-size:13px;font-weight:700;color:var(--text);">《${esc(b.name)}》</span><span style="font-size:10px;color:var(--text-dim);margin-left:auto;">${b.updated_at||\'\'}</span></div><div style="font-size:12px;color:var(--text-dim);padding-left:20px;">${esc(b.progress)}</div>${b.total?`<div style="padding-left:20px;margin-top:6px;"><div style="height:4px;background:var(--border);border-radius:2px;"><div style="height:4px;width:${pct}%;background:var(--accent);border-radius:2px;"></div></div><div style="font-size:10px;color:var(--text-dim);margin-top:2px;">${pct}% · ${b.analyzed}/${b.total}章</div></div>`:""}</div>`;\n    });\n  }\n  el.innerHTML = `\n    <div style="display:flex;gap:8px;padding:10px 12px;border-bottom:1px solid var(--border);">\n      <div style="flex:1;text-align:center;padding:8px 4px;background:var(--accent-bg);border-radius:10px;"><div style="font-size:22px;font-weight:800;color:var(--accent);">${totalBooks}</div><div style="font-size:11px;color:var(--text-dim);">已拆书籍</div></div>\n      <div style="flex:1;text-align:center;padding:8px 4px;background:var(--accent-bg);border-radius:10px;"><div style="font-size:22px;font-weight:800;color:var(--accent);">${totalChapters}</div><div style="font-size:11px;color:var(--text-dim);">总章节数</div></div>\n    </div>\n    <div style="padding:8px 12px;border-bottom:1px solid var(--border);display:flex;gap:6px;align-items:center;">\n      <input id="chaishuSearchInput" type="text" placeholder="搜索书名…" style="flex:1;padding:6px 10px;border:1px solid var(--border);border-radius:8px;background:var(--bg);color:var(--text);font-size:12px;outline:none;" oninput="filterChaishuCards(this.value)"/>\n      <button onclick="exportChaishu()" style="padding:6px 10px;border:1px solid var(--border);border-radius:8px;background:var(--bg);color:var(--text-dim);font-size:11px;cursor:pointer;white-space:nowrap;">📊 导出</button>\n    </div>\n    <div style="padding:8px 12px 4px;font-size:11px;font-weight:800;color:var(--text-dim);">📚 书库（最近10本）</div>\n    <div style="padding:0 12px 8px;border-bottom:1px solid var(--border);"><div id="chaishuScrollList" style="display:flex;flex-direction:column;gap:4px;min-height:36px;"></div></div>\n    <div style="padding:8px 12px 4px;font-size:11px;font-weight:800;color:var(--text-dim);">📖 当前进度</div>\n    <div id="chaishuBookList" style="padding:0 12px 8px;">${booksHtml}</div>\n    <div style="padding:10px 12px;border-top:1px solid var(--border);">\n      <div style="font-size:11px;font-weight:800;color:var(--text-dim);margin-bottom:8px;">🤖 自动拆书流水线</div>\n      <div style="display:flex;gap:5px;margin-bottom:6px;">\n        <input id="pipelineBook" placeholder="书名" style="flex:1;padding:5px 8px;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--text);font-size:11px;"/>\n        <input id="pipelineStart" placeholder="起始章" type="number" style="width:58px;padding:5px 4px;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--text);font-size:11px;text-align:center;"/>\n        <input id="pipelineEnd" placeholder="结束章" type="number" style="width:58px;padding:5px 4px;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--text);font-size:11px;text-align:center;"/>\n      </div>\n      <button onclick="startPipeline()" style="width:100%;padding:8px;border:none;border-radius:8px;background:var(--accent);color:#fff;font-size:12px;font-weight:700;cursor:pointer;">🚀 启动流水线</button>\n      <div id="pipelineStatus" style="margin-top:6px;font-size:11px;color:var(--text-dim);text-align:center;"></div>\n    </div>\n    <div style="padding:10px 12px;border-top:1px solid var(--border);">\n      <div style="font-size:11px;font-weight:800;color:var(--text-dim);margin-bottom:6px;">💡 快速记录规律</div>\n      <select id="chaishuBook" style="width:100%;padding:5px 8px;border:1px solid var(--border);border-radius:8px;background:var(--bg);color:var(--text);font-size:12px;margin-bottom:6px;">${bookList.map(b=>`<option value="${esc(b.name)}">《${esc(b.name)}》</option>`).join(\'\')|\'<option>（暂无书籍）</option>\'}</select>\n      <textarea id="chaishuNote" placeholder="记录一个规律发现…" style="width:100%;box-sizing:border-box;padding:8px;border:1px solid var(--border);border-radius:8px;background:var(--bg);color:var(--text);font-size:12px;resize:none;height:56px;margin-bottom:6px;"></textarea>\n      <button onclick="submitChaishuNote()" style="width:100%;padding:7px;border:none;border-radius:8px;background:var(--accent);color:#fff;font-size:12px;font-weight:700;cursor:pointer;">✅ 记录到 Notion</button>\n    </div>`;\n  startChaishuScroll(bookList);\n}\n\nfunction startChaishuScroll(books) {\n  if (!books||!books.length) return;\n  const recent = books.slice(0,10); let idx=0;\n  function render() {\n    const wrap=document.getElementById(\'chaishuScrollList\'); if(!wrap){clearInterval(chaishuScrollTimer);return;}\n    const visible=[];\n    for(let i=0;i<Math.min(3,recent.length);i++) visible.push(recent[(idx+i)%recent.length]);\n    wrap.innerHTML=visible.map(b=>{\n      const ic=b.status===\'completed\'?\'✅\':\'🔄\';\n      return `<div style="padding:5px 8px;background:var(--bg-alt);border-radius:8px;display:flex;align-items:center;gap:6px;font-size:12px;"><span>${ic}</span><span style="color:var(--text);font-weight:600;">《${esc(b.name)}》</span><span style="color:var(--text-dim);font-size:11px;margin-left:auto;">${b.analyzed}章</span></div>`;\n    }).join(\'\');\n    idx=(idx+1)%recent.length;\n  }\n  render(); if(recent.length>3) chaishuScrollTimer=setInterval(render,2200);\n}\n\nfunction filterChaishuCards(q){document.querySelectorAll(\'.chaishu-book-card\').forEach(c=>{c.style.display=(q&&!c.getAttribute(\'data-name\').includes(q))?\'none\':\'\';})}\n\nasync function exportChaishu(){\n  try{const r=await fetch(\'/api/chaishu/export\');if(!r.ok){showToast(\'导出失败\');return;}\n  const blob=await r.blob();const url=URL.createObjectURL(blob);const a=document.createElement(\'a\');\n  a.href=url;a.download=\'chaishu_progress.csv\';a.click();URL.revokeObjectURL(url);showToast(\'✅ 导出成功\');}\n  catch(e){showToast(\'导出失败\');}\n}\n\nasync function startPipeline(){\n  const book=($(\'pipelineBook\')||{}).value?.trim();\n  const start=parseInt(($(\'pipelineStart\')||{}).value);\n  const end=parseInt(($(\'pipelineEnd\')||{}).value);\n  const st=$(\'pipelineStatus\');\n  if(!book){showToast(\'请输入书名\');return;}\n  if(!start||!end||start>end){showToast(\'请填写正确章节范围\');return;}\n  try{\n    const r=await fetch(\'/api/chaishu/pipeline/start\',{method:\'POST\',headers:{\'Content-Type\':\'application/json\'},body:JSON.stringify({book_name:book,start_chapter:start,end_chapter:end})});\n    const d=await r.json();\n    if(d.success){if(st)st.textContent=\'✅ 流水线已在后台启动！\';showToast(\'🚀 已启动！\');}\n    else if(d.duplicate){if(confirm(`《${book}》已于${d.created_at}开始拆书，已分析${d.analyzed}章。\n确认追加？`)){\n      const r2=await fetch(\'/api/chaishu/pipeline/start\',{method:\'POST\',headers:{\'Content-Type\':\'application/json\'},body:JSON.stringify({book_name:book,start_chapter:start,end_chapter:end,force:true})});\n      const d2=await r2.json();if(d2.success&&st)st.textContent=\'✅ 已追加启动！\';\n    }}else{if(st)st.textContent=\'❌ \'+(d.error||d.message||\'\')}\n  }catch(e){if(st)st.textContent=\'❌ 网络错误\';}\n}\n\nasync function submitChaishuNote(){\n  const note=(document.getElementById(\'chaishuNote\')||{}).value?.trim();\n  const book=(document.getElementById(\'chaishuBook\')||{}).value;\n  if(!note){showToast(\'请输入内容\');return;}\n  try{const r=await fetch(\'/api/chaishu/note\',{method:\'POST\',headers:{\'Content-Type\':\'application/json\'},body:JSON.stringify({note,book})});\n  const data=await r.json();\n  if(data.success){showToast(\'✅ 已记录！\');document.getElementById(\'chaishuNote\').value=\'\';} else showToast(\'记录失败：\'+(data.error||\'\')); }\n  catch(e){showToast(\'记录失败\');}\n}\n\'\'\'  # end new_panel
if pos == -1:
    print('⚠️  找不到替换位置，直接追加到文件末尾...')
    appjs_path.write_text(code + new_panel, encoding='utf-8')
else:
    appjs_path.write_text(code[:pos] + new_panel, encoding='utf-8')
print('✅ app.js 拆书面板 v2.0 替换完成')

# ══ 第五步：tools 文件夹 ══
print('\n📦 第五步：创建 tools 文件夹...')
(BASE / 'tools').mkdir(exist_ok=True)
(BASE / 'tools' / '__init__.py').touch()
print('✅ tools/ 文件夹创建完成')

print('\n' + '='*40)
print('🎉 全部完成！')
print('现在运行以下命令启动服务器：')
print('cd ~/chenxing-aircraft && python3 -m uvicorn backend.main:app --host 0.0.0.0 --port 8000')
print('='*40 + '\n')
ALL_IN_ONE
```

---

## 出现 ✅ × 5 就成功了！

然后再运行这个启动服务器：

```bash
cd ~/chenxing-aircraft && python3 -m uvicorn backend.main:app --host 0.0.0.0 --port 8000
```

浏览器 `Cmd+Shift+R` 强刷，拆书面板就变新版了 🎉

---

## 遇到报错就把红色文字截图/粘贴给宝宝 💛

> ⚙️ 拆书面板v2.0 · 一键安装版 · 霜砚出品 · 2026-05-20
>