Files
writer-work-flow/apps/web/components/ui/EmptyState.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

118 lines
2.9 KiB
TypeScript

import type { ReactNode } from "react";
import type { LucideIcon } from "lucide-react";
import { Card } from "@/components/ui/Card";
import { Eyebrow } from "@/components/ui/Eyebrow";
import { cn } from "@/lib/ui/variants";
// 空态外框形态:
// - "inline"(默认):虚线中性框,用于列表/面板内联占位(向后兼容旧默认外观)。
// - "card":包裹在 Card(tone=soft) 内,用于独立区块的引导卡片。
// - "bare":无外框,仅内容,交由调用方自行放置(如已在卡片里)。
type EmptyStateVariant = "inline" | "card" | "bare";
type EmptyStateSize = "sm" | "md";
interface EmptyStateProps {
icon: LucideIcon;
title: string;
description: string;
action?: ReactNode;
/** 可选眉题,置于标题上方,用于分类/上下文提示。 */
eyebrow?: string;
/** 外框形态,默认 "inline"。 */
variant?: EmptyStateVariant;
/** 留白密度,默认 "md"。 */
size?: EmptyStateSize;
className?: string;
}
const SIZE_PAD: Record<EmptyStateSize, string> = {
sm: "px-5 py-8",
md: "px-6 py-10",
};
const SIZE_CHIP: Record<EmptyStateSize, string> = {
sm: "h-10 w-10",
md: "h-11 w-11",
};
// 内容主体:图标片 + 眉题 + 标题 + 描述 + 动作。中性、编辑部化,留白克制。
function EmptyStateBody({
icon: Icon,
title,
description,
action,
eyebrow,
size,
}: Pick<
EmptyStateProps,
"icon" | "title" | "description" | "action" | "eyebrow"
> & {
size: EmptyStateSize;
}) {
return (
<div className="text-center">
<div
className={cn(
"mx-auto mb-3 flex items-center justify-center rounded-md bg-surface-soft text-muted-soft",
SIZE_CHIP[size],
)}
>
<Icon className="h-5 w-5" aria-hidden="true" />
</div>
{eyebrow ? <Eyebrow className="mb-2">{eyebrow}</Eyebrow> : null}
<h2 className="font-serif text-title-md text-ink">{title}</h2>
<p className="mx-auto mt-2 max-w-sm text-caption leading-6 text-ink-soft">
{description}
</p>
{action ? <div className="mt-4">{action}</div> : null}
</div>
);
}
export function EmptyState({
icon,
title,
description,
action,
eyebrow,
variant = "inline",
size = "md",
className,
}: EmptyStateProps) {
const body = (
<EmptyStateBody
icon={icon}
title={title}
description={description}
action={action}
eyebrow={eyebrow}
size={size}
/>
);
if (variant === "bare") {
return <div className={cn(SIZE_PAD[size], className)}>{body}</div>;
}
if (variant === "card") {
return (
<Card tone="soft" flat className={cn(SIZE_PAD[size], className)}>
{body}
</Card>
);
}
return (
<div
className={cn(
"rounded-lg border border-dashed border-line bg-panel/60",
SIZE_PAD[size],
className,
)}
>
{body}
</div>
);
}