Files
Genarrative/src/components/shared-components/SharedComponentsShowcasePage.tsx
T
kdletters c5200c7d45
Project CI / Backend tests (push) Successful in 8m31s
Project CI / Repository checks (push) Successful in 14m14s
Project CI / Frontend tests (push) Successful in 13m2s
Project CI / Native shell tests (push) Successful in 21m30s
补齐共享 UI 组件库与展示页交互 (#202)
## 变更内容

- 新增基于 shadcn open-code 模式的共享 UI canonical 组件、样式与导出。
- 新增 `/components` 共享组件展示页,并覆盖平台组件、Token、状态和移动端布局。
- 补齐平台展示页的筛选、排序、上传预览、标签、开关和异步状态交互。
- 修复排序导致预览主题变化、筛选弹窗误关闭和入口状态不更新的问题。
- 同步网站与客户端构建别名、依赖、路由测试和项目文档。

## 验证

- `npm run test -- src/components/shared-components/SharedComponentsShowcasePage.test.tsx`
- `npm run typecheck`
- `npm run check:encoding`
- `npm run build:raw`
- `git diff --check`
- pre-push 门禁通过

Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/202
Co-authored-by: kdletters <kdletters@qq.com>
Co-committed-by: kdletters <kdletters@qq.com>
2026-08-31 14:10:44 +08:00

810 lines
28 KiB
TypeScript
Raw 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 '@genarrative/shared/styles.css';
import './sharedComponentsShowcase.css';
import {
Badge,
Button,
Checkbox,
Divider,
EmptyState,
IconButton,
Label,
Modal,
PlatformAsyncStatePanel,
PlatformBackActionButton,
PlatformEmptyState,
PlatformFilterToolbar,
PlatformInfoBlock,
PlatformNavigableListItem,
PlatformRuntimeStatusToast,
PlatformStatGrid,
PlatformToggleRow,
ProgressBar,
SegmentedTabs,
SelectField,
Skeleton,
Spinner,
Status,
Subpanel,
Switch,
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
TextField,
} from '@genarrative/shared/components';
import { Check, CircleAlert, Info, Plus, Sparkles } from 'lucide-react';
import { type ReactNode, useEffect, useState } from 'react';
import { PlatformAssetPickerGrid } from '../common/PlatformAssetPickerCard';
import { PlatformMediaFrame } from '../common/PlatformMediaFrame';
import { PlatformMediaTileGrid } from '../common/PlatformMediaTileGrid';
import { PlatformTagEditor } from '../common/PlatformTagEditor';
import { PlatformUploadPreviewCard } from '../common/PlatformUploadPreviewCard';
import { PlatformUploadTile } from '../common/PlatformUploadTile';
const tabs = [
{ id: 'overview', label: '概览' },
{ id: 'platform', label: '平台组件' },
{ id: 'tokens', label: 'Token' },
{ id: 'states', label: '状态' },
] as const;
const demoAssetImages = [
['珊瑚岛', '#ef9b72', '#ffd7b8', '草稿', 3],
['月光林', '#8094cf', '#dce3ff', '已发布', 4],
['萤火谷', '#78b899', '#d4f3d5', '草稿', 1],
['赤岩城', '#c87967', '#f8d4c9', '已发布', 2],
] as const;
type DemoAsset = {
id: string;
label: string;
image: string;
status: '草稿' | '已发布';
recentOrder: number;
};
function createDemoImage(label: string, from: string, to: string) {
return `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 220"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop stop-color="${from}"/><stop offset="1" stop-color="${to}"/></linearGradient></defs><rect width="320" height="220" rx="28" fill="url(#g)"/><circle cx="252" cy="54" r="30" fill="white" fill-opacity=".4"/><path d="M0 180 Q80 115 160 175 T320 150 V220 H0Z" fill="white" fill-opacity=".22"/><text x="24" y="196" fill="white" font-family="sans-serif" font-size="24" font-weight="700">${label}</text></svg>`)}`;
}
const demoAssets: DemoAsset[] = demoAssetImages.map(
([label, from, to, status, recentOrder], index) => ({
id: `demo-asset-${index}`,
label,
image: createDemoImage(label, from, to),
status,
recentOrder,
}),
);
export default function SharedComponentsShowcasePage() {
const [activeTab, setActiveTab] =
useState<(typeof tabs)[number]['id']>('overview');
const [enabled, setEnabled] = useState(true);
const [checkboxEnabled, setCheckboxEnabled] = useState(true);
const [progress, setProgress] = useState(64);
const [modalOpen, setModalOpen] = useState(false);
const [feedback, setFeedback] = useState<string | null>(null);
const [filterTab, setFilterTab] = useState('全部');
const [platformTags, setPlatformTags] = useState(['冒险', '海岛']);
const [platformToggle, setPlatformToggle] = useState(true);
const [selectedAssetId, setSelectedAssetId] = useState(
demoAssets[0]?.id ?? '',
);
const [filterOpen, setFilterOpen] = useState(false);
const [sortMode, setSortMode] = useState<'recent' | 'name'>('recent');
const [asyncMode, setAsyncMode] = useState<
'content' | 'loading' | 'empty' | 'error'
>('content');
const showFeedback = (label: string) => {
setFeedback(`${label}已点击`);
};
const adjustProgress = (delta: number, label: string) => {
setProgress((value) => Math.min(100, Math.max(0, value + delta)));
showFeedback(label);
};
const visibleDemoAssets = [...demoAssets]
.filter((asset) => filterTab === '全部' || asset.status === filterTab)
.sort((left, right) =>
sortMode === 'recent'
? right.recentOrder - left.recentOrder
: left.label.localeCompare(right.label, 'zh-CN'),
);
useEffect(() => {
document.title = '共享组件展示|陶泥儿 Genarrative';
}, []);
return (
<main className="shared-components-showcase genarrative-ui platform-theme platform-theme--light">
<div className="shared-components-showcase__inner">
<header className="shared-components-showcase__header">
<div>
<Badge tone="accent" icon={<Sparkles size={13} />}>
共享 UI
</Badge>
<h1>共享组件展示</h1>
<p>无业务依赖的基础交互组件,网站与客户端可直接复用。</p>
</div>
<IconButton
label="创建示例"
icon={<Plus size={18} />}
variant="default"
onClick={() => showFeedback('创建示例')}
/>
</header>
{feedback ? (
<Status
tone="info"
className="shared-components-showcase__feedback"
aria-label="交互反馈"
aria-live="polite"
>
{feedback}
</Status>
) : null}
<SegmentedTabs
items={tabs}
activeId={activeTab}
onChange={(id) => {
setActiveTab(id);
showFeedback(
tabs.find((tab) => tab.id === id)?.label.toString() ?? id,
);
}}
label="展示页分区"
className="shared-components-showcase__tabs"
/>
{activeTab === 'overview' ? (
<div className="shared-components-showcase__grid">
<ShowcaseSection title="Button / IconButton">
<div className="showcase-row">
<Button size="sm" onClick={() => showFeedback('主要操作')}>
主要操作
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => showFeedback('次要操作')}
>
次要操作
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => showFeedback('幽灵按钮')}
>
幽灵按钮
</Button>
<Button
variant="danger"
size="sm"
onClick={() => showFeedback('危险操作')}
>
危险操作
</Button>
<Button loading size="sm">
加载中
</Button>
</div>
<Divider />
<div className="showcase-row">
<IconButton
label="添加"
icon={<Plus size={18} />}
onClick={() => showFeedback('添加')}
/>
<IconButton
label="成功"
icon={<Check size={18} />}
variant="quiet"
onClick={() => showFeedback('成功')}
/>
<IconButton
label="删除"
icon="×"
variant="danger"
onClick={() => showFeedback('删除')}
/>
</div>
</ShowcaseSection>
<ShowcaseSection title="Badge / Status">
<div className="showcase-row showcase-row--wrap">
<Badge>中性</Badge>
<Badge tone="accent">强调</Badge>
<Badge tone="success" icon={<Check size={12} />}>
成功
</Badge>
<Badge tone="warning">提醒</Badge>
<Badge tone="danger">错误</Badge>
<Badge tone="info">信息</Badge>
</div>
<div className="showcase-stack">
<Status tone="info" icon={<Info size={16} />}>
这是信息状态提示。
</Status>
<Status tone="success" icon={<Check size={16} />}>
保存完成,可以继续。
</Status>
<Status tone="warning" icon={<CircleAlert size={16} />}>
请检查输入内容。
</Status>
<Status tone="error">操作失败,请稍后重试。</Status>
</div>
</ShowcaseSection>
<ShowcaseSection title="TextField / SelectField">
<div className="showcase-stack">
<TextField
label="名称"
placeholder="输入名称"
hint="支持中英文和数字。"
/>
<TextField label="描述" multiline placeholder="输入多行描述" />
<TextField
label="错误示例"
defaultValue="不合法的值"
error="请输入有效内容。"
/>
<SelectField label="主题" defaultValue="warm">
<option value="warm">暖色</option>
<option value="dark">暗色</option>
</SelectField>
</div>
</ShowcaseSection>
<ShowcaseSection title="Label / Checkbox / Skeleton / Table">
<div className="showcase-stack">
<div className="showcase-row">
<Checkbox
id="showcase-autosave"
checked={checkboxEnabled}
onChange={(event) => {
setCheckboxEnabled(event.currentTarget.checked);
showFeedback('复选框');
}}
/>
<Label htmlFor="showcase-autosave">
{checkboxEnabled ? '自动保存已开启' : '自动保存已关闭'}
</Label>
</div>
<div className="showcase-stack" aria-label="骨架屏示例">
<Skeleton className="h-3 w-2/5" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-4/5" />
</div>
<Table>
<TableCaption>最近同步记录</TableCaption>
<TableHeader>
<TableRow>
<TableHead>项目</TableHead>
<TableHead>状态</TableHead>
<TableHead>时间</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>珊瑚岛</TableCell>
<TableCell>已发布</TableCell>
<TableCell>2 分钟前</TableCell>
</TableRow>
<TableRow>
<TableCell>月光林</TableCell>
<TableCell>草稿</TableCell>
<TableCell>昨天</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</ShowcaseSection>
<ShowcaseSection title="Subpanel / Switch">
<Subpanel
title="可复用面板"
actions={<Badge tone="success">稳定</Badge>}
tone="soft"
>
<p className="showcase-copy">
面板只负责 surface、标题和 actions 插槽,不读取任何业务状态。
</p>
<Switch
checked={enabled}
label={enabled ? '已启用' : '已停用'}
onClick={() => {
setEnabled((value) => !value);
showFeedback('开关');
}}
/>
</Subpanel>
</ShowcaseSection>
<ShowcaseSection title="ProgressBar / Spinner / Divider">
<div className="showcase-stack">
<ProgressBar value={progress} label="示例进度">
完成 {progress}%
</ProgressBar>
<div className="showcase-row showcase-row--between">
<Button
size="sm"
variant="secondary"
onClick={() => adjustProgress(-10, '减少')}
>
减少
</Button>
<Spinner label="加载示例" />
<Button size="sm" onClick={() => adjustProgress(10, '增加')}>
增加
</Button>
</div>
<ProgressBar indeterminate label="正在处理" />
</div>
</ShowcaseSection>
<ShowcaseSection title="EmptyState / Modal">
<EmptyState
icon={<Sparkles size={20} />}
title="这里还没有内容"
description="空态组件只接受展示内容和 action,不负责创建逻辑。"
action={
<Button
size="sm"
onClick={() => {
setModalOpen(true);
showFeedback('打开弹窗');
}}
>
打开弹窗
</Button>
}
/>
<Modal
open={modalOpen}
title="共享弹窗"
description="弹窗支持 ESC、遮罩点击和 portal。"
onClose={() => setModalOpen(false)}
footer={
<Button
onClick={() => {
setModalOpen(false);
showFeedback('完成');
}}
>
完成
</Button>
}
>
<p className="showcase-copy">
正文由宿主传入,组件本身不携带账号、请求或页面路由。
</p>
</Modal>
</ShowcaseSection>
</div>
) : null}
{activeTab === 'platform' ? (
<PlatformComponentsReference
filterTab={filterTab}
sortLabel={sortMode === 'recent' ? '最近使用' : '名称'}
assets={visibleDemoAssets}
platformTags={platformTags}
platformToggle={platformToggle}
selectedAssetId={selectedAssetId}
asyncMode={asyncMode}
filterOpen={filterOpen}
onOpenFilter={() => {
setFilterOpen(true);
showFeedback('打开筛选');
}}
onCloseFilter={() => setFilterOpen(false)}
onFeedback={showFeedback}
onFilterTabChange={(id) => {
setFilterTab(id);
showFeedback(`筛选:${id}`);
}}
onToggleSort={() => {
const nextMode = sortMode === 'recent' ? 'name' : 'recent';
setSortMode(nextMode);
showFeedback(
`排序:${nextMode === 'recent' ? '最近使用' : '名称'}`,
);
}}
onTagsChange={(tags) => {
setPlatformTags(tags);
showFeedback('标签已更新');
}}
onGenerateTags={() => showFeedback('生成标签')}
onToggleChange={(checked) => {
setPlatformToggle(checked);
showFeedback(checked ? '开关已开启' : '开关已关闭');
}}
onSelectAsset={(id) => {
setSelectedAssetId(id);
showFeedback('已选择素材');
}}
onAsyncModeChange={(mode) => {
setAsyncMode(mode);
showFeedback(`异步状态:${mode}`);
}}
/>
) : null}
{activeTab === 'tokens' ? <TokenReference /> : null}
{activeTab === 'states' ? <StateReference /> : null}
</div>
</main>
);
}
type PlatformComponentsReferenceProps = {
filterTab: string;
sortLabel: string;
assets: DemoAsset[];
platformTags: string[];
platformToggle: boolean;
selectedAssetId: string;
asyncMode: 'content' | 'loading' | 'empty' | 'error';
filterOpen: boolean;
onFeedback: (label: string) => void;
onOpenFilter: () => void;
onCloseFilter: () => void;
onFilterTabChange: (id: string) => void;
onToggleSort: () => void;
onTagsChange: (tags: string[]) => void;
onGenerateTags: () => void;
onToggleChange: (checked: boolean) => void;
onSelectAsset: (id: string) => void;
onAsyncModeChange: (
mode: PlatformComponentsReferenceProps['asyncMode'],
) => void;
};
function PlatformComponentsReference({
filterTab,
sortLabel,
assets,
platformTags,
platformToggle,
selectedAssetId,
asyncMode,
filterOpen,
onFeedback,
onOpenFilter,
onCloseFilter,
onFilterTabChange,
onToggleSort,
onTagsChange,
onGenerateTags,
onToggleChange,
onSelectAsset,
onAsyncModeChange,
}: PlatformComponentsReferenceProps) {
// 预览绑定当前选中的素材,而不是过滤/排序后的第一项,避免排序带动上传预览的视觉主题变化。
const previewAsset =
demoAssets.find((asset) => asset.id === selectedAssetId) ?? demoAssets[0];
const filterApplied = filterTab !== '全部';
const filterEntryLabel = filterApplied ? filterTab : '筛选';
const filterEntryCount = filterApplied ? assets.length : 3;
return (
<div className="shared-components-showcase__platform-grid">
<ShowcaseSection title="导航 / 筛选工具栏">
<div className="showcase-platform-desktop-toolbar">
<PlatformFilterToolbar
filterLabel={filterEntryLabel}
filterCount={filterEntryCount}
tabItems={[
{ id: '全部', label: '全部' },
{ id: '草稿', label: '草稿' },
{ id: '已发布', label: '已发布' },
]}
activeTabId={filterTab}
sortLabel={sortLabel}
layout="desktop"
onOpenFilter={onOpenFilter}
onTabChange={onFilterTabChange}
onToggleSort={onToggleSort}
/>
</div>
<div className="showcase-platform-mobile-toolbar">
<PlatformFilterToolbar
filterLabel={filterEntryLabel}
filterCount={filterEntryCount}
tabItems={[
{ id: '全部', label: '全部' },
{ id: '草稿', label: '草稿' },
{ id: '已发布', label: '已发布' },
]}
activeTabId={filterTab}
sortLabel={sortLabel}
layout="mobile"
onOpenFilter={onOpenFilter}
onTabChange={onFilterTabChange}
onToggleSort={onToggleSort}
/>
</div>
<p className="showcase-copy showcase-filter-summary">
当前筛选:{filterTab} · {sortLabel} · {assets.length} 个结果
</p>
</ShowcaseSection>
<ShowcaseSection title="标签 / 整行开关">
<PlatformTagEditor
title="内容标签"
tags={platformTags}
maxTags={5}
parseInput={(value) => value.split(/[\s,,]+/)}
onChange={onTagsChange}
onGenerate={onGenerateTags}
generateLabel="生成标签"
inputLabel="新增标签"
inputPlaceholder="输入后回车"
tone="warm"
padding="md"
/>
<div className="mt-3">
<PlatformToggleRow
label="允许智能推荐"
checked={platformToggle}
onChange={onToggleChange}
onLabel="开启"
offLabel="关闭"
icon={<Sparkles size={15} />}
/>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<PlatformBackActionButton onClick={() => onFeedback('返回动作')} />
<PlatformBackActionButton
label="返回编辑"
variant="regular"
surface="editorDark"
onClick={() => onFeedback('返回编辑')}
/>
</div>
</ShowcaseSection>
<ShowcaseSection title="媒体 / 上传入口">
<div className="showcase-platform-media-row">
<PlatformMediaFrame
alt="无图片时的媒体框"
fallbackLabel="媒体 fallback"
aspect="landscape"
surface="soft"
fallbackContent={<span>16:9 媒体框</span>}
/>
<PlatformUploadTile
label="上传素材"
hint="PNG / JPG"
size="panel"
onClick={() => onFeedback('上传素材')}
/>
<div className="showcase-platform-upload-preview-shell">
<PlatformUploadPreviewCard
imageSrc={previewAsset?.image ?? ''}
imageAlt={`${previewAsset?.label ?? '素材'}预览`}
removeLabel="移除预览"
layout="square"
previewLabel={`预览${previewAsset?.label ?? '素材'}`}
onPreview={() =>
onFeedback(`预览${previewAsset?.label ?? '素材'}`)
}
onRemove={() => onFeedback('移除预览')}
/>
</div>
</div>
<PlatformMediaTileGrid
items={assets.map((asset) => ({
id: asset.id,
src: asset.image,
alt: asset.label,
fallbackLabel: asset.label,
}))}
columns="five"
aspect="auto"
surface="soft"
tileSurface="white"
className="mt-3"
/>
</ShowcaseSection>
<ShowcaseSection title="素材选择 / 选中态">
<PlatformAssetPickerGrid
items={assets}
loadingLabel="正在读取素材"
emptyLabel="还没有素材"
getKey={(asset) => asset.id}
getImageSrc={(asset) => asset.image}
getImageAlt={(asset) => asset.label}
getTitle={(asset) => asset.label}
getSubtitle={() => '本地示例素材'}
getAriaLabel={(asset) => `选择${asset.label}`}
isSelected={(asset) => asset.id === selectedAssetId}
onSelect={(asset) => onSelectAsset(asset.id)}
selectLabel="选择"
gridClassName="grid grid-cols-2 gap-2 sm:grid-cols-4"
/>
</ShowcaseSection>
<ShowcaseSection title="反馈 / 异步状态">
<div className="showcase-row showcase-row--wrap">
{(['content', 'loading', 'empty', 'error'] as const).map((mode) => (
<Button
key={mode}
size="sm"
variant={asyncMode === mode ? 'primary' : 'secondary'}
onClick={() => onAsyncModeChange(mode)}
>
{mode === 'content'
? '内容'
: mode === 'loading'
? '加载中'
: mode === 'empty'
? '空态'
: '错误'}
</Button>
))}
</div>
<div className="mt-3">
<PlatformAsyncStatePanel
isLoading={asyncMode === 'loading'}
isEmpty={asyncMode === 'empty'}
errorState={
asyncMode === 'error' ? (
<PlatformRuntimeStatusToast tone="error" surface="light">
读取失败,请重试
</PlatformRuntimeStatusToast>
) : null
}
loadingState={
<PlatformRuntimeStatusToast tone="info" surface="light">
正在读取素材…
</PlatformRuntimeStatusToast>
}
emptyState={
<PlatformEmptyState surface="dashed" size="inline">
暂无可展示内容
</PlatformEmptyState>
}
>
<div className="showcase-platform-toast-stack">
<PlatformRuntimeStatusToast tone="success" surface="light">
已保存
</PlatformRuntimeStatusToast>
<PlatformRuntimeStatusToast tone="warning" surface="solid">
即将过期
</PlatformRuntimeStatusToast>
<PlatformRuntimeStatusToast tone="info" surface="dark">
同步中
</PlatformRuntimeStatusToast>
</div>
</PlatformAsyncStatePanel>
</div>
</ShowcaseSection>
<ShowcaseSection title="信息 / 指标 / 列表行">
<PlatformStatGrid
items={[
{ label: '作品数', value: '24' },
{ label: '已发布', value: '18' },
{ label: '收藏', value: '1.2k' },
]}
columns="three"
surface="plain"
/>
<div className="showcase-platform-info-list">
<PlatformInfoBlock label="最近同步" variant="compactRow">
2 分钟前
</PlatformInfoBlock>
<PlatformNavigableListItem
leading={<Badge tone="success">在线</Badge>}
trailing={<span aria-hidden="true">›</span>}
onClick={() => onFeedback('打开项目工作台')}
>
<strong>项目工作台</strong>
<span className="showcase-platform-list-subtitle">
点击进入详情
</span>
</PlatformNavigableListItem>
</div>
</ShowcaseSection>
<Modal
open={filterOpen}
title="筛选示例"
description="选择后会同步到入口,点击完成关闭面板。"
onClose={onCloseFilter}
footer={
<Button variant="secondary" onClick={onCloseFilter}>
完成
</Button>
}
>
<div className="showcase-filter-options">
{['全部', '草稿', '已发布'].map((option) => (
<Button
key={option}
variant={filterTab === option ? 'primary' : 'secondary'}
onClick={() => onFilterTabChange(option)}
>
{option}
</Button>
))}
</div>
</Modal>
</div>
);
}
function ShowcaseSection({
title,
children,
}: {
title: string;
children: ReactNode;
}) {
return (
<Subpanel title={title} className="shared-components-showcase__section">
{children}
</Subpanel>
);
}
function TokenReference() {
return (
<div className="shared-components-showcase__token-grid">
{[
['Accent', 'var(--platform-accent)'],
['Panel', 'var(--platform-panel-fill)'],
['Border', 'var(--platform-subpanel-border)'],
['Strong text', 'var(--platform-text-strong)'],
['Muted text', 'var(--platform-text-soft)'],
['Focus', 'var(--platform-input-focus-ring)'],
].map(([label, value]) => (
<Subpanel key={label} title={label} padding="sm">
<div className="showcase-token-value" style={{ background: value }} />
<code>{value}</code>
</Subpanel>
))}
</div>
);
}
function StateReference() {
return (
<Subpanel title="交互状态">
<div className="showcase-state-list">
<span>
<span className="showcase-dot showcase-dot--default" />
默认
</span>
<span>
<span className="showcase-dot showcase-dot--hover" />
悬停
</span>
<span>
<span className="showcase-dot showcase-dot--focus" />
聚焦
</span>
<span>
<span className="showcase-dot showcase-dot--disabled" />
禁用
</span>
</div>
<p className="showcase-copy">
所有可交互组件都提供原生语义、键盘焦点样式和 disabled 状态。
</p>
</Subpanel>
);
}