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

- 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,
title TEXT NOT NULL,
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 (
@@ -126,7 +127,8 @@ export function migrate() {
['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', '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) {
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
summary: string
sort: number
chapter_count: number | null
}
export interface BeatRow {
+9 -3
View File
@@ -202,9 +202,9 @@ generateRouter.post('/generate/beats', (req, res) => {
})
.join('\n')
// 本卷目标章数:应用大纲时创建的空章节数;没有章节信息时默认 10
// 本卷目标章数:优先用大纲规划的每卷章数,其次本卷已有章节数,都没有时默认 10
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(
toProjectLike(bundle.project),
{ 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 volumes WHERE project_id = ?').run(projectId)
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')
} catch (e) {
+4 -3
View File
@@ -9,7 +9,8 @@ export const outlineRouter = Router()
const volumeInput = z.object({
title: z.string().min(1).max(100),
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) => {
@@ -17,8 +18,8 @@ outlineRouter.post('/projects/:projectId/volumes', (req, res) => {
const parsed = volumeInput.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: parsed.error.issues[0].message })
const info = db
.prepare('INSERT INTO volumes (project_id, title, summary, sort) VALUES (?,?,?,?)')
.run(projectId, parsed.data.title, parsed.data.summary, parsed.data.sort)
.prepare('INSERT INTO volumes (project_id, title, summary, sort, chapter_count) VALUES (?,?,?,?,?)')
.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))
})
+1
View File
@@ -48,6 +48,7 @@ export interface Volume {
title: string
summary: string
sort: number
chapter_count: number | null
}
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 }} />
</div>
<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 }}>
本卷 {bundle.chapters.filter((c) => c.volume_id === v.id).length} 章(生成细纲后自动创建)
<div style={{ marginTop: 6, color: '#bbb', fontSize: 12, display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
<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>
</Card>
))