Files
writer-work-flow/apps/web/components/ProjectWizard.tsx
Yaojia Wang 67e30a6863 feat(ui): P3 页面级应用(作品库卡片/空态/加载/设置/次级页)
- 作品库卡片重设计:书脊封面字块 + 衬线书名 + 一句话故事(缺失给占位) +
  元信息行(题材徽章/StatusDot 待审/mono 时间),Card tone=card 浮起消塌陷
- EmptyState 中性化 + inline/card/bare 变体 + eyebrow(向后兼容)
- loading 骨架化(Skeleton 拼作品库结构,motion-safe)
- 设置页拆分 ProvidersSettings 489→198 + 5 个子组件,凭据点用 StatusDot
- 次级页(向导/模板/codex/大纲)排版字阶与圆角 token 收敛
2026-07-11 07:36:32 +02:00

463 lines
15 KiB
TypeScript
Raw Permalink 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, Sparkles } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
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 type { ProjectPlanView } from "@/lib/api/types";
import { useProjectPlan } from "@/lib/wizard/useProjectPlan";
import {
ENDING_TYPES,
GENRES,
NARRATIVE_POVS,
SELLING_POINT_PRESETS,
STEP_TITLES,
STRUCTURES,
TONES,
WIZARD_STEPS,
applyPlanPatch,
canAdvance,
canGeneratePlan,
canSubmit,
clampStep,
emptyWizardForm,
mapPlanResultToWizardForm,
toCreateRequest,
toPlanSeedRequest,
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],
}));
// AI 立项方案按字段回填:只填当前为空的字段(保护作者已编辑内容,不 bulk 覆盖)。
const applyPlan = (plan: ProjectPlanView): void => {
const patch = mapPlanResultToWizardForm(plan);
const filled = (Object.keys(patch) as Array<keyof WizardForm>).filter(
(k) => typeof form[k] === "string" && (form[k] as string).trim() === "",
);
setForm((prev) => applyPlanPatch(prev, patch));
toast(
filled.length > 0
? `已回填 ${filled.length} 个空字段(已编辑内容不覆盖)`
: "向导相关字段均已填写,未覆盖任何内容",
filled.length > 0 ? "success" : "info",
);
};
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-lg 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-display-sm 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}
onApplyPlan={applyPlan}
/>
)}
{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-full ${
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>
);
}
// AI 立项方案助手(灵感⑤):种子(题材+logline就绪后一键生成结构化方案 → 预览 → 按字段回填。
function PlanAssistant({
form,
onApplyPlan,
}: {
form: WizardForm;
onApplyPlan: (plan: ProjectPlanView) => void;
}) {
const { status, plan, generate } = useProjectPlan();
const ready = canGeneratePlan(form);
const generating = status === "generating";
return (
<div className="my-4 rounded-md border border-line bg-bg p-3">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="flex items-center gap-1.5 text-sm text-ink">
<Sparkles className="h-4 w-4 text-cinnabar" aria-hidden="true" />
AI
</p>
<p className="mt-0.5 text-xs text-ink-soft">
{ready
? "根据题材 + 一句话故事推演书级蓝图,按字段回填(不覆盖已填内容)。"
: "填好「题材」(上一步)与「一句话故事」后即可生成。"}
</p>
</div>
<Button
onClick={() => void generate(toPlanSeedRequest(form))}
disabled={!ready || generating}
variant="secondary"
>
{generating ? (
<ThinkingIndicator label="生成中" />
) : (
<>
<Sparkles className="h-4 w-4" aria-hidden="true" />
</>
)}
</Button>
</div>
{status === "done" && plan && (
<div className="mt-3 border-t border-line pt-3">
<dl className="space-y-1.5 text-sm">
{plan.title_candidates && plan.title_candidates.length > 0 && (
<PlanRow label="书名候选" value={plan.title_candidates.join("、")} />
)}
<PlanRow label="时空背景" value={plan.setting} />
<PlanRow label="叙事结构" value={plan.narrative_structure} />
<PlanRow label="故事核心" value={plan.story_core} />
<PlanRow label="结局设计" value={plan.ending_design} />
<PlanRow label="基调" value={plan.tone} />
</dl>
<div className="mt-3 flex justify-end">
<Button onClick={() => onApplyPlan(plan)} variant="primary">
<Check className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
</div>
)}
</div>
);
}
function PlanRow({ label, value }: { label: string; value: string }) {
if (!value.trim()) return null;
return (
<div className="flex gap-3">
<dt className="w-16 shrink-0 text-ink-soft">{label}</dt>
<dd className="min-w-0 flex-1 whitespace-pre-wrap text-ink">{value}</dd>
</div>
);
}
function StepStory({
form,
update,
toggleSellingPoint,
onApplyPlan,
}: StepProps & {
toggleSellingPoint: (p: string) => void;
onApplyPlan: (plan: ProjectPlanView) => void;
}) {
return (
<div>
<Field label="一句话故事logline">
<TextArea
className="h-20 resize-none"
value={form.logline}
onChange={(e) => update({ logline: e.target.value })}
placeholder="废柴少年觉醒禁忌血脉,在仙门倾轧中逆势封神。"
/>
</Field>
<PlanAssistant form={form} onApplyPlan={onApplyPlan} />
<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-md 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>
);
}