feat(web): 立项新增 基调/结局/视角 三字段(书级常量入 stable_core 缓存前缀)

灵感计划 §3④ + §4 D 枚举决策:
- projects 表加 tone/ending_type/narrative_pov(Text nullable、无 CHECK,同 genre/structure;
  迁移 e5f6a7b8c9d0 nullable 免 backfill)。
- ProjectCreateRequest/ProjectResponse + domain ProjectCreate/ProjectView 全链路透传(snake_case)。
- 完整 stable_core 链路:ProjectSpecView + SqlProjectSpecRepo.spec + _build_spec_section 渲染进
  「作品蓝本」块——书级常量随书稳定,字节稳定守缓存前缀不变量 #9,绝不入 volatile。
- 前端 wizard.ts 三字段 + 预设数组 TONES/ENDING_TYPES/NARRATIVE_POVS + ProjectWizard 控件与确认页回显。
- 契约变更已 pnpm gen:api + memory/contracts.md 登记。
This commit is contained in:
Yaojia Wang
2026-07-06 15:59:49 +02:00
parent 491d9342ef
commit 821ace5989
17 changed files with 247 additions and 1 deletions

View File

@@ -55,6 +55,9 @@ class FakeProjectRepo:
theme=data.theme,
selling_points=list(data.selling_points),
structure=data.structure,
tone=data.tone,
ending_type=data.ending_type,
narrative_pov=data.narrative_pov,
updated_at=self.now,
)
self.rows[pid] = (owner_id, view)

View File

@@ -150,6 +150,54 @@ async def test_create_project_returns_201() -> None:
assert uuid.UUID(body["id"])
@pytest.mark.asyncio
async def test_create_project_persists_tone_ending_pov() -> None:
# ④ 立项新增基调/结局/视角:请求 → repo 落库 → 响应回显snake_case 契约)。
client, repo, _, _ = _make_client()
async with client:
resp = await client.post(
"/projects",
json={
"title": "基调作品",
"tone": "热血",
"ending_type": "HE",
"narrative_pov": "第一人称",
},
)
assert resp.status_code == 201
body = resp.json()
assert body["tone"] == "热血"
assert body["ending_type"] == "HE"
assert body["narrative_pov"] == "第一人称"
(_owner, view) = next(iter(repo.rows.values()))
assert view.tone == "热血"
assert view.ending_type == "HE"
assert view.narrative_pov == "第一人称"
@pytest.mark.asyncio
async def test_get_project_exposes_tone_ending_pov() -> None:
client, _, _, _ = _make_client()
async with client:
created = (
await client.post(
"/projects",
json={
"title": "",
"tone": "沉重",
"ending_type": "BE",
"narrative_pov": "多视角",
},
)
).json()
resp = await client.get(f"/projects/{created['id']}")
assert resp.status_code == 200
body = resp.json()
assert body["tone"] == "沉重"
assert body["ending_type"] == "BE"
assert body["narrative_pov"] == "多视角"
@pytest.mark.asyncio
async def test_list_projects() -> None:
client, repo, _, _ = _make_client()

View File

@@ -127,6 +127,9 @@ async def create_project(body: ProjectCreateRequest, repo: ProjectRepoDep) -> Pr
theme=body.theme,
selling_points=body.selling_points,
structure=body.structure,
tone=body.tone,
ending_type=body.ending_type,
narrative_pov=body.narrative_pov,
),
)
log.info("project_created", project_id=str(view.id), title_len=len(view.title))

View File

@@ -22,6 +22,10 @@ class ProjectCreateRequest(BaseModel):
theme: str | None = None
selling_points: list[str] = Field(default_factory=list)
structure: str | None = None
# 立项书级常量灵感④——free text + 前端预设,无 LiteralLiteral 只留 Verdict
tone: str | None = None
ending_type: str | None = None
narrative_pov: str | None = None
class ProjectResponse(BaseModel):
@@ -35,6 +39,9 @@ class ProjectResponse(BaseModel):
theme: str | None = None
selling_points: list[str] = Field(default_factory=list)
structure: str | None = None
tone: str | None = None
ending_type: str | None = None
narrative_pov: str | None = None
updated_at: datetime | None = Field(default=None, description="项目最近更新时间")
pending_review_count: int = Field(default=0, ge=0, description="待审稿章节数")

View File

@@ -15,10 +15,13 @@ import { TextInput } from "@/components/ui/TextInput";
import { api } from "@/lib/api/client";
import { buttonClass } from "@/lib/ui/variants";
import {
ENDING_TYPES,
GENRES,
NARRATIVE_POVS,
SELLING_POINT_PRESETS,
STEP_TITLES,
STRUCTURES,
TONES,
WIZARD_STEPS,
canAdvance,
canSubmit,
@@ -267,6 +270,37 @@ function StepPremise({ form, update }: StepProps) {
placeholder="例:抗争与代价"
/>
</Field>
<Field label="基调">
<Select
value={form.tone}
onChange={(e) => update({ tone: e.target.value })}
>
<option value=""></option>
{TONES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</Select>
</Field>
<Field label="叙事视角">
<SegmentedControl
ariaLabel="叙事视角"
className="flex-wrap"
value={form.narrativePov}
onChange={(narrativePov) => update({ narrativePov })}
options={NARRATIVE_POVS.map((p) => ({ value: p, label: p }))}
/>
</Field>
<Field label="结局取向">
<SegmentedControl
ariaLabel="结局取向"
className="flex-wrap"
value={form.endingType}
onChange={(endingType) => update({ endingType })}
options={ENDING_TYPES.map((e) => ({ value: e, label: e }))}
/>
</Field>
</div>
);
}
@@ -302,6 +336,9 @@ function StepConfirm({ form }: { form: WizardForm }) {
label: "核心卖点",
value: form.sellingPoints.length > 0 ? form.sellingPoints.join("、") : "未选择",
},
{ label: "基调", value: form.tone || "未选择" },
{ label: "叙事视角", value: form.narrativePov || "未选择" },
{ label: "结局取向", value: form.endingType || "未选择" },
];
return (
<div>

View File

@@ -1518,6 +1518,12 @@ export interface components {
selling_points?: string[];
/** Structure */
structure?: string | null;
/** Tone */
tone?: string | null;
/** Ending Type */
ending_type?: string | null;
/** Narrative Pov */
narrative_pov?: string | null;
};
/**
* ProjectListResponse
@@ -1551,6 +1557,12 @@ export interface components {
selling_points?: string[];
/** Structure */
structure?: string | null;
/** Tone */
tone?: string | null;
/** Ending Type */
ending_type?: string | null;
/** Narrative Pov */
narrative_pov?: string | null;
/**
* Updated At
* @description 项目最近更新时间

View File

@@ -57,6 +57,9 @@ describe("toCreateRequest", () => {
premise: " ",
protagonist: "",
theme: "抗争",
tone: "",
endingType: "",
narrativePov: "",
};
expect(toCreateRequest(form)).toEqual({
title: "逐光而行",
@@ -66,9 +69,34 @@ describe("toCreateRequest", () => {
theme: "抗争",
selling_points: ["逆袭", "系统流"],
structure: "三幕",
tone: null,
ending_type: null,
narrative_pov: null,
});
});
// ④ 立项新增基调/结局/视角camelCase 表单 → snake_case 请求,空值落 null。
it("maps tone, endingType, narrativePov to snake_case fields", () => {
const form: WizardForm = {
...emptyWizardForm,
title: "书",
tone: "热血",
endingType: "HE",
narrativePov: "第一人称",
};
const req = toCreateRequest(form);
expect(req.tone).toBe("热血");
expect(req.ending_type).toBe("HE");
expect(req.narrative_pov).toBe("第一人称");
});
it("nulls empty tone, endingType, narrativePov", () => {
const req = toCreateRequest({ ...emptyWizardForm, title: "书" });
expect(req.tone).toBeNull();
expect(req.ending_type).toBeNull();
expect(req.narrative_pov).toBeNull();
});
// QA H1 回归立意premise与主角/金手指protagonist是两个独立输入
// 不能互相覆盖——提交时各占一段合并进 premise。
it("merges premise and protagonist into premise without overwriting", () => {

View File

@@ -10,6 +10,10 @@ export interface WizardForm {
premise: string;
protagonist: string;
theme: string;
// 立项书级常量(灵感④):基调 / 结局取向 / 叙事视角。
tone: string;
endingType: string;
narrativePov: string;
}
export const WIZARD_STEPS = 5;
@@ -32,11 +36,18 @@ export const emptyWizardForm: WizardForm = {
premise: "",
protagonist: "",
theme: "",
tone: "",
endingType: "",
narrativePov: "",
};
export const GENRES = ["玄幻", "仙侠", "都市", "科幻", "历史", "悬疑"];
export const STRUCTURES = ["三幕", "故事圈", "雪花"];
export const SELLING_POINT_PRESETS = ["逆袭", "打脸", "系统流", "双男主", "群像"];
// 立项书级常量预设(灵感④ / D 枚举——free text 单选,命不中预设可留空。
export const TONES = ["热血", "轻松", "沉重", "治愈", "暗黑", "爽文"];
export const ENDING_TYPES = ["HE", "BE", "开放", "正剧"];
export const NARRATIVE_POVS = ["第一人称", "第三人称限知", "第三人称全知", "多视角"];
// 每一步可否前进:仅第 1 步(书名)必填,其余可空着继续(草稿式立项)。
export function canAdvance(step: number, form: WizardForm): boolean {
@@ -75,5 +86,8 @@ export function toCreateRequest(form: WizardForm): ProjectCreateRequest {
theme: trim(form.theme),
selling_points: form.sellingPoints,
structure: trim(form.structure),
tone: trim(form.tone),
ending_type: trim(form.endingType),
narrative_pov: trim(form.narrativePov),
};
}