feat(web): 立项向导确认步/故事结构分段控件/书名必填提示 + 作品卡键盘焦点可见 + 作品库分段切换
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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: "",
|
||||||
|
|||||||
Reference in New Issue
Block a user