细纲生成按大纲规划的每卷章数出章,大纲页可编辑计划章数

- volumes 表新增 chapter_count 列(增量迁移),应用大纲时落库保存
- 细纲生成目标章数优先级:大纲计划章数 > 卷内已有章节数 > 默认 10
- outline 路由支持手动设置 chapter_count;大纲页每卷卡片新增「计划章数」输入框
This commit is contained in:
Administrator
2026-09-13 20:52:43 +08:00
parent 4073edcc20
commit ceaae40b54
6 changed files with 32 additions and 10 deletions
+4 -2
View File
@@ -55,7 +55,8 @@ export function migrate() {
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
title TEXT NOT NULL, title TEXT NOT NULL,
summary TEXT NOT NULL DEFAULT '', summary TEXT NOT NULL DEFAULT '',
sort INTEGER NOT NULL DEFAULT 0 sort INTEGER NOT NULL DEFAULT 0,
chapter_count INTEGER
); );
CREATE TABLE IF NOT EXISTS beats ( CREATE TABLE IF NOT EXISTS beats (
@@ -126,7 +127,8 @@ export function migrate() {
['beats', 'pov', "ALTER TABLE beats ADD COLUMN pov TEXT NOT NULL DEFAULT ''"], ['beats', 'pov', "ALTER TABLE beats ADD COLUMN pov TEXT NOT NULL DEFAULT ''"],
['beats', 'emotional_arc', "ALTER TABLE beats ADD COLUMN emotional_arc TEXT NOT NULL DEFAULT ''"], ['beats', 'emotional_arc', "ALTER TABLE beats ADD COLUMN emotional_arc TEXT NOT NULL DEFAULT ''"],
['beats', 'purpose', "ALTER TABLE beats ADD COLUMN purpose TEXT NOT NULL DEFAULT ''"], ['beats', 'purpose', "ALTER TABLE beats ADD COLUMN purpose TEXT NOT NULL DEFAULT ''"],
['beats', 'tension', 'ALTER TABLE beats ADD COLUMN tension INTEGER NOT NULL DEFAULT 0'] ['beats', 'tension', 'ALTER TABLE beats ADD COLUMN tension INTEGER NOT NULL DEFAULT 0'],
['volumes', 'chapter_count', 'ALTER TABLE volumes ADD COLUMN chapter_count INTEGER']
] ]
for (const [table, col, ddl] of addColumns) { for (const [table, col, ddl] of addColumns) {
const cols = db.prepare(`PRAGMA table_info(${table})`).all() as unknown as { name: string }[] const cols = db.prepare(`PRAGMA table_info(${table})`).all() as unknown as { name: string }[]
+1
View File
@@ -47,6 +47,7 @@ export interface VolumeRow {
title: string title: string
summary: string summary: string
sort: number sort: number
chapter_count: number | null
} }
export interface BeatRow { export interface BeatRow {
+9 -3
View File
@@ -202,9 +202,9 @@ generateRouter.post('/generate/beats', (req, res) => {
}) })
.join('\n') .join('\n')
// 本卷目标章数:应用大纲时创建的空章节数;没有章节信息时默认 10 // 本卷目标章数:优先用大纲规划的每卷章数,其次本卷已有章节数,都没有时默认 10
const existing = bundle.chapters.filter((c: ChapterRow) => c.volume_id === volume.id).length const existing = bundle.chapters.filter((c: ChapterRow) => c.volume_id === volume.id).length
const chapterCount = existing > 0 ? existing : 10 const chapterCount = volume.chapter_count && volume.chapter_count > 0 ? volume.chapter_count : existing > 0 ? existing : 10
const messages = beatsMessages( const messages = beatsMessages(
toProjectLike(bundle.project), toProjectLike(bundle.project),
{ title: volume.title, summary: volume.summary, chapter_count: chapterCount }, { title: volume.title, summary: volume.summary, chapter_count: chapterCount },
@@ -393,7 +393,13 @@ generateRouter.post('/generate/apply/volumes', (req, res) => {
db.prepare('DELETE FROM beats WHERE project_id = ?').run(projectId) db.prepare('DELETE FROM beats WHERE project_id = ?').run(projectId)
db.prepare('DELETE FROM volumes WHERE project_id = ?').run(projectId) db.prepare('DELETE FROM volumes WHERE project_id = ?').run(projectId)
items.forEach((v, i) => { items.forEach((v, i) => {
db.prepare('INSERT INTO volumes (project_id, title, summary, sort) VALUES (?,?,?,?)').run(projectId, v.title, v.summary, i + 1) db.prepare('INSERT INTO volumes (project_id, title, summary, sort, chapter_count) VALUES (?,?,?,?,?)').run(
projectId,
v.title,
v.summary,
i + 1,
v.chapter_count ?? null
)
}) })
db.exec('COMMIT') db.exec('COMMIT')
} catch (e) { } catch (e) {
+4 -3
View File
@@ -9,7 +9,8 @@ export const outlineRouter = Router()
const volumeInput = z.object({ const volumeInput = z.object({
title: z.string().min(1).max(100), title: z.string().min(1).max(100),
summary: z.string().max(4000).default(''), summary: z.string().max(4000).default(''),
sort: z.number().int().default(0) sort: z.number().int().default(0),
chapter_count: z.number().int().min(1).max(2000).nullable().optional()
}) })
outlineRouter.post('/projects/:projectId/volumes', (req, res) => { outlineRouter.post('/projects/:projectId/volumes', (req, res) => {
@@ -17,8 +18,8 @@ outlineRouter.post('/projects/:projectId/volumes', (req, res) => {
const parsed = volumeInput.safeParse(req.body) const parsed = volumeInput.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: parsed.error.issues[0].message }) if (!parsed.success) return res.status(400).json({ error: parsed.error.issues[0].message })
const info = db const info = db
.prepare('INSERT INTO volumes (project_id, title, summary, sort) VALUES (?,?,?,?)') .prepare('INSERT INTO volumes (project_id, title, summary, sort, chapter_count) VALUES (?,?,?,?,?)')
.run(projectId, parsed.data.title, parsed.data.summary, parsed.data.sort) .run(projectId, parsed.data.title, parsed.data.summary, parsed.data.sort, parsed.data.chapter_count ?? null)
res.json(db.prepare('SELECT * FROM volumes WHERE id = ?').get(info.lastInsertRowid)) res.json(db.prepare('SELECT * FROM volumes WHERE id = ?').get(info.lastInsertRowid))
}) })
+1
View File
@@ -48,6 +48,7 @@ export interface Volume {
title: string title: string
summary: string summary: string
sort: number sort: number
chapter_count: number | null
} }
export interface Beat { export interface Beat {
+13 -2
View File
@@ -48,8 +48,19 @@ export default function OutlineView({
<AutoSaveText value={v.title} onSave={(t) => saveVolume(v.id, { title: t })} placeholder="卷名" bordered={false} style={{ fontWeight: 700, fontSize: 15 }} /> <AutoSaveText value={v.title} onSave={(t) => saveVolume(v.id, { title: t })} placeholder="卷名" bordered={false} style={{ fontWeight: 700, fontSize: 15 }} />
</div> </div>
<AutoSaveText value={v.summary} onSave={(s) => saveVolume(v.id, { summary: s })} multiline minRows={2} placeholder="本卷大纲:核心冲突、关键事件、结尾钩子" style={{ color: '#555' }} /> <AutoSaveText value={v.summary} onSave={(s) => saveVolume(v.id, { summary: s })} multiline minRows={2} placeholder="本卷大纲:核心冲突、关键事件、结尾钩子" style={{ color: '#555' }} />
<div style={{ marginTop: 6, color: '#bbb', fontSize: 12 }}> <div style={{ marginTop: 6, color: '#bbb', fontSize: 12, display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
本卷 {bundle.chapters.filter((c) => c.volume_id === v.id).length} 章(生成细纲后自动创建) <span>本卷已有 {bundle.chapters.filter((c) => c.volume_id === v.id).length} 章 · 计划章数</span>
<AutoSaveText
value={v.chapter_count ? String(v.chapter_count) : ''}
onSave={(t) => {
const n = parseInt(t, 10)
saveVolume(v.id, { chapter_count: Number.isFinite(n) && n > 0 ? n : null })
}}
placeholder="自动"
size="small"
style={{ width: 64 }}
/>
<span>(生成细纲时按此数量出章;留空则按已有章节数,都没有时默认 10)</span>
</div> </div>
</Card> </Card>
)) ))