向导阶段(project 未建)一键推演结构化书级蓝图预填立项向导。 - 新 SPEC project_plan_spec(analyst 档,纯预览 reads/writes=())+ schema ProjectPlanResult(书名候选 + 时空背景/叙事结构/故事核心/结局设计/基调, 全字段默认值守解析韧性)+ 注册 SCHEMA_CATALOG;计数三处 21→22 + regen golden。 - 端点 POST /skills/project-plan/generate(不带 project 前缀,绕开 toolbox 404): 请求体序列化向导草稿为 project_context,复用 build_brief_context + run_generator; 种子门控(缺 genre/logline→422);请求 schema 加 max_length 上界。 - run_generator/_build_request 的 project_id 放宽 uuid.UUID|None(向导无 project)。 - 前端 wizard.ts 加 mapPlanResultToWizardForm(错配字段折进 premise 不静默丢)+ applyPlanPatch(仅回填空字段、保护已编辑)+ useProjectPlan hook + PlanAssistant。
463 lines
15 KiB
TypeScript
463 lines
15 KiB
TypeScript
"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 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}
|
||
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 ${
|
||
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 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 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>
|
||
);
|
||
}
|