Files
writer-work-flow/apps/web/components/projects/ProjectCard.tsx
Yaojia Wang 67e30a6863 feat(ui): P3 页面级应用(作品库卡片/空态/加载/设置/次级页)
- 作品库卡片重设计:书脊封面字块 + 衬线书名 + 一句话故事(缺失给占位) +
  元信息行(题材徽章/StatusDot 待审/mono 时间),Card tone=card 浮起消塌陷
- EmptyState 中性化 + inline/card/bare 变体 + eyebrow(向后兼容)
- loading 骨架化(Skeleton 拼作品库结构,motion-safe)
- 设置页拆分 ProvidersSettings 489→198 + 5 个子组件,凭据点用 StatusDot
- 次级页(向导/模板/codex/大纲)排版字阶与圆角 token 收敛
2026-07-11 07:36:32 +02:00

165 lines
5.6 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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