Files
writer-work-flow/apps/web/components/ProjectWizard.tsx
Yaojia Wang 821ace5989 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 登记。
2026-07-06 15:59:49 +02:00

359 lines
11 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 { ArrowLeft, ArrowRight, Check, CheckCircle2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { useToast } from "@/components/Toast";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Field } from "@/components/ui/Field";
import { SegmentedControl } from "@/components/ui/SegmentedControl";
import { Select } from "@/components/ui/Select";
import { TextArea } from "@/components/ui/TextArea";
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,
clampStep,
emptyWizardForm,
toCreateRequest,
type WizardForm,
} from "@/lib/wizard/wizard";
// 立项向导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);
// 换步后把焦点移到新步标题,方便键盘/读屏用户感知步骤变化;首帧不抢第 1 步输入框的 autoFocus。
const stepHeadingRef = useRef<HTMLParagraphElement>(null);
const isFirstRender = useRef(true);
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
stepHeadingRef.current?.focus();
}, [step]);
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-6 shadow-paper sm:p-8">
<div className="mb-6 flex items-center justify-between">
<h1 className="font-serif text-2xl text-ink"></h1>
<StepDots current={step} />
</div>
<div className="mb-4">
<Badge variant="accent">
{step}/{WIZARD_STEPS}
</Badge>
<p
ref={stepHeadingRef}
tabIndex={-1}
className="mt-2 text-sm text-ink-soft focus-visible:outline-none"
>
{STEP_TITLES[step - 1]}
</p>
</div>
<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 && <StepConfirm form={form} />}
</div>
<div className="mt-8 flex items-center justify-between">
<Button onClick={goBack} disabled={step === 1} variant="secondary">
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
</Button>
{isLast ? (
<Button
onClick={submit}
disabled={submitting || !canSubmit(form)}
variant="primary"
>
<CheckCircle2 className="h-4 w-4" aria-hidden="true" />
{submitting ? "创建中…" : "完成立项"}
</Button>
) : (
<Button
onClick={goNext}
disabled={!canAdvance(step, form)}
variant="primary"
>
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</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 ${
i + 1 <= current ? "bg-cinnabar" : "bg-line"
}`}
/>
))}
</div>
);
}
interface StepProps {
form: WizardForm;
update: (patch: Partial<WizardForm>) => void;
}
function StepBasics({ form, update }: StepProps) {
const titleMissing = form.title.trim().length === 0;
return (
<div>
<Field
label="书名"
required
help={titleMissing ? "请先填写书名才能继续" : undefined}
>
<TextInput
value={form.title}
onChange={(e) => update({ title: e.target.value })}
placeholder="例:逐光而行"
autoFocus
/>
</Field>
<Field label="题材">
<Select
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="h-20 resize-none"
value={form.logline}
onChange={(e) => update({ logline: e.target.value })}
placeholder="废柴少年觉醒禁忌血脉,在仙门倾轧中逆势封神。"
/>
</Field>
<Field label="故事结构">
<SegmentedControl
ariaLabel="故事结构"
className="flex-wrap"
value={form.structure}
onChange={(structure) => update({ structure })}
options={STRUCTURES.map((s) => ({ value: s, label: s }))}
/>
</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) => {
const selected = form.sellingPoints.includes(p);
return (
<button
type="button"
key={p}
aria-pressed={selected}
onClick={() => toggleSellingPoint(p)}
className={buttonClass({
variant: selected ? "primary" : "secondary",
size: "sm",
})}
>
{selected ? (
<Check className="h-3.5 w-3.5" aria-hidden="true" />
) : null}
{p}
</button>
);
})}
</div>
</fieldset>
</div>
);
}
function StepPremise({ form, update }: StepProps) {
return (
<div>
<Field label="立意premise">
<TextArea
className="h-20 resize-none"
value={form.premise}
onChange={(e) => update({ premise: e.target.value })}
placeholder="故事的核心命题与前提。"
/>
</Field>
<Field label="主题theme">
<TextInput
value={form.theme}
onChange={(e) => update({ theme: e.target.value })}
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>
);
}
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="h-28 resize-none"
value={form.protagonist}
onChange={(e) => update({ protagonist: e.target.value })}
placeholder="主角设定、金手指来源与限制……"
/>
</Field>
</div>
);
}
// 第 5 步:确认/概览——回显已填字段供复核后提交(取代旧的空「世界观后续里程碑」说明)。
function StepConfirm({ form }: { form: WizardForm }) {
const rows: Array<{ label: string; value: string }> = [
{ label: "书名", value: form.title.trim() || "未填写" },
{ label: "一句话故事", value: form.logline.trim() || "未填写" },
{ label: "题材", value: form.genre || "未选择" },
{ label: "故事结构", value: form.structure || "未选择" },
{
label: "核心卖点",
value: form.sellingPoints.length > 0 ? form.sellingPoints.join("、") : "未选择",
},
{ label: "基调", value: form.tone || "未选择" },
{ label: "叙事视角", value: form.narrativePov || "未选择" },
{ label: "结局取向", value: form.endingType || "未选择" },
];
return (
<div>
<p className="mb-3 text-sm text-ink-soft">
</p>
<dl className="divide-y divide-line rounded border border-line bg-bg">
{rows.map((row) => (
<div key={row.label} className="flex gap-4 px-4 py-2.5 text-sm">
<dt className="w-20 shrink-0 text-ink-soft">{row.label}</dt>
<dd className="min-w-0 flex-1 text-ink">{row.value}</dd>
</div>
))}
</dl>
</div>
);
}