- 作品库卡片重设计:书脊封面字块 + 衬线书名 + 一句话故事(缺失给占位) + 元信息行(题材徽章/StatusDot 待审/mono 时间),Card tone=card 浮起消塌陷 - EmptyState 中性化 + inline/card/bare 变体 + eyebrow(向后兼容) - loading 骨架化(Skeleton 拼作品库结构,motion-safe) - 设置页拆分 ProvidersSettings 489→198 + 5 个子组件,凭据点用 StatusDot - 次级页(向导/模板/codex/大纲)排版字阶与圆角 token 收敛
118 lines
2.9 KiB
TypeScript
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>
|
|
);
|
|
}
|