C1 (CRITICAL) stream_draft:对不存在 project 流式写章先 fail-fast 404, 否则非法 id 静默烧一次付费/限流 LLM 调用并返 200。在触网关前查 project_repo.get。 H2 (HIGH) create_rule:给不存在 project 加规则原 FK 违例逃逸成 500 → 改为入库前 校验项目存在返 404(仿 chain/_require_project)。 H1 (HIGH) ProjectWizard:第3步「立意」与第4步「主角/金手指」原共用 form.premise 互相覆盖丢数据 → 新增独立 form.protagonist,toCreateRequest 合并两段进 premise (M1 projects 表仍只有 premise,不编造 API)。 回归测试: - test_projects.py:stream_draft 不存在 project → 404 且网关零调用;已有 draft 用例改 seed 真项目。 - test_rules.py:create_rule 不存在 project → 404 不写库;已有用例 seed 真项目。 - wizard.test.ts:premise+protagonist 合并不互相覆盖(2 例)。 门禁绿:ruff/format clean · mypy 210 · pytest 749 · 前端 tsc/lint/vitest 干净。
71 lines
2.2 KiB
TypeScript
71 lines
2.2 KiB
TypeScript
import type { ProjectCreateRequest } from "@/lib/api/types";
|
||
|
||
// 立项向导 5 步(UX §6.2)。表单状态用字符串/数组,提交时归一为 ProjectCreateRequest。
|
||
export interface WizardForm {
|
||
title: string;
|
||
genre: string;
|
||
logline: string;
|
||
sellingPoints: string[];
|
||
structure: string;
|
||
premise: string;
|
||
protagonist: string;
|
||
theme: string;
|
||
}
|
||
|
||
export const WIZARD_STEPS = 5;
|
||
|
||
export const emptyWizardForm: WizardForm = {
|
||
title: "",
|
||
genre: "",
|
||
logline: "",
|
||
sellingPoints: [],
|
||
structure: "",
|
||
premise: "",
|
||
protagonist: "",
|
||
theme: "",
|
||
};
|
||
|
||
export const GENRES = ["玄幻", "仙侠", "都市", "科幻", "历史", "悬疑"];
|
||
export const STRUCTURES = ["三幕", "故事圈", "雪花"];
|
||
export const SELLING_POINT_PRESETS = ["逆袭", "打脸", "系统流", "双男主", "群像"];
|
||
|
||
// 每一步可否前进:仅第 1 步(书名)必填,其余可空着继续(草稿式立项)。
|
||
export function canAdvance(step: number, form: WizardForm): boolean {
|
||
if (step === 1) return form.title.trim().length > 0;
|
||
return true;
|
||
}
|
||
|
||
// 整个向导可否提交:书名必填。
|
||
export function canSubmit(form: WizardForm): boolean {
|
||
return form.title.trim().length > 0;
|
||
}
|
||
|
||
export function clampStep(step: number): number {
|
||
if (step < 1) return 1;
|
||
if (step > WIZARD_STEPS) return WIZARD_STEPS;
|
||
return step;
|
||
}
|
||
|
||
// 归一为后端请求体(snake_case,空值落 null/省略)。
|
||
export function toCreateRequest(form: WizardForm): ProjectCreateRequest {
|
||
const trim = (s: string): string | null => {
|
||
const v = s.trim();
|
||
return v.length > 0 ? v : null;
|
||
};
|
||
// 立意(premise)与主角/金手指(protagonist)是两个独立输入,但 M1 projects 表只有
|
||
// premise 一个字段 → 合并进 premise,二者各占一段,互不覆盖(QA H1:此前共用同一字段)。
|
||
const premise =
|
||
[trim(form.premise), form.protagonist.trim() ? `主角/金手指:${form.protagonist.trim()}` : null]
|
||
.filter((s): s is string => s !== null)
|
||
.join("\n\n") || null;
|
||
return {
|
||
title: form.title.trim(),
|
||
genre: trim(form.genre),
|
||
logline: trim(form.logline),
|
||
premise,
|
||
theme: trim(form.theme),
|
||
selling_points: form.sellingPoints,
|
||
structure: trim(form.structure),
|
||
};
|
||
}
|