feat(web): 立项向导确认步/故事结构分段控件/书名必填提示 + 作品卡键盘焦点可见 + 作品库分段切换

This commit is contained in:
Yaojia Wang
2026-06-30 08:56:22 +02:00
parent a22a16c9c4
commit e82aff7067
5 changed files with 138 additions and 103 deletions

View File

@@ -7,7 +7,7 @@ import {
formatProjectUpdatedAt, formatProjectUpdatedAt,
pendingReviewCount, pendingReviewCount,
} from "@/lib/projects/projects"; } from "@/lib/projects/projects";
import { cardClass } from "@/lib/ui/variants"; import { cardClass, focusRing } from "@/lib/ui/variants";
interface ProjectCardProps { interface ProjectCardProps {
project: ProjectResponse; project: ProjectResponse;
@@ -24,7 +24,7 @@ export function ProjectCard({ project, compact = false }: ProjectCardProps) {
<Link <Link
href={`/projects/${project.id}/write`} href={`/projects/${project.id}/write`}
className={cardClass( className={cardClass(
"group flex items-center gap-3 p-3 transition-colors hover:border-cinnabar", `group flex items-center gap-3 p-3 transition-colors hover:border-cinnabar ${focusRing}`,
)} )}
> >
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded bg-[var(--color-cinnabar-wash)] text-cinnabar"> <span className="flex h-9 w-9 shrink-0 items-center justify-center rounded bg-[var(--color-cinnabar-wash)] text-cinnabar">
@@ -57,7 +57,7 @@ export function ProjectCard({ project, compact = false }: ProjectCardProps) {
<Link <Link
href={`/projects/${project.id}/write`} href={`/projects/${project.id}/write`}
className={cardClass( className={cardClass(
"group flex h-full min-h-[176px] flex-col p-6 transition-colors hover:border-cinnabar", `group flex h-full min-h-[176px] flex-col p-6 transition-colors hover:border-cinnabar ${focusRing}`,
)} )}
> >
<div className="mb-4 flex items-start justify-between gap-3"> <div className="mb-4 flex items-start justify-between gap-3">
@@ -85,7 +85,7 @@ export function ProjectCard({ project, compact = false }: ProjectCardProps) {
<Clock3 className="h-3.5 w-3.5" aria-hidden="true" /> <Clock3 className="h-3.5 w-3.5" aria-hidden="true" />
{updatedLabel} {updatedLabel}
</span> </span>
<span className="mt-auto flex items-center gap-1 pt-4 text-xs text-cinnabar opacity-0 transition-opacity group-hover:opacity-100"> <span className="mt-auto flex items-center gap-1 pt-4 text-xs text-cinnabar opacity-0 motion-safe:transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100">
<ChevronRight className="h-3.5 w-3.5" aria-hidden="true" /> <ChevronRight className="h-3.5 w-3.5" aria-hidden="true" />
</span> </span>

View File

@@ -1,13 +1,14 @@
"use client"; "use client";
import { ArrowLeft, ArrowRight, CheckCircle2 } from "lucide-react"; import { ArrowLeft, ArrowRight, Check, CheckCircle2 } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useToast } from "@/components/Toast"; import { useToast } from "@/components/Toast";
import { Badge } from "@/components/ui/Badge"; import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Field } from "@/components/ui/Field"; import { Field } from "@/components/ui/Field";
import { SegmentedControl } from "@/components/ui/SegmentedControl";
import { Select } from "@/components/ui/Select"; import { Select } from "@/components/ui/Select";
import { TextArea } from "@/components/ui/TextArea"; import { TextArea } from "@/components/ui/TextArea";
import { TextInput } from "@/components/ui/TextInput"; import { TextInput } from "@/components/ui/TextInput";
@@ -16,6 +17,7 @@ import { buttonClass } from "@/lib/ui/variants";
import { import {
GENRES, GENRES,
SELLING_POINT_PRESETS, SELLING_POINT_PRESETS,
STEP_TITLES,
STRUCTURES, STRUCTURES,
WIZARD_STEPS, WIZARD_STEPS,
canAdvance, canAdvance,
@@ -26,14 +28,6 @@ import {
type WizardForm, type WizardForm,
} from "@/lib/wizard/wizard"; } from "@/lib/wizard/wizard";
const STEP_TITLES = [
"书名 + 题材",
"一句话故事 + 卖点 + 结构",
"立意 / 总纲",
"主角 / 金手指",
"基础世界观",
];
// 立项向导UX §6.2。Client Component收集字段 → POST /projects → 进工作台。 // 立项向导UX §6.2。Client Component收集字段 → POST /projects → 进工作台。
export function ProjectWizard() { export function ProjectWizard() {
const router = useRouter(); const router = useRouter();
@@ -42,6 +36,17 @@ export function ProjectWizard() {
const [form, setForm] = useState<WizardForm>(emptyWizardForm); const [form, setForm] = useState<WizardForm>(emptyWizardForm);
const [submitting, setSubmitting] = useState(false); 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 => const update = (patch: Partial<WizardForm>): void =>
setForm((prev) => ({ ...prev, ...patch })); setForm((prev) => ({ ...prev, ...patch }));
@@ -86,7 +91,13 @@ export function ProjectWizard() {
<Badge variant="accent"> <Badge variant="accent">
{step}/{WIZARD_STEPS} {step}/{WIZARD_STEPS}
</Badge> </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>
<div className="min-h-[220px]"> <div className="min-h-[220px]">
@@ -100,7 +111,7 @@ export function ProjectWizard() {
)} )}
{step === 3 && <StepPremise form={form} update={update} />} {step === 3 && <StepPremise form={form} update={update} />}
{step === 4 && <StepProtagonist form={form} update={update} />} {step === 4 && <StepProtagonist form={form} update={update} />}
{step === 5 && <StepWorld />} {step === 5 && <StepConfirm form={form} />}
</div> </div>
<div className="mt-8 flex items-center justify-between"> <div className="mt-8 flex items-center justify-between">
@@ -153,9 +164,14 @@ interface StepProps {
} }
function StepBasics({ form, update }: StepProps) { function StepBasics({ form, update }: StepProps) {
const titleMissing = form.title.trim().length === 0;
return ( return (
<div> <div>
<Field label="书名(必填)"> <Field
label="书名"
required
help={titleMissing ? "请先填写书名才能继续" : undefined}
>
<TextInput <TextInput
value={form.title} value={form.title}
onChange={(e) => update({ title: e.target.value })} onChange={(e) => update({ title: e.target.value })}
@@ -196,41 +212,37 @@ function StepStory({
/> />
</Field> </Field>
<Field label="故事结构"> <Field label="故事结构">
<div className="flex gap-2"> <SegmentedControl
{STRUCTURES.map((s) => ( ariaLabel="故事结构"
<button className="flex-wrap"
type="button" value={form.structure}
key={s} onChange={(structure) => update({ structure })}
onClick={() => update({ structure: s })} options={STRUCTURES.map((s) => ({ value: s, label: s }))}
className={buttonClass({ />
variant: form.structure === s ? "outline" : "secondary",
size: "sm",
})}
>
{s}
</button>
))}
</div>
</Field> </Field>
<fieldset> <fieldset>
<legend className="mb-1 block text-sm text-ink-soft"></legend> <legend className="mb-1 block text-sm text-ink-soft"></legend>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{SELLING_POINT_PRESETS.map((p) => ( {SELLING_POINT_PRESETS.map((p) => {
<button const selected = form.sellingPoints.includes(p);
type="button" return (
key={p} <button
aria-pressed={form.sellingPoints.includes(p)} type="button"
onClick={() => toggleSellingPoint(p)} key={p}
className={buttonClass({ aria-pressed={selected}
variant: form.sellingPoints.includes(p) onClick={() => toggleSellingPoint(p)}
? "outline" className={buttonClass({
: "secondary", variant: selected ? "primary" : "secondary",
size: "sm", size: "sm",
})} })}
> >
{p} {selected ? (
</button> <Check className="h-3.5 w-3.5" aria-hidden="true" />
))} ) : null}
{p}
</button>
);
})}
</div> </div>
</fieldset> </fieldset>
</div> </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 ( 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> </div>
); );
} }

View File

@@ -2,11 +2,12 @@
import Link from "next/link"; import Link from "next/link";
import { Clock3, LayoutGrid, List, Plus, Search } from "lucide-react"; import { Clock3, LayoutGrid, List, Plus, Search } from "lucide-react";
import { useEffect, useMemo, useState, type ReactNode } from "react"; import { useEffect, useMemo, useState } from "react";
import { ProjectCard } from "@/components/ProjectCard"; import { ProjectCard } from "@/components/ProjectCard";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { EmptyState } from "@/components/ui/EmptyState"; import { EmptyState } from "@/components/ui/EmptyState";
import { SegmentedControl } from "@/components/ui/SegmentedControl";
import { Select } from "@/components/ui/Select"; import { Select } from "@/components/ui/Select";
import { TextInput } from "@/components/ui/TextInput"; import { TextInput } from "@/components/ui/TextInput";
import type { ProjectResponse } from "@/lib/api/types"; import type { ProjectResponse } from "@/lib/api/types";
@@ -16,7 +17,7 @@ import {
type ProjectSort, type ProjectSort,
type ProjectViewMode, type ProjectViewMode,
} from "@/lib/projects/projects"; } from "@/lib/projects/projects";
import { buttonClass, cardClass, cn } from "@/lib/ui/variants"; import { buttonClass, cardClass } from "@/lib/ui/variants";
interface ProjectLibraryProps { interface ProjectLibraryProps {
projects: ProjectResponse[]; projects: ProjectResponse[];
@@ -84,29 +85,39 @@ export function ProjectLibrary({ projects }: ProjectLibraryProps) {
<option value="title"></option> <option value="title"></option>
<option value="genre"></option> <option value="genre"></option>
</Select> </Select>
<div <SegmentedControl
className="inline-flex justify-self-start rounded border border-line bg-bg p-1 md:justify-self-end" ariaLabel="视图密度"
role="group" className="justify-self-start md:justify-self-end"
aria-label="视图密度" value={viewMode}
> onChange={setView}
<ViewButton options={[
label="卡片" {
active={viewMode === "cards"} value: "cards",
onClick={() => setView("cards")} label: (
> <>
<LayoutGrid className="h-4 w-4" aria-hidden="true" /> <LayoutGrid className="h-4 w-4" aria-hidden="true" />
</ViewButton> <span className="sr-only"></span>
<ViewButton </>
label="紧凑" ),
active={viewMode === "compact"} },
onClick={() => setView("compact")} {
> value: "compact",
<List className="h-4 w-4" aria-hidden="true" /> label: (
</ViewButton> <>
</div> <List className="h-4 w-4" aria-hidden="true" />
<span className="sr-only"></span>
</>
),
},
]}
/>
</div> </div>
<p className="text-xs text-ink-soft"> <p
className="text-xs text-ink-soft"
role="status"
aria-live="polite"
>
<Clock3 className="mr-1 inline h-3.5 w-3.5" aria-hidden="true" /> <Clock3 className="mr-1 inline h-3.5 w-3.5" aria-hidden="true" />
{visibleProjects.length} / {projects.length} {visibleProjects.length} / {projects.length}
</p> </p>
@@ -174,35 +185,6 @@ export function ProjectLibrary({ projects }: ProjectLibraryProps) {
); );
} }
function ViewButton({
active,
label,
children,
onClick,
}: {
active: boolean;
label: string;
children: ReactNode;
onClick: () => void;
}) {
return (
<button
type="button"
aria-label={label}
aria-pressed={active}
onClick={onClick}
className={cn(
"rounded px-2 py-1.5 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/35",
active
? "bg-panel text-cinnabar shadow-paper"
: "text-ink-soft hover:text-cinnabar",
)}
>
{children}
</button>
);
}
function NewProjectCard() { function NewProjectCard() {
return ( return (
<Link <Link

View File

@@ -5,11 +5,22 @@ import {
canSubmit, canSubmit,
clampStep, clampStep,
emptyWizardForm, emptyWizardForm,
STEP_TITLES,
toCreateRequest, toCreateRequest,
WIZARD_STEPS, WIZARD_STEPS,
type WizardForm, type WizardForm,
} from "./wizard"; } from "./wizard";
describe("wizard step metadata", () => {
it("provides one title per step", () => {
expect(STEP_TITLES).toHaveLength(WIZARD_STEPS);
});
it("ends on a confirmation step rather than an empty world step", () => {
expect(STEP_TITLES[WIZARD_STEPS - 1]).toBe("确认与提交");
});
});
describe("wizard step gating", () => { describe("wizard step gating", () => {
it("blocks advancing past step 1 without a title", () => { it("blocks advancing past step 1 without a title", () => {
expect(canAdvance(1, emptyWizardForm)).toBe(false); expect(canAdvance(1, emptyWizardForm)).toBe(false);

View File

@@ -14,6 +14,15 @@ export interface WizardForm {
export const WIZARD_STEPS = 5; export const WIZARD_STEPS = 5;
// 各步标题(与 WIZARD_STEPS 一一对应)。第 5 步为「确认/概览」:回显已填字段供复核后提交。
export const STEP_TITLES = [
"书名 + 题材",
"一句话故事 + 卖点 + 结构",
"立意 / 总纲",
"主角 / 金手指",
"确认与提交",
] as const;
export const emptyWizardForm: WizardForm = { export const emptyWizardForm: WizardForm = {
title: "", title: "",
genre: "", genre: "",