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:
@@ -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>
|
||||
|
||||
12
apps/web/lib/api/schema.d.ts
vendored
12
apps/web/lib/api/schema.d.ts
vendored
@@ -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 项目最近更新时间
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user