feat(web): 立项向导确认步/故事结构分段控件/书名必填提示 + 作品卡键盘焦点可见 + 作品库分段切换
This commit is contained in:
@@ -1,13 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeft, ArrowRight, CheckCircle2 } from "lucide-react";
|
||||
import { ArrowLeft, ArrowRight, Check, CheckCircle2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
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";
|
||||
@@ -16,6 +17,7 @@ import { buttonClass } from "@/lib/ui/variants";
|
||||
import {
|
||||
GENRES,
|
||||
SELLING_POINT_PRESETS,
|
||||
STEP_TITLES,
|
||||
STRUCTURES,
|
||||
WIZARD_STEPS,
|
||||
canAdvance,
|
||||
@@ -26,14 +28,6 @@ import {
|
||||
type WizardForm,
|
||||
} from "@/lib/wizard/wizard";
|
||||
|
||||
const STEP_TITLES = [
|
||||
"书名 + 题材",
|
||||
"一句话故事 + 卖点 + 结构",
|
||||
"立意 / 总纲",
|
||||
"主角 / 金手指",
|
||||
"基础世界观",
|
||||
];
|
||||
|
||||
// 立项向导(UX §6.2)。Client Component:收集字段 → POST /projects → 进工作台。
|
||||
export function ProjectWizard() {
|
||||
const router = useRouter();
|
||||
@@ -42,6 +36,17 @@ export function ProjectWizard() {
|
||||
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 }));
|
||||
|
||||
@@ -86,7 +91,13 @@ export function ProjectWizard() {
|
||||
<Badge variant="accent">
|
||||
步骤 {step}/{WIZARD_STEPS}
|
||||
</Badge>
|
||||
<p className="mt-2 text-sm text-ink-soft">{STEP_TITLES[step - 1]}</p>
|
||||
<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]">
|
||||
@@ -100,7 +111,7 @@ export function ProjectWizard() {
|
||||
)}
|
||||
{step === 3 && <StepPremise form={form} update={update} />}
|
||||
{step === 4 && <StepProtagonist form={form} update={update} />}
|
||||
{step === 5 && <StepWorld />}
|
||||
{step === 5 && <StepConfirm form={form} />}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex items-center justify-between">
|
||||
@@ -153,9 +164,14 @@ interface StepProps {
|
||||
}
|
||||
|
||||
function StepBasics({ form, update }: StepProps) {
|
||||
const titleMissing = form.title.trim().length === 0;
|
||||
return (
|
||||
<div>
|
||||
<Field label="书名(必填)">
|
||||
<Field
|
||||
label="书名"
|
||||
required
|
||||
help={titleMissing ? "请先填写书名才能继续" : undefined}
|
||||
>
|
||||
<TextInput
|
||||
value={form.title}
|
||||
onChange={(e) => update({ title: e.target.value })}
|
||||
@@ -196,41 +212,37 @@ function StepStory({
|
||||
/>
|
||||
</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>
|
||||
<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) => (
|
||||
<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>
|
||||
))}
|
||||
{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>
|
||||
@@ -279,10 +291,31 @@ function StepProtagonist({ form, update }: StepProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function StepWorld() {
|
||||
// 第 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("、") : "未选择",
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div className="rounded border border-dashed border-line bg-bg p-6 text-sm leading-6 text-ink-soft">
|
||||
基础世界观将在「设定库」里建模(后续里程碑)。现在可直接完成立项,进入工作台开始写第一章。
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user