289 lines
8.4 KiB
TypeScript
289 lines
8.4 KiB
TypeScript
"use client";
|
||
|
||
import { ArrowLeft, ArrowRight, CheckCircle2 } from "lucide-react";
|
||
import { useRouter } from "next/navigation";
|
||
import { 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 { 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 {
|
||
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-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 className="mt-2 text-sm text-ink-soft">{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 && <StepWorld />}
|
||
</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) {
|
||
return (
|
||
<div>
|
||
<Field label="书名(必填)">
|
||
<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="故事结构">
|
||
<div className="flex gap-2">
|
||
{STRUCTURES.map((s) => (
|
||
<button
|
||
type="button"
|
||
key={s}
|
||
onClick={() => update({ structure: s })}
|
||
className={buttonClass({
|
||
variant: form.structure === s ? "outline" : "secondary",
|
||
size: "sm",
|
||
})}
|
||
>
|
||
{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={buttonClass({
|
||
variant: form.sellingPoints.includes(p)
|
||
? "outline"
|
||
: "secondary",
|
||
size: "sm",
|
||
})}
|
||
>
|
||
{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>
|
||
</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>
|
||
);
|
||
}
|
||
|
||
function StepWorld() {
|
||
return (
|
||
<div className="rounded border border-dashed border-line bg-bg p-6 text-sm leading-6 text-ink-soft">
|
||
基础世界观将在「设定库」里建模(后续里程碑)。现在可直接完成立项,进入工作台开始写第一章。
|
||
</div>
|
||
);
|
||
}
|