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,
pendingReviewCount,
} from "@/lib/projects/projects";
import { cardClass } from "@/lib/ui/variants";
import { cardClass, focusRing } from "@/lib/ui/variants";
interface ProjectCardProps {
project: ProjectResponse;
@@ -24,7 +24,7 @@ export function ProjectCard({ project, compact = false }: ProjectCardProps) {
<Link
href={`/projects/${project.id}/write`}
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">
@@ -57,7 +57,7 @@ export function ProjectCard({ project, compact = false }: ProjectCardProps) {
<Link
href={`/projects/${project.id}/write`}
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">
@@ -85,7 +85,7 @@ export function ProjectCard({ project, compact = false }: ProjectCardProps) {
<Clock3 className="h-3.5 w-3.5" aria-hidden="true" />
{updatedLabel}
</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" />
</span>

View File

@@ -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>
);
}

View File

@@ -2,11 +2,12 @@
import Link from "next/link";
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 { Button } from "@/components/ui/Button";
import { EmptyState } from "@/components/ui/EmptyState";
import { SegmentedControl } from "@/components/ui/SegmentedControl";
import { Select } from "@/components/ui/Select";
import { TextInput } from "@/components/ui/TextInput";
import type { ProjectResponse } from "@/lib/api/types";
@@ -16,7 +17,7 @@ import {
type ProjectSort,
type ProjectViewMode,
} from "@/lib/projects/projects";
import { buttonClass, cardClass, cn } from "@/lib/ui/variants";
import { buttonClass, cardClass } from "@/lib/ui/variants";
interface ProjectLibraryProps {
projects: ProjectResponse[];
@@ -84,29 +85,39 @@ export function ProjectLibrary({ projects }: ProjectLibraryProps) {
<option value="title"></option>
<option value="genre"></option>
</Select>
<div
className="inline-flex justify-self-start rounded border border-line bg-bg p-1 md:justify-self-end"
role="group"
aria-label="视图密度"
>
<ViewButton
label="卡片"
active={viewMode === "cards"}
onClick={() => setView("cards")}
>
<LayoutGrid className="h-4 w-4" aria-hidden="true" />
</ViewButton>
<ViewButton
label="紧凑"
active={viewMode === "compact"}
onClick={() => setView("compact")}
>
<List className="h-4 w-4" aria-hidden="true" />
</ViewButton>
</div>
<SegmentedControl
ariaLabel="视图密度"
className="justify-self-start md:justify-self-end"
value={viewMode}
onChange={setView}
options={[
{
value: "cards",
label: (
<>
<LayoutGrid className="h-4 w-4" aria-hidden="true" />
<span className="sr-only"></span>
</>
),
},
{
value: "compact",
label: (
<>
<List className="h-4 w-4" aria-hidden="true" />
<span className="sr-only"></span>
</>
),
},
]}
/>
</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" />
{visibleProjects.length} / {projects.length}
</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() {
return (
<Link

View File

@@ -5,11 +5,22 @@ import {
canSubmit,
clampStep,
emptyWizardForm,
STEP_TITLES,
toCreateRequest,
WIZARD_STEPS,
type WizardForm,
} 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", () => {
it("blocks advancing past step 1 without a title", () => {
expect(canAdvance(1, emptyWizardForm)).toBe(false);

View File

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