Files
writer-work-flow/apps/web/components/ProjectWizard.tsx
Yaojia Wang 2fe3bedfba fix(qa): 修 QA C1/H1/H2——写章/规则缺项目校验 + 立项向导字段覆盖
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 干净。
2026-06-24 17:17:35 +02:00

303 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { api } from "@/lib/api/client";
import { useToast } from "@/components/Toast";
import {
GENRES,
SELLING_POINT_PRESETS,
STRUCTURES,
WIZARD_STEPS,
canAdvance,
canSubmit,
clampStep,
emptyWizardForm,
toCreateRequest,
type WizardForm,
} from "@/lib/wizard/wizard";
const STEP_TITLES = [
"书名 + 题材",
"一句话故事 + 卖点 + 结构",
"立意 / 总纲",
"主角 / 金手指",
"基础世界观",
];
// 立项向导UX §6.2。Client Component收集字段 → POST /projects → 进工作台。
export function ProjectWizard() {
const router = useRouter();
const toast = useToast();
const [step, setStep] = useState(1);
const [form, setForm] = useState<WizardForm>(emptyWizardForm);
const [submitting, setSubmitting] = useState(false);
const update = (patch: Partial<WizardForm>): void =>
setForm((prev) => ({ ...prev, ...patch }));
const toggleSellingPoint = (point: string): void =>
setForm((prev) => ({
...prev,
sellingPoints: prev.sellingPoints.includes(point)
? prev.sellingPoints.filter((p) => p !== point)
: [...prev.sellingPoints, point],
}));
const goBack = (): void => setStep((s) => clampStep(s - 1));
const goNext = (): void => setStep((s) => clampStep(s + 1));
const submit = async (): Promise<void> => {
if (!canSubmit(form)) {
toast("请先填写书名", "error");
setStep(1);
return;
}
setSubmitting(true);
const { data, error } = await api.POST("/projects", {
body: toCreateRequest(form),
});
if (error || !data) {
setSubmitting(false);
toast("创建作品失败,请重试", "error");
return;
}
router.push(`/projects/${data.id}/write`);
};
const isLast = step === WIZARD_STEPS;
return (
<div className="mx-auto max-w-2xl rounded border border-line bg-panel p-8 shadow-paper">
<div className="mb-6 flex items-center justify-between">
<h1 className="font-serif text-2xl text-ink"></h1>
<StepDots current={step} />
</div>
<p className="mb-4 text-sm text-ink-soft">
{step}/{WIZARD_STEPS} · {STEP_TITLES[step - 1]}
</p>
<div className="min-h-[220px]">
{step === 1 && <StepBasics form={form} update={update} />}
{step === 2 && (
<StepStory
form={form}
update={update}
toggleSellingPoint={toggleSellingPoint}
/>
)}
{step === 3 && <StepPremise form={form} update={update} />}
{step === 4 && <StepProtagonist form={form} update={update} />}
{step === 5 && <StepWorld />}
</div>
<div className="mt-8 flex items-center justify-between">
<button
type="button"
onClick={goBack}
disabled={step === 1}
className="rounded border border-line px-4 py-2 text-sm text-ink disabled:opacity-40"
>
</button>
{isLast ? (
<button
type="button"
onClick={submit}
disabled={submitting || !canSubmit(form)}
className="rounded bg-cinnabar px-5 py-2 text-sm text-panel disabled:opacity-40"
>
{submitting ? "创建中…" : "完成立项 →"}
</button>
) : (
<button
type="button"
onClick={goNext}
disabled={!canAdvance(step, form)}
className="rounded bg-cinnabar px-5 py-2 text-sm text-panel disabled:opacity-40"
>
</button>
)}
</div>
</div>
);
}
function StepDots({ current }: { current: number }) {
return (
<div className="flex gap-1.5" aria-hidden="true">
{Array.from({ length: WIZARD_STEPS }, (_, i) => (
<span
key={i}
className={`h-2 w-2 rounded-full ${
i + 1 <= current ? "bg-cinnabar" : "bg-line"
}`}
/>
))}
</div>
);
}
interface StepProps {
form: WizardForm;
update: (patch: Partial<WizardForm>) => void;
}
function Field({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<label className="mb-4 block">
<span className="mb-1 block text-sm text-ink-soft">{label}</span>
{children}
</label>
);
}
const inputCls =
"w-full rounded border border-line bg-bg px-3 py-2 text-ink focus:border-cinnabar focus:outline-none";
function StepBasics({ form, update }: StepProps) {
return (
<div>
<Field label="书名(必填)">
<input
className={inputCls}
value={form.title}
onChange={(e) => update({ title: e.target.value })}
placeholder="例:逐光而行"
autoFocus
/>
</Field>
<Field label="题材">
<select
className={inputCls}
value={form.genre}
onChange={(e) => update({ genre: e.target.value })}
>
<option value=""></option>
{GENRES.map((g) => (
<option key={g} value={g}>
{g}
</option>
))}
</select>
</Field>
</div>
);
}
function StepStory({
form,
update,
toggleSellingPoint,
}: StepProps & { toggleSellingPoint: (p: string) => void }) {
return (
<div>
<Field label="一句话故事logline">
<textarea
className={`${inputCls} h-20 resize-none`}
value={form.logline}
onChange={(e) => update({ logline: e.target.value })}
placeholder="废柴少年觉醒禁忌血脉,在仙门倾轧中逆势封神。"
/>
</Field>
<Field label="故事结构">
<div className="flex gap-2">
{STRUCTURES.map((s) => (
<button
type="button"
key={s}
onClick={() => update({ structure: s })}
className={`rounded border px-3 py-1.5 text-sm ${
form.structure === s
? "border-cinnabar bg-[var(--color-cinnabar-wash)] text-cinnabar"
: "border-line text-ink"
}`}
>
{s}
</button>
))}
</div>
</Field>
<fieldset>
<legend className="mb-1 block text-sm text-ink-soft"></legend>
<div className="flex flex-wrap gap-2">
{SELLING_POINT_PRESETS.map((p) => (
<button
type="button"
key={p}
aria-pressed={form.sellingPoints.includes(p)}
onClick={() => toggleSellingPoint(p)}
className={`rounded border px-3 py-1.5 text-sm ${
form.sellingPoints.includes(p)
? "border-cinnabar bg-[var(--color-cinnabar-wash)] text-cinnabar"
: "border-line text-ink"
}`}
>
{p}
</button>
))}
</div>
</fieldset>
</div>
);
}
function StepPremise({ form, update }: StepProps) {
return (
<div>
<Field label="立意premise">
<textarea
className={`${inputCls} h-20 resize-none`}
value={form.premise}
onChange={(e) => update({ premise: e.target.value })}
placeholder="故事的核心命题与前提。"
/>
</Field>
<Field label="主题theme">
<input
className={inputCls}
value={form.theme}
onChange={(e) => update({ theme: e.target.value })}
placeholder="例:抗争与代价"
/>
</Field>
</div>
);
}
function StepProtagonist({ form, update }: StepProps) {
// M1 projects 表无独立主角/金手指字段;提交时由 toCreateRequest 合并进 premise与立意各占一段
// 必须绑定独立的 form.protagonist不能复用 form.premise否则与第 3 步立意互相覆盖QA H1
return (
<div>
<p className="mb-3 text-sm text-ink-soft">
M1
</p>
<Field label="主角 / 金手指概要">
<textarea
className={`${inputCls} h-28 resize-none`}
value={form.protagonist}
onChange={(e) => update({ protagonist: e.target.value })}
placeholder="主角设定、金手指来源与限制……"
/>
</Field>
</div>
);
}
function StepWorld() {
return (
<div className="rounded border border-dashed border-line bg-bg p-6 text-sm text-ink-soft">
</div>
);
}