feat(ui): P3 页面级应用(作品库卡片/空态/加载/设置/次级页)
- 作品库卡片重设计:书脊封面字块 + 衬线书名 + 一句话故事(缺失给占位) + 元信息行(题材徽章/StatusDot 待审/mono 时间),Card tone=card 浮起消塌陷 - EmptyState 中性化 + inline/card/bare 变体 + eyebrow(向后兼容) - loading 骨架化(Skeleton 拼作品库结构,motion-safe) - 设置页拆分 ProvidersSettings 489→198 + 5 个子组件,凭据点用 StatusDot - 次级页(向导/模板/codex/大纲)排版字阶与圆角 token 收敛
This commit is contained in:
164
apps/web/components/projects/ProjectCard.tsx
Normal file
164
apps/web/components/projects/ProjectCard.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import Link from "next/link";
|
||||
import { ChevronRight, Clock3 } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { StatusDot } from "@/components/ui/StatusDot";
|
||||
import type { ProjectResponse } from "@/lib/api/types";
|
||||
import {
|
||||
formatProjectUpdatedAt,
|
||||
pendingReviewCount,
|
||||
} from "@/lib/projects/projects";
|
||||
import { cardClass, cn, focusRing } from "@/lib/ui/variants";
|
||||
|
||||
interface ProjectCardProps {
|
||||
project: ProjectResponse;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
// 缺一句话故事时的中性占位——避免卡片正文塌陷成空白(P3-2)。
|
||||
const LOGLINE_PLACEHOLDER = "还没有一句话故事,进入写作台补上一句梗概。";
|
||||
|
||||
// 书脊/封面暖色板:全部取自现有语义 token(朱砂/琥珀),仅调透明度,无硬编码 hex。
|
||||
const COVER_TINTS = [
|
||||
"bg-[var(--color-cinnabar-wash)] text-cinnabar",
|
||||
"bg-cinnabar/10 text-cinnabar",
|
||||
"bg-cinnabar/[0.16] text-cinnabar",
|
||||
"bg-overdue/10 text-overdue",
|
||||
"bg-overdue/[0.18] text-overdue",
|
||||
"bg-surface-strong text-body-strong",
|
||||
] as const;
|
||||
|
||||
// 由 id/title 确定性取暖色底色块(djb2 变体哈希,同一本书永远同一色)。
|
||||
function coverTint(seed: string): string {
|
||||
let hash = 0;
|
||||
for (const ch of seed) hash = (hash * 31 + (ch.codePointAt(0) ?? 0)) >>> 0;
|
||||
return COVER_TINTS[hash % COVER_TINTS.length] ?? COVER_TINTS[0];
|
||||
}
|
||||
|
||||
// 取书名首个字符作封面字(Array.from 保证 CJK/emoji 不被截断)。
|
||||
function coverGlyph(title: string): string {
|
||||
return Array.from(title.trim())[0] ?? "书";
|
||||
}
|
||||
|
||||
interface CoverProps {
|
||||
project: ProjectResponse;
|
||||
size: "sm" | "md";
|
||||
}
|
||||
|
||||
// 书脊色块:填补此前的空白,给每本书一个稳定的视觉锚点。
|
||||
function Cover({ project, size }: CoverProps) {
|
||||
const tint = coverTint(project.id || project.title || "");
|
||||
const box = size === "md" ? "h-12 w-12 text-title-lg" : "h-10 w-10 text-title-md";
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-center rounded-md font-serif",
|
||||
box,
|
||||
tint,
|
||||
)}
|
||||
>
|
||||
{coverGlyph(project.title || "")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// 作品卡(UX §6.1):书脊色块 + 书名衬线 + 一句话故事 + 元信息行。
|
||||
// M1 无字数/章数端点 → 不展示该统计(避免编造 API)。
|
||||
export function ProjectCard({ project, compact = false }: ProjectCardProps) {
|
||||
const pendingCount = pendingReviewCount(project);
|
||||
const updatedLabel = formatProjectUpdatedAt(project.updated_at);
|
||||
const title = project.title || "未命名作品";
|
||||
const logline = project.logline?.trim();
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<Link
|
||||
href={`/projects/${project.id}/write`}
|
||||
className={cardClass({
|
||||
tone: "card",
|
||||
interactive: true,
|
||||
className: cn("group flex items-center gap-4 p-4", focusRing),
|
||||
})}
|
||||
>
|
||||
<Cover project={project} size="sm" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-serif text-title-md text-ink">
|
||||
〈{title}〉
|
||||
</span>
|
||||
<span className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-caption">
|
||||
<GenreBadge genre={project.genre} />
|
||||
<PendingNote count={pendingCount} />
|
||||
<span className="min-w-0 flex-1 truncate text-muted-soft">
|
||||
{logline ?? LOGLINE_PLACEHOLDER}
|
||||
</span>
|
||||
</span>
|
||||
<span className="mt-1 flex items-center gap-1 font-mono text-2xs text-muted-soft">
|
||||
<Clock3 className="h-3 w-3" aria-hidden="true" />
|
||||
最近编辑 {updatedLabel}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRight
|
||||
className="h-4 w-4 shrink-0 text-muted-soft transition-colors duration-fast ease-standard group-hover:text-cinnabar"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/projects/${project.id}/write`}
|
||||
className={cardClass({
|
||||
tone: "card",
|
||||
interactive: true,
|
||||
className: cn("group flex h-full flex-col gap-4 p-5", focusRing),
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Cover project={project} size="md" />
|
||||
<h2 className="min-w-0 flex-1 truncate font-serif text-title-lg text-ink">
|
||||
〈{title}〉
|
||||
</h2>
|
||||
<ChevronRight
|
||||
className="h-4 w-4 shrink-0 text-muted-soft transition-colors duration-fast ease-standard group-hover:text-cinnabar"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
"line-clamp-2 min-h-[3rem] text-body leading-6",
|
||||
logline ? "text-body" : "text-muted-soft",
|
||||
)}
|
||||
>
|
||||
{logline ?? LOGLINE_PLACEHOLDER}
|
||||
</p>
|
||||
<div className="mt-auto flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
<GenreBadge genre={project.genre} />
|
||||
<PendingNote count={pendingCount} />
|
||||
<span className="ml-auto flex items-center gap-1 font-mono text-caption text-muted-soft">
|
||||
<Clock3 className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{updatedLabel}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// 题材徽章:有值用中性徽章,缺失给稳定占位而非空白。
|
||||
function GenreBadge({ genre }: { genre?: string | null }) {
|
||||
const value = genre?.trim();
|
||||
if (value) return <Badge>{value}</Badge>;
|
||||
return <Badge className="border-dashed text-muted-soft">未定题材</Badge>;
|
||||
}
|
||||
|
||||
// 待审提示:状态点 + 文字(不单靠颜色传达状态)。
|
||||
function PendingNote({ count }: { count: number }) {
|
||||
if (count <= 0) return null;
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-caption text-ink-soft">
|
||||
<StatusDot tone="warning" label={`${count} 章待审`} />
|
||||
{count} 待审
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import Link from "next/link";
|
||||
import { Clock3, LayoutGrid, List, Plus, Search } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { ProjectCard } from "@/components/ProjectCard";
|
||||
import { ProjectCard } from "@/components/projects/ProjectCard";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { SegmentedControl } from "@/components/ui/SegmentedControl";
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
type ProjectSort,
|
||||
type ProjectViewMode,
|
||||
} from "@/lib/projects/projects";
|
||||
import { buttonClass, cardClass } from "@/lib/ui/variants";
|
||||
import { buttonClass, cardClass, cn, focusRing } from "@/lib/ui/variants";
|
||||
|
||||
interface ProjectLibraryProps {
|
||||
projects: ProjectResponse[];
|
||||
@@ -189,16 +189,21 @@ function NewProjectCard() {
|
||||
return (
|
||||
<Link
|
||||
href="/projects/new"
|
||||
className={buttonClass({
|
||||
variant: "outline",
|
||||
className: "group h-full min-h-[176px] flex-col border-dashed text-center",
|
||||
className={cardClass({
|
||||
tone: "soft",
|
||||
flat: true,
|
||||
interactive: true,
|
||||
className: cn(
|
||||
"group flex h-full min-h-[184px] flex-col items-center justify-center gap-3 border-dashed p-5 text-center",
|
||||
focusRing,
|
||||
),
|
||||
})}
|
||||
>
|
||||
<span className="mb-3 flex h-10 w-10 items-center justify-center rounded bg-[var(--color-cinnabar-wash)] text-cinnabar transition-colors group-hover:bg-cinnabar group-hover:text-panel">
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-md bg-[var(--color-cinnabar-wash)] text-cinnabar transition-colors duration-fast ease-standard group-hover:bg-cinnabar group-hover:text-panel">
|
||||
<Plus className="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="font-serif text-xl text-cinnabar">新建</span>
|
||||
<span className="mt-2 text-sm text-ink-soft">从一句灵感开始一本书</span>
|
||||
<span className="font-serif text-title-md text-cinnabar">新建作品</span>
|
||||
<span className="text-caption text-muted-soft">从一句灵感开始一本书</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user