扩展共享组件库并接入当前项目
- 收口平台异步、筛选、图标、信息、列表、运行态和统计组件到共享包 - 新增 Label、Checkbox、Skeleton、Table 语义基础组件 - 展示页与代表性业务页面改用共享 canonical 导出并补齐交互反馈 - 同步共享样式、组件测试和前端架构决策文档
This commit is contained in:
@@ -7759,3 +7759,10 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
|
||||
- 决策:共享 Web UI 采用 shadcn 的源码归属项目模式,新增 canonical source 放入 `packages/shared/src/components/ui`,统一通过 `packages/shared/src/lib/utils.ts` 的 `cn` 与 CVA 组织变体;不引入 MUI / Ant Design 全量组件,也不一次性重写现有业务 common。
|
||||
- 首步:`Button`、`Modal`、`SegmentedTabs`、`Switch`、`Input`、`Textarea`、`Badge`、`Card` 已迁移到 `components/ui` canonical source,保留 `@genarrative/shared/components` 的旧 API 作为兼容适配;Button/Input/Badge 用 CVA,Dialog/Tabs/Switch 按需使用 Radix,Tailwind 4 继续消费现有 `--platform-*` token 与 `.genarrative-ui-*` 样式。原生 Select 暂不迁移。
|
||||
- 边界:网站与 Tauri WebView 可消费该 DOM 源码,移动端 React Native 不导入这套组件与 CSS;Radix primitive 仅在 Dialog、Tabs、Switch 等具体组件迁移时按需加入。
|
||||
|
||||
## 2026-08-28 共享平台 chrome 与基础语义件继续扩展
|
||||
|
||||
- 决策:无业务依赖的 `PlatformAsyncStatePanel`、`PlatformFilterToolbar`、`PlatformIconBadge`、`PlatformInfoBlock`、`PlatformNavigableListItem`、`PlatformRuntimeStatusToast`、`PlatformStatGrid` 实现统一收口到 `packages/shared/src/components`;`src/components/common` 仅保留兼容导出,展示页和部分实际业务页面开始直接消费共享 barrel。
|
||||
- 基础件:`components/ui` 新增语义 `Label`、原生 `Checkbox`、`Skeleton` 和语义 `Table` 组合件;继续沿用项目自有 shadcn open-code 源码,不引入额外运行时依赖。
|
||||
- 样式:筛选工具栏与可导航列表行的 hover/active/focus/disabled 反馈、移动端断点和独立宿主所需 chrome 放入共享样式;共享主题仍只消费 `--platform-*` token,不依赖网站总 CSS。
|
||||
- 验证:相关共享/兼容组件 10 个测试文件共 36 个断言、类型检查、编码检查、定向 ESLint、`git diff --check` 和生产构建均通过。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 共享基础组件库与展示页
|
||||
|
||||
更新时间:`2026-08-26`
|
||||
更新时间:`2026-08-28`
|
||||
|
||||
## 目标
|
||||
|
||||
@@ -19,17 +19,28 @@
|
||||
- `Subpanel`、`Modal`
|
||||
- `Status`、`EmptyState`、`Badge`
|
||||
- `ProgressBar`、`SegmentedTabs`、`Switch`
|
||||
- `Spinner`、`Divider`
|
||||
- `Spinner`、`Divider`、`Label`、`Checkbox`、`Skeleton`
|
||||
- `Table`(含 `TableHeader`、`TableBody`、`TableRow`、`TableHead`、`TableCell`、`TableCaption`、`TableFooter`)
|
||||
|
||||
平台 chrome 组件(同样从 `@genarrative/shared/components` 导出):
|
||||
|
||||
- `PlatformActionButton`、`PlatformIconButton`、`PlatformPillBadge`
|
||||
- `PlatformStatusMessage`、`PlatformEmptyState`、`PlatformTextField`
|
||||
- `PlatformProgressBar`、`PlatformSegmentedTabs`、`PlatformFieldLabel`、`PlatformSubpanel`
|
||||
- `PlatformAsyncStatePanel`、`PlatformFilterToolbar`、`PlatformIconBadge`、`PlatformRuntimeStatusToast`
|
||||
- `PlatformInfoBlock`、`PlatformNavigableListItem`、`PlatformStatGrid`
|
||||
|
||||
迁移约定:`Button`、`Modal`、`SegmentedTabs`、`Switch`、`Input`、`Textarea`、`Badge`、`Card` 的 canonical source 位于 `packages/shared/src/components/ui`,旧的 `@genarrative/shared/components` API 作为兼容适配层继续保留。`Button`、`Input`、`Badge` 使用 CVA,Dialog/Tabs/Switch 使用按需 Radix primitive;原生 `SelectField` 暂不迁移,避免破坏现有 `<option>` 与表单事件合同。后续组件按使用面逐个迁移。
|
||||
|
||||
2026-08-28 平台 chrome 的无业务依赖组件继续完成实现收口:新增 `PlatformAsyncStatePanel`、`PlatformFilterToolbar`、`PlatformIconBadge`、`PlatformInfoBlock`、`PlatformNavigableListItem`、`PlatformRuntimeStatusToast`、`PlatformStatGrid`。这些组件的实现统一位于 `packages/shared/src/components`;`src/components/common` 中同名文件仅保留兼容导出,因此现有业务页面无需一次性改写导入路径,实际渲染已复用共享包实现。带 API、store、编辑器或素材解析副作用的组件仍留在业务层,后续按同一边界逐项评估。
|
||||
|
||||
同时提供 `Platform*` 别名,便于从现有平台组件命名迁移;别名不携带平台业务语义。
|
||||
|
||||
Web 宿主需要显式启用 Tailwind 4,并引入 `@genarrative/shared/styles.css` 和 `@genarrative/shared/theme.css`;Tauri WebView 与网站共用这套源码,移动端 React Native 不消费 DOM/CSS 组件。组件库不导入网站 `src/index.css`,也不提供页面壳或业务流程。`components.json` 只用于 shadcn CLI 定位源码,不允许覆盖现有业务组件。
|
||||
Web 宿主需要显式启用 Tailwind 4,并引入 `@genarrative/shared/styles.css` 和 `@genarrative/shared/theme.css`;Tauri WebView 与网站共用这套源码,移动端 React Native 不消费 DOM/CSS 组件。筛选工具栏所需的 `platform-category-*` chrome 样式也已放入共享样式文件,独立宿主无需依赖网站 `src/index.css`。组件库不提供页面壳或业务流程。`components.json` 只用于 shadcn CLI 定位源码,不允许覆盖现有业务组件。
|
||||
|
||||
## 展示页
|
||||
|
||||
网站 `/components`(兼容别名 `/design-system`)是共享组件展示页,不经过账号 Gate。页面按“基础组件”“平台通用组件”“Token”“状态”分区,覆盖公共组件的主要变体、交互态、筛选/标签/媒体/上传/异步状态/指标列表等平台 chrome 和移动端布局;展示数据均为本地静态示例,不调用 API。平台组件分区中的筛选按示例素材状态过滤结果,排序按最近使用或名称重排结果,并在筛选按钮、排序按钮和独立筛选面板之间保持同一份本地状态。展示页可用于网站与客户端接入前的视觉回归和人工验收。平台通用组件示例来自 `src/components/common`,只接入不读取请求、store 或业务实体的 chrome,不把账号、发布和编辑器业务流程嵌入展示页。
|
||||
网站 `/components`(兼容别名 `/design-system`)是共享组件展示页,不经过账号 Gate。页面按“基础组件”“平台通用组件”“Token”“状态”分区,覆盖公共组件的主要变体、交互态、筛选/标签/媒体/上传/异步状态/指标列表等平台 chrome 和移动端布局;展示数据均为本地静态示例,不调用 API。平台组件分区中的筛选按示例素材状态过滤结果,排序按最近使用或名称重排结果,并在筛选按钮、排序按钮和独立筛选面板之间保持同一份本地状态。展示页可用于网站与客户端接入前的视觉回归和人工验收。平台通用组件示例优先从 `@genarrative/shared/components` 直接导入,业务代码仍可通过 `src/components/common` 的兼容出口渐进迁移;展示页只接入不读取请求、store 或业务实体的 chrome,不把账号、发布和编辑器业务流程嵌入展示页。
|
||||
|
||||
## 验收
|
||||
|
||||
|
||||
@@ -4,24 +4,25 @@ import type {
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
|
||||
import { Button, type ButtonProps, type SharedButtonSize } from './ui';
|
||||
import {
|
||||
getPlatformActionButtonClassName,
|
||||
type PlatformActionButtonAlign,
|
||||
type PlatformActionButtonShape,
|
||||
type PlatformActionButtonSize,
|
||||
type PlatformActionButtonSurface,
|
||||
type PlatformActionButtonTone,
|
||||
} from './platformActionButtonModel';
|
||||
|
||||
export type PlatformActionButtonTone =
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'ghost'
|
||||
| 'danger'
|
||||
| 'success'
|
||||
| 'warning'
|
||||
| 'accent'
|
||||
| 'accentSoft';
|
||||
export type PlatformActionButtonSurface = 'platform' | 'profile' | 'editorDark';
|
||||
export type PlatformActionButtonSize = 'xxs' | 'xs' | SharedButtonSize;
|
||||
export type PlatformActionButtonShape = 'default' | 'pill';
|
||||
export type PlatformActionButtonAlign = 'center' | 'start';
|
||||
export type {
|
||||
PlatformActionButtonAlign,
|
||||
PlatformActionButtonShape,
|
||||
PlatformActionButtonSize,
|
||||
PlatformActionButtonSurface,
|
||||
PlatformActionButtonTone,
|
||||
} from './platformActionButtonModel';
|
||||
|
||||
type PlatformActionButtonBaseProps = {
|
||||
children?: ReactNode;
|
||||
children: ReactNode;
|
||||
tone?: PlatformActionButtonTone;
|
||||
surface?: PlatformActionButtonSurface;
|
||||
size?: PlatformActionButtonSize;
|
||||
@@ -31,8 +32,8 @@ type PlatformActionButtonBaseProps = {
|
||||
};
|
||||
|
||||
type PlatformActionButtonButtonProps = Omit<
|
||||
ButtonProps,
|
||||
'children' | 'variant' | 'size' | 'fullWidth'
|
||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
'children'
|
||||
> &
|
||||
PlatformActionButtonBaseProps & {
|
||||
asChild?: false;
|
||||
@@ -50,25 +51,9 @@ export type PlatformActionButtonProps =
|
||||
| PlatformActionButtonButtonProps
|
||||
| PlatformActionButtonLabelProps;
|
||||
|
||||
const toneVariant: Record<PlatformActionButtonTone, ButtonProps['variant']> = {
|
||||
primary: 'primary',
|
||||
secondary: 'secondary',
|
||||
ghost: 'ghost',
|
||||
danger: 'danger',
|
||||
success: 'primary',
|
||||
warning: 'secondary',
|
||||
accent: 'primary',
|
||||
accentSoft: 'secondary',
|
||||
};
|
||||
|
||||
function toneClassName(tone: PlatformActionButtonTone) {
|
||||
return tone.replace(/[A-Z]/gu, (letter) => `-${letter.toLowerCase()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Product-neutral compatibility wrapper for the existing platform action
|
||||
* button naming. It keeps the optional label host useful for file inputs while
|
||||
* leaving all product state and event handling with the caller.
|
||||
* 平台通用动作按钮。
|
||||
* 收口平台与个人中心主动作按钮的样式族、尺寸、圆角和禁用态 class。
|
||||
*/
|
||||
export function PlatformActionButton({
|
||||
tone = 'primary',
|
||||
@@ -82,23 +67,16 @@ export function PlatformActionButton({
|
||||
asChild,
|
||||
...props
|
||||
}: PlatformActionButtonProps) {
|
||||
const resolvedSize: SharedButtonSize =
|
||||
size === 'xxs' || size === 'xs' ? 'sm' : size;
|
||||
const modifierClassName = [
|
||||
shape === 'pill' ? 'genarrative-ui-button--pill' : null,
|
||||
align === 'start' ? 'genarrative-ui-button--start' : null,
|
||||
fullWidth ? 'genarrative-ui-button--full-width' : null,
|
||||
`genarrative-ui-button--surface-${surface}`,
|
||||
`genarrative-ui-button--tone-${toneClassName(tone)}`,
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
const actionClassName = [
|
||||
'genarrative-ui-button',
|
||||
`genarrative-ui-button--${toneVariant[tone]}`,
|
||||
`genarrative-ui-button--${resolvedSize}`,
|
||||
modifierClassName,
|
||||
getPlatformActionButtonClassName({
|
||||
surface,
|
||||
tone,
|
||||
size,
|
||||
shape,
|
||||
align,
|
||||
fullWidth,
|
||||
}),
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
@@ -117,15 +95,8 @@ export function PlatformActionButton({
|
||||
const { type = 'button', ...buttonProps } =
|
||||
props as ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
return (
|
||||
<Button
|
||||
{...buttonProps}
|
||||
type={type}
|
||||
fullWidth={fullWidth}
|
||||
size={resolvedSize}
|
||||
variant={toneVariant[tone]}
|
||||
className={modifierClassName}
|
||||
>
|
||||
<button {...buttonProps} type={type} className={actionClassName}>
|
||||
{children}
|
||||
</Button>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export type PlatformAsyncStatePanelProps = {
|
||||
errorState?: ReactNode;
|
||||
isLoading?: boolean;
|
||||
loadingState?: ReactNode;
|
||||
isEmpty?: boolean;
|
||||
emptyState?: ReactNode;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* 平台异步状态面板骨架。
|
||||
* 只负责在错误、读取、空态和内容之间切换,具体文案与外观继续交给调用方传入 slot。
|
||||
*/
|
||||
export function PlatformAsyncStatePanel({
|
||||
errorState,
|
||||
isLoading = false,
|
||||
loadingState = null,
|
||||
isEmpty = false,
|
||||
emptyState = null,
|
||||
children,
|
||||
}: PlatformAsyncStatePanelProps) {
|
||||
if (errorState !== undefined && errorState !== null) {
|
||||
return <>{errorState}</>;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <>{loadingState}</>;
|
||||
}
|
||||
|
||||
if (isEmpty) {
|
||||
return <>{emptyState}</>;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,2 +1,128 @@
|
||||
export type { EmptyStateProps as PlatformEmptyStateProps } from './ui';
|
||||
export { EmptyState as PlatformEmptyState } from './ui';
|
||||
import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
export type PlatformEmptyStateSurface =
|
||||
| 'soft'
|
||||
| 'dashed'
|
||||
| 'subpanel'
|
||||
| 'editorDark';
|
||||
export type PlatformEmptyStateSize = 'compact' | 'panel' | 'inline';
|
||||
export type PlatformEmptyStateTone = 'base' | 'soft';
|
||||
|
||||
type PlatformEmptyStateBaseProps = {
|
||||
children: ReactNode;
|
||||
surface?: PlatformEmptyStateSurface;
|
||||
size?: PlatformEmptyStateSize;
|
||||
tone?: PlatformEmptyStateTone;
|
||||
};
|
||||
|
||||
type PlatformEmptyStateDivProps = Omit<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
'children'
|
||||
> & {
|
||||
asChild?: false;
|
||||
} & PlatformEmptyStateBaseProps;
|
||||
|
||||
type PlatformEmptyStateButtonProps = Omit<
|
||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
'children'
|
||||
> & {
|
||||
asChild: 'button';
|
||||
} & PlatformEmptyStateBaseProps;
|
||||
|
||||
export type PlatformEmptyStateProps =
|
||||
| PlatformEmptyStateDivProps
|
||||
| PlatformEmptyStateButtonProps;
|
||||
|
||||
type PlatformEmptyStateClassNameOptions = {
|
||||
surface: PlatformEmptyStateSurface;
|
||||
size: PlatformEmptyStateSize;
|
||||
tone: PlatformEmptyStateTone;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const PLATFORM_EMPTY_STATE_SURFACE_CLASS: Record<
|
||||
PlatformEmptyStateSurface,
|
||||
string
|
||||
> = {
|
||||
soft: 'platform-surface platform-surface--soft rounded-[1.35rem]',
|
||||
dashed:
|
||||
'rounded-[1.35rem] border border-dashed border-[var(--platform-subpanel-border)] bg-white/52',
|
||||
subpanel:
|
||||
'rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/74',
|
||||
editorDark: 'rounded-2xl border border-dashed border-white/12 bg-black/20',
|
||||
};
|
||||
|
||||
const PLATFORM_EMPTY_STATE_SIZE_CLASS: Record<PlatformEmptyStateSize, string> =
|
||||
{
|
||||
compact: 'px-4 py-3 text-sm leading-6',
|
||||
panel:
|
||||
'flex min-h-[14rem] items-center justify-center px-6 text-center text-sm',
|
||||
inline: 'px-4 py-5 text-center text-sm font-semibold',
|
||||
};
|
||||
|
||||
const PLATFORM_EMPTY_STATE_TONE_CLASS: Record<PlatformEmptyStateTone, string> =
|
||||
{
|
||||
base: 'text-[var(--platform-text-base)]',
|
||||
soft: 'text-[var(--platform-text-soft)]',
|
||||
};
|
||||
|
||||
/**
|
||||
* 平台通用空态和轻量加载态。
|
||||
* 收口平台列表、作品架和素材选择弹窗中重复的空面板外观。
|
||||
*/
|
||||
function getPlatformEmptyStateClassName({
|
||||
surface,
|
||||
size,
|
||||
tone,
|
||||
className,
|
||||
}: PlatformEmptyStateClassNameOptions) {
|
||||
return [
|
||||
'min-w-0',
|
||||
'platform-empty-state',
|
||||
PLATFORM_EMPTY_STATE_SURFACE_CLASS[surface],
|
||||
PLATFORM_EMPTY_STATE_SIZE_CLASS[size],
|
||||
PLATFORM_EMPTY_STATE_TONE_CLASS[tone],
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
export function PlatformEmptyState({
|
||||
children,
|
||||
surface = 'soft',
|
||||
size = 'compact',
|
||||
tone,
|
||||
className,
|
||||
asChild,
|
||||
...emptyStateProps
|
||||
}: PlatformEmptyStateProps) {
|
||||
const resolvedTone =
|
||||
tone ?? (surface === 'subpanel' || size === 'inline' ? 'soft' : 'base');
|
||||
const emptyStateClassName = getPlatformEmptyStateClassName({
|
||||
surface,
|
||||
size,
|
||||
tone: resolvedTone,
|
||||
className,
|
||||
});
|
||||
|
||||
if (asChild === 'button') {
|
||||
const { type = 'button', ...buttonProps } =
|
||||
emptyStateProps as ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
|
||||
return (
|
||||
<button {...buttonProps} type={type} className={emptyStateClassName}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
{...(emptyStateProps as HTMLAttributes<HTMLDivElement>)}
|
||||
className={emptyStateClassName}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export type PlatformFieldLabelVariant =
|
||||
| 'field'
|
||||
| 'section'
|
||||
| 'form'
|
||||
| 'inlineForm'
|
||||
| 'pill'
|
||||
| 'accentPill';
|
||||
|
||||
export type PlatformFieldLabelProps = {
|
||||
children: ReactNode;
|
||||
variant?: PlatformFieldLabelVariant;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const PLATFORM_FIELD_LABEL_CLASS: Record<PlatformFieldLabelVariant, string> = {
|
||||
field: 'text-xs font-bold text-[var(--platform-text-soft)]',
|
||||
section:
|
||||
'text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]',
|
||||
form: 'mb-2 block text-sm font-black text-[var(--platform-text-strong)]',
|
||||
inlineForm:
|
||||
'inline-flex items-center text-sm font-bold text-[var(--platform-text-base)]',
|
||||
pill: 'mb-2 inline-flex rounded-full px-2 py-0.5 text-sm font-black text-[var(--platform-text-strong)]',
|
||||
accentPill:
|
||||
'mb-2 inline-flex rounded-full border border-rose-200/70 bg-rose-50/88 px-2.5 py-1 text-sm font-black text-rose-700 shadow-sm',
|
||||
};
|
||||
|
||||
/** 平台字段标签,供子面板和表单控件复用。 */
|
||||
export function PlatformFieldLabel({
|
||||
children,
|
||||
variant = 'field',
|
||||
className,
|
||||
}: PlatformFieldLabelProps) {
|
||||
return (
|
||||
<span
|
||||
className={[PLATFORM_FIELD_LABEL_CLASS[variant], className]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { ChevronDown, SlidersHorizontal } from 'lucide-react';
|
||||
|
||||
import { PlatformSegmentedTabs } from './PlatformSegmentedTabs';
|
||||
|
||||
export interface PlatformFilterToolbarTabItem {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface PlatformFilterToolbarProps {
|
||||
filterLabel: string;
|
||||
filterCount: number;
|
||||
tabItems: readonly PlatformFilterToolbarTabItem[];
|
||||
activeTabId: string;
|
||||
sortLabel: string;
|
||||
layout: 'mobile' | 'desktop';
|
||||
onOpenFilter: () => void;
|
||||
onTabChange: (id: string) => void;
|
||||
onToggleSort: () => void;
|
||||
}
|
||||
|
||||
function buildToolbarTabItemClassName(active: boolean) {
|
||||
return [
|
||||
'platform-category-chip shrink-0 !min-h-[2.35rem] !rounded-none !border-0 !bg-transparent !px-0 !shadow-none hover:!bg-transparent',
|
||||
active ? 'platform-category-chip--active' : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* 平台内容列表的筛选、分类和排序工具栏。
|
||||
* 只负责展示入口与转发回调,筛选条件和排序规则由宿主页面持有。
|
||||
*/
|
||||
export function PlatformFilterToolbar({
|
||||
filterLabel,
|
||||
filterCount,
|
||||
tabItems,
|
||||
activeTabId,
|
||||
sortLabel,
|
||||
layout,
|
||||
onOpenFilter,
|
||||
onTabChange,
|
||||
onToggleSort,
|
||||
}: PlatformFilterToolbarProps) {
|
||||
const isMobileLayout = layout === 'mobile';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={
|
||||
isMobileLayout
|
||||
? 'platform-category-filter-row'
|
||||
: 'mb-4 flex min-w-0 items-center gap-2'
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenFilter}
|
||||
aria-haspopup="dialog"
|
||||
className="platform-category-filter-button"
|
||||
>
|
||||
<SlidersHorizontal className="h-4 w-4" />
|
||||
<span>{filterLabel}</span>
|
||||
<span className="platform-category-filter-button__count">
|
||||
{filterCount}
|
||||
</span>
|
||||
</button>
|
||||
{isMobileLayout ? (
|
||||
<span className="platform-category-filter-divider" />
|
||||
) : null}
|
||||
<PlatformSegmentedTabs
|
||||
items={tabItems}
|
||||
activeId={activeTabId}
|
||||
onChange={onTabChange}
|
||||
layout="scroll"
|
||||
gap="md"
|
||||
frame="bare"
|
||||
surface="transparent"
|
||||
size="sm"
|
||||
tone="neutral"
|
||||
className={
|
||||
isMobileLayout
|
||||
? 'platform-category-chip-scroll min-w-0 flex-1'
|
||||
: 'min-w-0 flex-1 pb-1'
|
||||
}
|
||||
itemClassName={(_, active) => buildToolbarTabItemClassName(active)}
|
||||
/>
|
||||
{!isMobileLayout ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleSort}
|
||||
className="platform-category-sort-button shrink-0"
|
||||
>
|
||||
<span>{sortLabel}</span>
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{isMobileLayout ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleSort}
|
||||
className="platform-category-sort-button"
|
||||
>
|
||||
<span>按{sortLabel}排序</span>
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export type PlatformIconBadgeSize =
|
||||
| 'xs'
|
||||
| 'sm'
|
||||
| 'base'
|
||||
| 'md'
|
||||
| 'lg'
|
||||
| 'xl'
|
||||
| 'xxl';
|
||||
export type PlatformIconBadgeShape = 'circle' | 'rounded' | 'xl';
|
||||
export type PlatformIconBadgeTone =
|
||||
| 'neutral'
|
||||
| 'soft'
|
||||
| 'softBright'
|
||||
| 'hero'
|
||||
| 'heroMuted'
|
||||
| 'darkAmber'
|
||||
| 'success'
|
||||
| 'danger';
|
||||
|
||||
export type PlatformIconBadgeProps = {
|
||||
icon: ReactNode;
|
||||
label?: string;
|
||||
size?: PlatformIconBadgeSize;
|
||||
shape?: PlatformIconBadgeShape;
|
||||
tone?: PlatformIconBadgeTone;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const PLATFORM_ICON_BADGE_SIZE_CLASS: Record<PlatformIconBadgeSize, string> = {
|
||||
xs: 'h-7 w-7',
|
||||
sm: 'h-9 w-9',
|
||||
base: 'h-10 w-10',
|
||||
md: 'h-11 w-11',
|
||||
lg: 'h-12 w-12',
|
||||
xl: 'h-14 w-14',
|
||||
xxl: 'h-20 w-20',
|
||||
};
|
||||
|
||||
const PLATFORM_ICON_BADGE_SHAPE_CLASS: Record<PlatformIconBadgeShape, string> =
|
||||
{
|
||||
circle: 'rounded-full',
|
||||
rounded: 'rounded-[0.85rem]',
|
||||
xl: 'rounded-2xl',
|
||||
};
|
||||
|
||||
const PLATFORM_ICON_BADGE_TONE_CLASS: Record<PlatformIconBadgeTone, string> = {
|
||||
neutral:
|
||||
'bg-[var(--platform-neutral-bg)] text-[var(--platform-neutral-text)]',
|
||||
soft: 'bg-white/82 text-[var(--platform-text-strong)] shadow-sm',
|
||||
softBright: 'bg-white/84 text-[var(--platform-text-strong)] shadow-sm',
|
||||
hero: 'bg-white/18 text-white',
|
||||
heroMuted: 'bg-white/18 text-white/72',
|
||||
darkAmber: 'border border-amber-400/30 bg-amber-500/15 text-amber-50',
|
||||
success:
|
||||
'bg-[var(--platform-success-bg)] text-[var(--platform-success-text)]',
|
||||
danger:
|
||||
'bg-[var(--platform-button-danger-fill)] text-[var(--platform-button-danger-text)]',
|
||||
};
|
||||
|
||||
/**
|
||||
* 平台中性图标徽章。
|
||||
* 统一承接弹窗标题、列表项和小卡片里的非交互图标槽。
|
||||
*/
|
||||
export function PlatformIconBadge({
|
||||
icon,
|
||||
label,
|
||||
size = 'sm',
|
||||
shape = 'circle',
|
||||
tone = 'neutral',
|
||||
className,
|
||||
}: PlatformIconBadgeProps) {
|
||||
return (
|
||||
<span
|
||||
aria-label={label}
|
||||
aria-hidden={label ? undefined : true}
|
||||
className={[
|
||||
'platform-icon-badge',
|
||||
'grid shrink-0 place-items-center',
|
||||
PLATFORM_ICON_BADGE_SIZE_CLASS[size],
|
||||
PLATFORM_ICON_BADGE_SHAPE_CLASS[shape],
|
||||
PLATFORM_ICON_BADGE_TONE_CLASS[tone],
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -8,13 +8,11 @@ import type {
|
||||
} from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
import { IconButton, type IconButtonProps, type IconButtonSize } from './ui';
|
||||
|
||||
export type PlatformIconButtonVariant =
|
||||
| 'platformIcon'
|
||||
| 'surfaceFloating'
|
||||
| 'darkMini';
|
||||
export type PlatformIconButtonSize = IconButtonSize;
|
||||
export type PlatformIconButtonSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
type PlatformIconButtonBaseProps = {
|
||||
label: string;
|
||||
@@ -24,8 +22,8 @@ type PlatformIconButtonBaseProps = {
|
||||
};
|
||||
|
||||
type PlatformIconButtonButtonProps = Omit<
|
||||
IconButtonProps,
|
||||
'variant' | 'children'
|
||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
'aria-label' | 'children'
|
||||
> &
|
||||
PlatformIconButtonBaseProps & {
|
||||
asChild?: false;
|
||||
@@ -53,12 +51,10 @@ export type PlatformIconButtonProps =
|
||||
| PlatformIconButtonLabelProps
|
||||
| PlatformIconButtonSpanButtonProps;
|
||||
|
||||
const variantClassName: Record<PlatformIconButtonVariant, string> = {
|
||||
platformIcon: 'genarrative-ui-icon-button--surface-platform',
|
||||
surfaceFloating: 'genarrative-ui-icon-button--surface-floating',
|
||||
darkMini: 'genarrative-ui-icon-button--surface-dark-mini',
|
||||
};
|
||||
|
||||
/**
|
||||
* 平台通用图标动作按钮。
|
||||
* 统一承接纯图标动作、图标上传 label 和带短标签的浮动图标动作。
|
||||
*/
|
||||
export const PlatformIconButton = forwardRef<
|
||||
HTMLButtonElement | HTMLLabelElement | HTMLSpanElement,
|
||||
PlatformIconButtonProps
|
||||
@@ -75,13 +71,15 @@ export const PlatformIconButton = forwardRef<
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const resolvedVariant = variant === 'darkMini' ? 'quiet' : 'default';
|
||||
const actionClassName = [
|
||||
'genarrative-ui-icon-button',
|
||||
`genarrative-ui-icon-button--${resolvedVariant}`,
|
||||
variantClassName[variant],
|
||||
className,
|
||||
]
|
||||
const variantClassName = {
|
||||
platformIcon: 'platform-icon-button',
|
||||
surfaceFloating:
|
||||
'inline-flex items-center justify-center rounded-full border border-white/80 bg-white/94 text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[var(--platform-accent)] disabled:cursor-not-allowed disabled:opacity-55',
|
||||
darkMini:
|
||||
'inline-flex items-center justify-center rounded-full border border-white/16 bg-black/55 text-white transition-colors hover:bg-black/70 disabled:cursor-not-allowed disabled:opacity-55',
|
||||
}[variant];
|
||||
|
||||
const actionClassName = [variantClassName, className]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
@@ -91,9 +89,9 @@ export const PlatformIconButton = forwardRef<
|
||||
{...(actionProps as LabelHTMLAttributes<HTMLLabelElement>)}
|
||||
ref={ref as Ref<HTMLLabelElement>}
|
||||
title={title}
|
||||
className={`${actionClassName} genarrative-ui-icon-button--md`}
|
||||
className={actionClassName}
|
||||
>
|
||||
<span className="genarrative-ui-visually-hidden">{label}</span>
|
||||
<span className="sr-only">{label}</span>
|
||||
{icon}
|
||||
{children}
|
||||
</label>
|
||||
@@ -107,7 +105,9 @@ export const PlatformIconButton = forwardRef<
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLSpanElement>) => {
|
||||
onKeyDown?.(event);
|
||||
if (event.defaultPrevented || disabled) return;
|
||||
if (event.defaultPrevented || disabled) {
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
event.currentTarget.click();
|
||||
@@ -118,14 +118,14 @@ export const PlatformIconButton = forwardRef<
|
||||
<span
|
||||
{...spanProps}
|
||||
ref={ref as Ref<HTMLSpanElement>}
|
||||
aria-disabled={disabled || undefined}
|
||||
aria-disabled={disabled}
|
||||
aria-label={label}
|
||||
className={`${actionClassName} genarrative-ui-icon-button--md`}
|
||||
className={actionClassName}
|
||||
onClick={disabled ? undefined : onClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
role="button"
|
||||
tabIndex={disabled ? -1 : (tabIndex ?? 0)}
|
||||
title={title ?? label}
|
||||
title={title}
|
||||
>
|
||||
{icon}
|
||||
{children}
|
||||
@@ -135,17 +135,18 @@ export const PlatformIconButton = forwardRef<
|
||||
|
||||
const { type = 'button', ...buttonProps } =
|
||||
actionProps as ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
<button
|
||||
{...buttonProps}
|
||||
ref={ref as Ref<HTMLButtonElement>}
|
||||
type={type}
|
||||
label={label}
|
||||
icon={icon}
|
||||
children={children}
|
||||
variant={resolvedVariant}
|
||||
aria-label={label}
|
||||
title={title}
|
||||
className={actionClassName}
|
||||
title={title ?? label}
|
||||
/>
|
||||
>
|
||||
{icon}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export type PlatformInfoBlockVariant = 'default' | 'compactRow';
|
||||
|
||||
export type PlatformInfoBlockProps = {
|
||||
label?: ReactNode;
|
||||
children: ReactNode;
|
||||
variant?: PlatformInfoBlockVariant;
|
||||
multiline?: boolean;
|
||||
className?: string;
|
||||
labelClassName?: string;
|
||||
valueClassName?: string;
|
||||
};
|
||||
|
||||
const PLATFORM_INFO_BLOCK_CLASS: Record<PlatformInfoBlockVariant, string> = {
|
||||
default:
|
||||
'rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-2',
|
||||
compactRow:
|
||||
'flex justify-between gap-2 rounded-[0.85rem] border-0 bg-white/74 px-2.5 py-1.5 sm:gap-3 sm:px-3 sm:py-2',
|
||||
};
|
||||
|
||||
const PLATFORM_INFO_BLOCK_LABEL_CLASS: Record<
|
||||
PlatformInfoBlockVariant,
|
||||
string
|
||||
> = {
|
||||
default: 'text-xs font-bold text-[var(--platform-text-soft)]',
|
||||
compactRow: 'text-xs font-bold text-[var(--platform-text-muted)] sm:text-sm',
|
||||
};
|
||||
|
||||
function getValueClassName({
|
||||
hasLabel,
|
||||
multiline,
|
||||
variant,
|
||||
valueClassName,
|
||||
}: {
|
||||
hasLabel: boolean;
|
||||
multiline: boolean;
|
||||
variant: PlatformInfoBlockVariant;
|
||||
valueClassName?: string;
|
||||
}) {
|
||||
if (variant === 'compactRow') {
|
||||
return [
|
||||
'break-words text-right text-xs font-black leading-5 text-[var(--platform-text-strong)] sm:text-sm',
|
||||
valueClassName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
return [
|
||||
'break-words text-sm text-[var(--platform-text-strong)]',
|
||||
hasLabel ? 'mt-1' : null,
|
||||
multiline ? 'whitespace-pre-wrap leading-6' : 'font-semibold leading-5',
|
||||
valueClassName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* 平台信息展示块。
|
||||
* 统一承接弹窗和详情页中“短标签 + 白底内容”的只读信息 chrome。
|
||||
*/
|
||||
export function PlatformInfoBlock({
|
||||
label,
|
||||
children,
|
||||
variant = 'default',
|
||||
multiline = false,
|
||||
className,
|
||||
labelClassName,
|
||||
valueClassName,
|
||||
}: PlatformInfoBlockProps) {
|
||||
return (
|
||||
<div
|
||||
className={[PLATFORM_INFO_BLOCK_CLASS[variant], className]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
{label ? (
|
||||
<div
|
||||
className={[PLATFORM_INFO_BLOCK_LABEL_CLASS[variant], labelClassName]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className={getValueClassName({
|
||||
hasLabel: Boolean(label),
|
||||
multiline,
|
||||
variant,
|
||||
valueClassName,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { ComponentPropsWithoutRef, ReactNode } from 'react';
|
||||
|
||||
export type PlatformNavigableListItemAlign = 'center' | 'start';
|
||||
|
||||
export type PlatformNavigableListItemProps = Omit<
|
||||
ComponentPropsWithoutRef<'button'>,
|
||||
'children' | 'type'
|
||||
> & {
|
||||
children: ReactNode;
|
||||
leading?: ReactNode;
|
||||
trailing?: ReactNode;
|
||||
align?: PlatformNavigableListItemAlign;
|
||||
leadingClassName?: string;
|
||||
trailingClassName?: string;
|
||||
bodyClassName?: string;
|
||||
};
|
||||
|
||||
const ALIGN_CLASS: Record<PlatformNavigableListItemAlign, string> = {
|
||||
center: 'items-center gap-3',
|
||||
start: 'items-start gap-4',
|
||||
};
|
||||
|
||||
/**
|
||||
* 轻量可导航列表行骨架。
|
||||
* 只统一 button 语义与 left-content/right-affordance 结构,不持有业务文案、badge 或封面规则。
|
||||
*/
|
||||
export function PlatformNavigableListItem({
|
||||
children,
|
||||
leading = null,
|
||||
trailing = null,
|
||||
align = 'center',
|
||||
className,
|
||||
leadingClassName,
|
||||
trailingClassName,
|
||||
bodyClassName,
|
||||
...props
|
||||
}: PlatformNavigableListItemProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
'platform-navigable-list-item flex w-full min-w-0 text-left',
|
||||
ALIGN_CLASS[align],
|
||||
className ?? null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
{...props}
|
||||
>
|
||||
{leading ? (
|
||||
<div
|
||||
className={[
|
||||
'platform-navigable-list-item__leading shrink-0',
|
||||
leadingClassName ?? null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
{leading}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={[
|
||||
'platform-navigable-list-item__body min-w-0 flex-1',
|
||||
bodyClassName ?? null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{trailing ? (
|
||||
<div
|
||||
className={[
|
||||
'platform-navigable-list-item__trailing shrink-0',
|
||||
trailingClassName ?? null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
{trailing}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,45 @@
|
||||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
import {
|
||||
getPlatformPillBadgeClassName,
|
||||
type PlatformPillBadgeSize,
|
||||
type PlatformPillBadgeTone,
|
||||
} from './platformPillBadgeModel';
|
||||
|
||||
export type {
|
||||
BadgeProps as PlatformPillBadgeProps,
|
||||
BadgeTone as PlatformPillBadgeTone,
|
||||
} from './ui';
|
||||
export { Badge as PlatformPillBadge } from './ui';
|
||||
PlatformPillBadgeSize,
|
||||
PlatformPillBadgeTone,
|
||||
} from './platformPillBadgeModel';
|
||||
|
||||
export type PlatformPillBadgeProps = Omit<
|
||||
HTMLAttributes<HTMLSpanElement>,
|
||||
'children'
|
||||
> & {
|
||||
tone?: PlatformPillBadgeTone;
|
||||
size?: PlatformPillBadgeSize;
|
||||
icon?: ReactNode;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* 平台胶囊状态标签。
|
||||
* 统一承接结果页、作品卡和配置摘要里的小型状态 / 标签 chip。
|
||||
*/
|
||||
export function PlatformPillBadge({
|
||||
tone = 'neutral',
|
||||
size = 'sm',
|
||||
icon,
|
||||
children,
|
||||
className,
|
||||
...spanProps
|
||||
}: PlatformPillBadgeProps) {
|
||||
return (
|
||||
<span
|
||||
{...spanProps}
|
||||
className={getPlatformPillBadgeClassName({ tone, size, className })}
|
||||
>
|
||||
{icon}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
|
||||
export type PlatformProgressBarSize = 'xs' | 'sm' | 'md' | 'lg';
|
||||
|
||||
export type PlatformProgressBarProps = {
|
||||
value: number;
|
||||
minVisibleValue?: number;
|
||||
size?: PlatformProgressBarSize;
|
||||
ariaLabel?: string;
|
||||
labelledBy?: string;
|
||||
indeterminate?: boolean;
|
||||
className?: string;
|
||||
fillClassName?: string;
|
||||
fillStyle?: CSSProperties;
|
||||
trackStyle?: CSSProperties;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
const PLATFORM_PROGRESS_BAR_SIZE_CLASS: Record<
|
||||
PlatformProgressBarSize,
|
||||
string
|
||||
> = {
|
||||
xs: 'h-2',
|
||||
sm: 'h-2.5',
|
||||
md: 'h-3',
|
||||
lg: 'h-12',
|
||||
};
|
||||
|
||||
function clampProgressValue(value: number) {
|
||||
if (!Number.isFinite(value)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.min(100, Math.max(0, Math.round(value)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 平台通用进度条。
|
||||
* 统一承接 progressbar 语义、platform-progress-track 壳和填充宽度计算。
|
||||
*/
|
||||
export function PlatformProgressBar({
|
||||
value,
|
||||
minVisibleValue = 0,
|
||||
size = 'xs',
|
||||
ariaLabel,
|
||||
labelledBy,
|
||||
indeterminate = false,
|
||||
className,
|
||||
fillClassName,
|
||||
fillStyle,
|
||||
trackStyle,
|
||||
children,
|
||||
}: PlatformProgressBarProps) {
|
||||
const progress = clampProgressValue(value);
|
||||
const visibleProgress =
|
||||
progress <= 0 ? 0 : Math.max(minVisibleValue, progress);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={indeterminate ? undefined : progress}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={labelledBy}
|
||||
className={[
|
||||
'platform-progress-track relative overflow-hidden rounded-full',
|
||||
PLATFORM_PROGRESS_BAR_SIZE_CLASS[size],
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={trackStyle}
|
||||
>
|
||||
<div
|
||||
className={[
|
||||
'h-full rounded-full transition-[width] duration-300',
|
||||
fillClassName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={{
|
||||
width: `${visibleProgress}%`,
|
||||
...fillStyle,
|
||||
}}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
export type PlatformRuntimeStatusTone =
|
||||
| 'error'
|
||||
| 'success'
|
||||
| 'info'
|
||||
| 'warning'
|
||||
| 'neutral';
|
||||
export type PlatformRuntimeStatusSurface = 'light' | 'dark' | 'solid';
|
||||
export type PlatformRuntimeStatusSize = 'xs' | 'sm' | 'md';
|
||||
export type PlatformRuntimeStatusShape = 'pill' | 'rounded';
|
||||
|
||||
export type PlatformRuntimeStatusToastProps = Omit<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
'children'
|
||||
> & {
|
||||
tone: PlatformRuntimeStatusTone;
|
||||
surface?: PlatformRuntimeStatusSurface;
|
||||
size?: PlatformRuntimeStatusSize;
|
||||
shape?: PlatformRuntimeStatusShape;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const PLATFORM_RUNTIME_STATUS_SURFACE_CLASS: Record<
|
||||
PlatformRuntimeStatusSurface,
|
||||
Record<PlatformRuntimeStatusTone, string>
|
||||
> = {
|
||||
light: {
|
||||
error:
|
||||
'border-white/70 bg-white/86 text-[var(--platform-button-danger-text)]',
|
||||
success: 'border-white/70 bg-white/86 text-[var(--platform-success-text)]',
|
||||
info: 'border-white/70 bg-white/86 text-[var(--platform-cool-text)]',
|
||||
warning: 'border-white/70 bg-white/86 text-[var(--platform-warm-text)]',
|
||||
neutral: 'border-white/70 bg-white/86 text-[var(--platform-text-base)]',
|
||||
},
|
||||
dark: {
|
||||
error: 'border-rose-200/35 bg-rose-400/18 text-rose-50',
|
||||
success: 'border-emerald-200/35 bg-emerald-400/18 text-emerald-50',
|
||||
info: 'border-sky-200/30 bg-sky-400/16 text-sky-50',
|
||||
warning: 'border-amber-200/35 bg-amber-400/18 text-amber-50',
|
||||
neutral: 'border-white/15 bg-white/12 text-white/82',
|
||||
},
|
||||
solid: {
|
||||
error: 'border-transparent bg-rose-600 text-white',
|
||||
success: 'border-transparent bg-emerald-600 text-white',
|
||||
info: 'border-transparent bg-sky-600 text-white',
|
||||
warning: 'border-transparent bg-amber-500 text-amber-950',
|
||||
neutral: 'border-transparent bg-slate-800 text-white',
|
||||
},
|
||||
};
|
||||
|
||||
const PLATFORM_RUNTIME_STATUS_SIZE_CLASS: Record<
|
||||
PlatformRuntimeStatusSize,
|
||||
string
|
||||
> = {
|
||||
xs: 'px-3 py-1 text-xs leading-5',
|
||||
sm: 'px-3 py-2 text-sm leading-5',
|
||||
md: 'px-4 py-3 text-sm leading-6',
|
||||
};
|
||||
|
||||
const PLATFORM_RUNTIME_STATUS_SHAPE_CLASS: Record<
|
||||
PlatformRuntimeStatusShape,
|
||||
string
|
||||
> = {
|
||||
pill: 'rounded-full',
|
||||
rounded: 'rounded-[1.2rem]',
|
||||
};
|
||||
|
||||
/**
|
||||
* 运行态 HUD 状态提示。
|
||||
* 收口游戏内短错误、成功和反馈 chip,位置与玩法强品牌视觉仍由调用方控制。
|
||||
*/
|
||||
export function PlatformRuntimeStatusToast({
|
||||
tone,
|
||||
surface = 'light',
|
||||
size = 'sm',
|
||||
shape = 'pill',
|
||||
children,
|
||||
className,
|
||||
role,
|
||||
...divProps
|
||||
}: PlatformRuntimeStatusToastProps) {
|
||||
const ariaLive = divProps['aria-live'];
|
||||
|
||||
return (
|
||||
<div
|
||||
{...divProps}
|
||||
role={role ?? (tone === 'error' ? 'alert' : 'status')}
|
||||
aria-live={ariaLive ?? (tone === 'error' ? 'assertive' : 'polite')}
|
||||
className={[
|
||||
'platform-runtime-status-toast inline-flex max-w-full items-center justify-center gap-1.5 border text-center font-black shadow-sm backdrop-blur',
|
||||
PLATFORM_RUNTIME_STATUS_SIZE_CLASS[size],
|
||||
PLATFORM_RUNTIME_STATUS_SHAPE_CLASS[shape],
|
||||
PLATFORM_RUNTIME_STATUS_SURFACE_CLASS[surface][tone],
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export type PlatformSegmentedTabsColumns =
|
||||
| 'one'
|
||||
| 'two'
|
||||
| 'three'
|
||||
| 'four'
|
||||
| 'threeToSix';
|
||||
export type PlatformSegmentedTabsGap = 'sm' | 'md';
|
||||
export type PlatformSegmentedTabsRadius = 'md' | 'lg' | 'xl';
|
||||
export type PlatformSegmentedTabsSize =
|
||||
| 'sm'
|
||||
| 'md'
|
||||
| 'compact'
|
||||
| 'choice'
|
||||
| 'tab';
|
||||
export type PlatformSegmentedTabsSurface = 'default' | 'soft' | 'transparent';
|
||||
export type PlatformSegmentedTabsTone =
|
||||
| 'neutral'
|
||||
| 'warm'
|
||||
| 'rose'
|
||||
| 'underline';
|
||||
export type PlatformSegmentedTabsFrame = 'panel' | 'bare';
|
||||
export type PlatformSegmentedTabsSemantics = 'segment' | 'tabs';
|
||||
export type PlatformSegmentedTabsLayout = 'grid' | 'scroll';
|
||||
|
||||
export type PlatformSegmentedTabItem<TId extends string = string> = {
|
||||
id: TId;
|
||||
label: ReactNode;
|
||||
ariaLabel?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export type PlatformSegmentedTabsProps<TId extends string = string> = {
|
||||
items: readonly PlatformSegmentedTabItem<TId>[];
|
||||
activeId: TId;
|
||||
onChange: (id: TId) => void;
|
||||
columns?: PlatformSegmentedTabsColumns;
|
||||
gap?: PlatformSegmentedTabsGap;
|
||||
radius?: PlatformSegmentedTabsRadius;
|
||||
size?: PlatformSegmentedTabsSize;
|
||||
surface?: PlatformSegmentedTabsSurface;
|
||||
tone?: PlatformSegmentedTabsTone;
|
||||
frame?: PlatformSegmentedTabsFrame;
|
||||
semantics?: PlatformSegmentedTabsSemantics;
|
||||
layout?: PlatformSegmentedTabsLayout;
|
||||
ariaLabel?: string;
|
||||
truncateLabels?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
itemClassName?:
|
||||
| string
|
||||
| ((item: PlatformSegmentedTabItem<TId>, active: boolean) => string | null);
|
||||
};
|
||||
|
||||
const PLATFORM_SEGMENTED_TABS_COLUMNS_CLASS: Record<
|
||||
PlatformSegmentedTabsColumns,
|
||||
string
|
||||
> = {
|
||||
one: 'grid-cols-1',
|
||||
two: 'grid-cols-2',
|
||||
three: 'grid-cols-3',
|
||||
four: 'grid-cols-4',
|
||||
threeToSix: 'grid-cols-3 sm:grid-cols-6',
|
||||
};
|
||||
|
||||
const PLATFORM_SEGMENTED_TABS_GAP_CLASS: Record<
|
||||
PlatformSegmentedTabsGap,
|
||||
string
|
||||
> = {
|
||||
sm: 'gap-1',
|
||||
md: 'gap-2',
|
||||
};
|
||||
|
||||
const PLATFORM_SEGMENTED_TABS_RADIUS_CLASS: Record<
|
||||
PlatformSegmentedTabsRadius,
|
||||
string
|
||||
> = {
|
||||
md: 'rounded-[1rem]',
|
||||
lg: 'rounded-[1.1rem]',
|
||||
xl: 'rounded-[1.25rem]',
|
||||
};
|
||||
|
||||
const PLATFORM_SEGMENTED_TABS_SURFACE_CLASS: Record<
|
||||
PlatformSegmentedTabsSurface,
|
||||
string
|
||||
> = {
|
||||
default: 'bg-white/62',
|
||||
soft: 'bg-white/58',
|
||||
transparent: 'bg-transparent',
|
||||
};
|
||||
|
||||
const PLATFORM_SEGMENTED_TABS_FRAME_CLASS: Record<
|
||||
PlatformSegmentedTabsFrame,
|
||||
string
|
||||
> = {
|
||||
panel: 'border border-[var(--platform-subpanel-border)] p-1',
|
||||
bare: 'border-0 p-0',
|
||||
};
|
||||
|
||||
const PLATFORM_SEGMENTED_TABS_ITEM_SIZE_CLASS: Record<
|
||||
PlatformSegmentedTabsSize,
|
||||
string
|
||||
> = {
|
||||
sm: 'min-h-10 rounded-[0.9rem] px-3 text-sm font-bold',
|
||||
md: 'min-h-10 rounded-[1rem] px-3 text-sm font-bold',
|
||||
compact: 'min-h-10 rounded-[0.8rem] px-2 text-xs font-black sm:text-sm',
|
||||
choice: 'min-h-10 rounded-[0.9rem] px-1.5 py-2 text-center',
|
||||
tab: 'relative h-12 rounded-none px-2 text-base font-semibold sm:text-lg',
|
||||
};
|
||||
|
||||
const PLATFORM_SEGMENTED_TABS_TONE_CLASS: Record<
|
||||
PlatformSegmentedTabsTone,
|
||||
{ active: string; idle: string }
|
||||
> = {
|
||||
neutral: {
|
||||
active: 'bg-white text-[var(--platform-text-strong)] shadow-sm',
|
||||
idle: 'text-[var(--platform-text-base)] hover:bg-white/60',
|
||||
},
|
||||
warm: {
|
||||
active:
|
||||
'bg-[var(--platform-warm-bg)] text-[var(--platform-text-strong)] shadow-[inset_0_0_0_1px_rgba(204,117,76,0.18)]',
|
||||
idle: 'text-[var(--platform-text-base)] hover:bg-white/58',
|
||||
},
|
||||
rose: {
|
||||
active:
|
||||
'border border-[#ff7890] bg-[linear-gradient(180deg,#ff7890_0%,#ff4f6a_100%)] text-white shadow-[0_8px_18px_rgba(244,63,94,0.16)]',
|
||||
idle: 'border border-[var(--platform-subpanel-border)] bg-white/76 text-[var(--platform-text-strong)] hover:border-[var(--platform-surface-hover-border)] hover:bg-white',
|
||||
},
|
||||
underline: {
|
||||
active: 'text-[var(--platform-text-strong)]',
|
||||
idle: 'text-[var(--platform-text-muted)] hover:text-[var(--platform-text-base)]',
|
||||
},
|
||||
};
|
||||
|
||||
function resolveItemClassName<TId extends string>({
|
||||
active,
|
||||
disabled,
|
||||
item,
|
||||
itemClassName,
|
||||
size,
|
||||
tone,
|
||||
}: {
|
||||
active: boolean;
|
||||
disabled: boolean;
|
||||
item: PlatformSegmentedTabItem<TId>;
|
||||
itemClassName?: PlatformSegmentedTabsProps<TId>['itemClassName'];
|
||||
size: PlatformSegmentedTabsSize;
|
||||
tone: PlatformSegmentedTabsTone;
|
||||
}) {
|
||||
const extraClassName =
|
||||
typeof itemClassName === 'function'
|
||||
? itemClassName(item, active)
|
||||
: itemClassName;
|
||||
|
||||
return [
|
||||
'min-w-0 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--platform-warm-border)]',
|
||||
PLATFORM_SEGMENTED_TABS_ITEM_SIZE_CLASS[size],
|
||||
active
|
||||
? PLATFORM_SEGMENTED_TABS_TONE_CLASS[tone].active
|
||||
: PLATFORM_SEGMENTED_TABS_TONE_CLASS[tone].idle,
|
||||
disabled ? 'cursor-not-allowed opacity-55 hover:bg-transparent' : null,
|
||||
extraClassName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/** 平台白底分段选择控件,统一承接 tab / segment button chrome。 */
|
||||
export function PlatformSegmentedTabs<TId extends string = string>({
|
||||
items,
|
||||
activeId,
|
||||
onChange,
|
||||
columns = 'two',
|
||||
gap = 'md',
|
||||
radius = 'xl',
|
||||
size = 'md',
|
||||
surface = 'default',
|
||||
tone = 'neutral',
|
||||
frame = 'panel',
|
||||
semantics = 'segment',
|
||||
layout = 'grid',
|
||||
ariaLabel,
|
||||
truncateLabels = false,
|
||||
disabled = false,
|
||||
className,
|
||||
itemClassName,
|
||||
}: PlatformSegmentedTabsProps<TId>) {
|
||||
return (
|
||||
<div
|
||||
role={semantics === 'tabs' ? 'tablist' : undefined}
|
||||
aria-label={semantics === 'tabs' ? ariaLabel : undefined}
|
||||
className={[
|
||||
layout === 'scroll'
|
||||
? 'flex min-w-0 items-center overflow-x-auto scrollbar-hide'
|
||||
: 'grid',
|
||||
PLATFORM_SEGMENTED_TABS_FRAME_CLASS[frame],
|
||||
PLATFORM_SEGMENTED_TABS_GAP_CLASS[gap],
|
||||
layout === 'grid'
|
||||
? PLATFORM_SEGMENTED_TABS_COLUMNS_CLASS[columns]
|
||||
: null,
|
||||
PLATFORM_SEGMENTED_TABS_RADIUS_CLASS[radius],
|
||||
PLATFORM_SEGMENTED_TABS_SURFACE_CLASS[surface],
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
{items.map((item) => {
|
||||
const active = activeId === item.id;
|
||||
const itemDisabled = disabled || Boolean(item.disabled);
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
role={semantics === 'tabs' ? 'tab' : undefined}
|
||||
aria-label={item.ariaLabel}
|
||||
aria-selected={semantics === 'tabs' ? active : undefined}
|
||||
aria-pressed={semantics === 'segment' ? active : undefined}
|
||||
disabled={itemDisabled}
|
||||
onClick={() => {
|
||||
if (!itemDisabled) {
|
||||
onChange(item.id);
|
||||
}
|
||||
}}
|
||||
className={resolveItemClassName({
|
||||
active,
|
||||
disabled: itemDisabled,
|
||||
item,
|
||||
itemClassName,
|
||||
size,
|
||||
tone,
|
||||
})}
|
||||
>
|
||||
{truncateLabels ? (
|
||||
<span className="block truncate">{item.label}</span>
|
||||
) : (
|
||||
item.label
|
||||
)}
|
||||
{tone === 'underline' && active ? (
|
||||
<span className="absolute bottom-1 left-1/2 h-1 w-12 -translate-x-1/2 rounded-full bg-[var(--platform-accent)]" />
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export type PlatformStatGridColumns = 'two' | 'three' | 'four' | 'twoToFour';
|
||||
export type PlatformStatGridDensity = 'compact' | 'default';
|
||||
export type PlatformStatGridOrder = 'valueFirst' | 'labelFirst';
|
||||
export type PlatformStatGridSurface = 'soft' | 'plain';
|
||||
export type PlatformStatGridTextAlign = 'left' | 'center';
|
||||
|
||||
export type PlatformStatGridItem = {
|
||||
label?: ReactNode;
|
||||
value: ReactNode;
|
||||
key?: string;
|
||||
};
|
||||
|
||||
export type PlatformStatGridProps = {
|
||||
items: readonly PlatformStatGridItem[];
|
||||
columns?: PlatformStatGridColumns;
|
||||
density?: PlatformStatGridDensity;
|
||||
order?: PlatformStatGridOrder;
|
||||
surface?: PlatformStatGridSurface;
|
||||
textAlign?: PlatformStatGridTextAlign;
|
||||
className?: string;
|
||||
itemClassName?:
|
||||
| string
|
||||
| ((item: PlatformStatGridItem, index: number) => string | null);
|
||||
};
|
||||
|
||||
const PLATFORM_STAT_GRID_COLUMNS_CLASS: Record<
|
||||
PlatformStatGridColumns,
|
||||
string
|
||||
> = {
|
||||
two: 'grid-cols-2',
|
||||
three: 'grid-cols-3',
|
||||
four: 'grid-cols-4',
|
||||
twoToFour: 'grid-cols-2 sm:grid-cols-4',
|
||||
};
|
||||
|
||||
const PLATFORM_STAT_GRID_DENSITY_CLASS: Record<
|
||||
PlatformStatGridDensity,
|
||||
{ item: string; value: string; label: string }
|
||||
> = {
|
||||
compact: {
|
||||
item: 'rounded-[1rem] px-2 py-2',
|
||||
value: 'text-sm font-black',
|
||||
label: 'mt-1 text-[0.68rem] font-bold tracking-[0.14em]',
|
||||
},
|
||||
default: {
|
||||
item: 'rounded-[1rem] px-3 py-3',
|
||||
value: 'text-lg font-black',
|
||||
label: 'mt-1 text-[11px] font-bold tracking-[0.14em]',
|
||||
},
|
||||
};
|
||||
|
||||
const PLATFORM_STAT_GRID_SURFACE_CLASS: Record<
|
||||
PlatformStatGridSurface,
|
||||
string
|
||||
> = {
|
||||
soft: 'bg-white/76',
|
||||
plain: 'border border-[var(--platform-subpanel-border)] bg-white/68',
|
||||
};
|
||||
|
||||
const PLATFORM_STAT_GRID_TEXT_ALIGN_CLASS: Record<
|
||||
PlatformStatGridTextAlign,
|
||||
string
|
||||
> = {
|
||||
left: 'text-left',
|
||||
center: 'text-center',
|
||||
};
|
||||
|
||||
/**
|
||||
* 平台统计小卡网格。
|
||||
* 统一承接结果页里的“数值 / 标签”或轻量状态 chip 布局。
|
||||
*/
|
||||
export function PlatformStatGrid({
|
||||
items,
|
||||
columns = 'three',
|
||||
density = 'default',
|
||||
order = 'valueFirst',
|
||||
surface = 'soft',
|
||||
textAlign = 'center',
|
||||
className,
|
||||
itemClassName,
|
||||
}: PlatformStatGridProps) {
|
||||
const densityClass = PLATFORM_STAT_GRID_DENSITY_CLASS[density];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
'grid gap-2',
|
||||
PLATFORM_STAT_GRID_COLUMNS_CLASS[columns],
|
||||
PLATFORM_STAT_GRID_TEXT_ALIGN_CLASS[textAlign],
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
{items.map((item, index) => {
|
||||
const extraClassName =
|
||||
typeof itemClassName === 'function'
|
||||
? itemClassName(item, index)
|
||||
: itemClassName;
|
||||
const key =
|
||||
item.key ??
|
||||
(typeof item.label === 'string'
|
||||
? item.label
|
||||
: typeof item.value === 'string'
|
||||
? item.value
|
||||
: index);
|
||||
|
||||
const valueNode = (
|
||||
<div
|
||||
className={[
|
||||
densityClass.value,
|
||||
'text-[var(--platform-text-strong)]',
|
||||
].join(' ')}
|
||||
>
|
||||
{item.value}
|
||||
</div>
|
||||
);
|
||||
const labelNode = item.label ? (
|
||||
<div
|
||||
className={[
|
||||
densityClass.label,
|
||||
'text-[var(--platform-text-soft)]',
|
||||
].join(' ')}
|
||||
>
|
||||
{item.label}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={[
|
||||
densityClass.item,
|
||||
PLATFORM_STAT_GRID_SURFACE_CLASS[surface],
|
||||
extraClassName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
{order === 'labelFirst' ? (
|
||||
<>
|
||||
{labelNode}
|
||||
{valueNode}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{valueNode}
|
||||
{labelNode}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,113 @@
|
||||
export type {
|
||||
StatusProps as PlatformStatusMessageProps,
|
||||
StatusTone as PlatformStatusMessageTone,
|
||||
} from './ui';
|
||||
export { Status as PlatformStatusMessage } from './ui';
|
||||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
export type PlatformStatusMessageTone =
|
||||
| 'error'
|
||||
| 'success'
|
||||
| 'info'
|
||||
| 'warning'
|
||||
| 'neutral';
|
||||
export type PlatformStatusMessageSurface =
|
||||
| 'light'
|
||||
| 'tinted'
|
||||
| 'platform'
|
||||
| 'profile'
|
||||
| 'editorDark';
|
||||
export type PlatformStatusMessageSize = 'xs' | 'sm' | 'md';
|
||||
|
||||
export type PlatformStatusMessageProps = Omit<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
'children'
|
||||
> & {
|
||||
tone: PlatformStatusMessageTone;
|
||||
surface?: PlatformStatusMessageSurface;
|
||||
size?: PlatformStatusMessageSize;
|
||||
remapSurface?: boolean;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const PLATFORM_STATUS_MESSAGE_CLASS_BY_SURFACE_AND_TONE: Record<
|
||||
PlatformStatusMessageSurface,
|
||||
Record<PlatformStatusMessageTone, string>
|
||||
> = {
|
||||
light: {
|
||||
error: 'border-rose-200 bg-rose-50 text-rose-700',
|
||||
success: 'border-emerald-200 bg-emerald-50 text-emerald-700',
|
||||
info: 'border-[var(--platform-cool-border)] bg-[var(--platform-cool-bg)] text-[var(--platform-cool-text)]',
|
||||
warning: 'border-amber-200 bg-amber-50 text-amber-900',
|
||||
neutral:
|
||||
'border-[var(--platform-subpanel-border)] bg-white/68 text-[var(--platform-text-base)]',
|
||||
},
|
||||
tinted: {
|
||||
error: 'border-rose-400/20 bg-rose-500/10 text-rose-700',
|
||||
success: 'border-emerald-300/25 bg-emerald-500/12 text-emerald-700',
|
||||
info: 'border-[var(--platform-cool-border)] bg-[var(--platform-cool-bg)] text-[var(--platform-cool-text)]',
|
||||
warning: 'border-amber-300/30 bg-amber-500/12 text-amber-900',
|
||||
neutral:
|
||||
'border-[var(--platform-subpanel-border)] bg-white/20 text-[var(--platform-text-base)]',
|
||||
},
|
||||
platform: {
|
||||
error:
|
||||
'border-[var(--platform-button-danger-border)] bg-[var(--platform-button-danger-fill)] text-[var(--platform-button-danger-text)]',
|
||||
success:
|
||||
'border-[var(--platform-success-border)] bg-[var(--platform-success-bg)] text-[var(--platform-success-text)]',
|
||||
info: 'border-[var(--platform-cool-border)] bg-[var(--platform-cool-bg)] text-[var(--platform-cool-text)]',
|
||||
warning:
|
||||
'border-[var(--platform-warm-border)] bg-[var(--platform-warm-bg)] text-[var(--platform-warm-text)]',
|
||||
neutral:
|
||||
'border-[var(--platform-subpanel-border)] bg-white/68 text-[var(--platform-text-base)]',
|
||||
},
|
||||
profile: {
|
||||
error:
|
||||
'border-[var(--platform-button-danger-border)] bg-[var(--platform-button-danger-fill)] text-[var(--platform-button-danger-text)]',
|
||||
success:
|
||||
'border-[var(--platform-success-border)] bg-[var(--platform-success-bg)] text-[var(--platform-success-text)]',
|
||||
info: 'border-[var(--platform-cool-border)] bg-[var(--platform-cool-bg)] text-[var(--platform-cool-text)]',
|
||||
warning: 'border-amber-300 bg-amber-500/12 text-amber-900',
|
||||
neutral:
|
||||
'border-[var(--platform-subpanel-border)] bg-white/68 text-[var(--platform-text-base)]',
|
||||
},
|
||||
editorDark: {
|
||||
error: 'border-rose-300/15 bg-rose-500/10 text-rose-50/90',
|
||||
success: 'border-emerald-300/15 bg-emerald-500/10 text-emerald-50/90',
|
||||
info: 'border-sky-300/15 bg-sky-500/10 text-sky-50/90',
|
||||
warning: 'border-amber-300/15 bg-amber-500/10 text-amber-50/90',
|
||||
neutral: 'border-white/10 bg-black/20 text-zinc-300',
|
||||
},
|
||||
};
|
||||
|
||||
const PLATFORM_STATUS_MESSAGE_SIZE_CLASS: Record<
|
||||
PlatformStatusMessageSize,
|
||||
string
|
||||
> = {
|
||||
xs: 'px-3 py-2 text-xs',
|
||||
sm: 'px-3 py-2 text-sm',
|
||||
md: 'px-4 py-3 text-sm leading-6',
|
||||
};
|
||||
|
||||
/** 平台通用状态提示条。 */
|
||||
export function PlatformStatusMessage({
|
||||
tone,
|
||||
surface = 'light',
|
||||
size = 'sm',
|
||||
remapSurface = false,
|
||||
children,
|
||||
className,
|
||||
...divProps
|
||||
}: PlatformStatusMessageProps) {
|
||||
return (
|
||||
<div
|
||||
{...divProps}
|
||||
className={[
|
||||
remapSurface ? 'platform-remap-surface' : null,
|
||||
'platform-status-message rounded-xl border',
|
||||
PLATFORM_STATUS_MESSAGE_SIZE_CLASS[size],
|
||||
PLATFORM_STATUS_MESSAGE_CLASS_BY_SURFACE_AND_TONE[surface][tone],
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,2 +1,223 @@
|
||||
export type { SubpanelProps as PlatformSubpanelProps } from './ui';
|
||||
export { Subpanel as PlatformSubpanel } from './ui';
|
||||
import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
import { PlatformFieldLabel } from './PlatformFieldLabel';
|
||||
|
||||
export type PlatformSubpanelStaticElement =
|
||||
| 'section'
|
||||
| 'div'
|
||||
| 'article'
|
||||
| 'aside';
|
||||
export type PlatformSubpanelElement = PlatformSubpanelStaticElement | 'button';
|
||||
export type PlatformSubpanelPadding =
|
||||
| 'tight'
|
||||
| 'row'
|
||||
| 'xs'
|
||||
| 'sm'
|
||||
| 'md'
|
||||
| 'lg'
|
||||
| 'none';
|
||||
export type PlatformSubpanelRadius = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
export type PlatformSubpanelSurface =
|
||||
| 'platform'
|
||||
| 'flat'
|
||||
| 'soft'
|
||||
| 'dark'
|
||||
| 'darkSky'
|
||||
| 'darkEmerald'
|
||||
| 'darkAmber'
|
||||
| 'darkRose'
|
||||
| 'danger';
|
||||
export type PlatformSubpanelTitleVariant = 'section' | 'strong';
|
||||
|
||||
type PlatformSubpanelBaseProps = {
|
||||
as?: PlatformSubpanelElement;
|
||||
title?: ReactNode;
|
||||
titleVariant?: PlatformSubpanelTitleVariant;
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
interactive?: boolean;
|
||||
padding?: PlatformSubpanelPadding;
|
||||
radius?: PlatformSubpanelRadius;
|
||||
surface?: PlatformSubpanelSurface;
|
||||
className?: string;
|
||||
headerClassName?: string;
|
||||
titleClassName?: string;
|
||||
actionsClassName?: string;
|
||||
bodyClassName?: string;
|
||||
};
|
||||
|
||||
type PlatformSubpanelStaticProps = PlatformSubpanelBaseProps &
|
||||
Omit<HTMLAttributes<HTMLElement>, 'children' | 'className' | 'title'> & {
|
||||
as?: PlatformSubpanelStaticElement;
|
||||
};
|
||||
|
||||
type PlatformSubpanelButtonProps = PlatformSubpanelBaseProps &
|
||||
Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children' | 'title'> & {
|
||||
as: 'button';
|
||||
};
|
||||
|
||||
export type PlatformSubpanelProps =
|
||||
| PlatformSubpanelStaticProps
|
||||
| PlatformSubpanelButtonProps;
|
||||
|
||||
const PLATFORM_SUBPANEL_PADDING_CLASS: Record<PlatformSubpanelPadding, string> =
|
||||
{
|
||||
tight: 'p-2',
|
||||
row: 'px-3 py-2',
|
||||
xs: 'px-3 py-2.5',
|
||||
sm: 'p-3',
|
||||
md: 'p-4',
|
||||
lg: 'p-4 sm:p-5',
|
||||
none: 'p-0',
|
||||
};
|
||||
|
||||
const PLATFORM_SUBPANEL_RADIUS_CLASS: Record<PlatformSubpanelRadius, string> = {
|
||||
xs: 'rounded-xl',
|
||||
sm: 'rounded-[1rem]',
|
||||
md: 'rounded-[1.25rem]',
|
||||
lg: 'rounded-[1.35rem]',
|
||||
xl: 'rounded-[1.5rem]',
|
||||
};
|
||||
|
||||
const PLATFORM_SUBPANEL_SURFACE_CLASS: Record<PlatformSubpanelSurface, string> =
|
||||
{
|
||||
platform: 'platform-subpanel',
|
||||
flat: 'border border-[var(--platform-subpanel-border)] bg-white/72',
|
||||
soft: 'border border-[var(--platform-subpanel-border)] bg-white/68',
|
||||
dark: 'border border-white/10 bg-black/25 text-zinc-100',
|
||||
darkSky: 'border border-sky-400/18 bg-sky-500/8 text-sky-50',
|
||||
darkEmerald:
|
||||
'border border-emerald-400/18 bg-emerald-500/8 text-emerald-100/85',
|
||||
darkAmber: 'border border-amber-300/18 bg-amber-500/8 text-amber-50',
|
||||
darkRose: 'border border-rose-300/18 bg-rose-500/8 text-rose-50',
|
||||
danger:
|
||||
'border border-[var(--platform-button-danger-border)] bg-[var(--platform-button-danger-fill)]',
|
||||
};
|
||||
|
||||
const PLATFORM_SUBPANEL_INTERACTIVE_CLASS =
|
||||
'text-left transition hover:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--platform-warm-border)] disabled:cursor-not-allowed disabled:opacity-55';
|
||||
|
||||
function renderSubpanelTitle({
|
||||
title,
|
||||
titleClassName,
|
||||
titleVariant,
|
||||
}: {
|
||||
title: ReactNode;
|
||||
titleClassName?: string;
|
||||
titleVariant: PlatformSubpanelTitleVariant;
|
||||
}) {
|
||||
if (titleVariant === 'strong') {
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
'text-sm font-black text-[var(--platform-text-strong)]',
|
||||
titleClassName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PlatformFieldLabel variant="section" className={titleClassName}>
|
||||
{title}
|
||||
</PlatformFieldLabel>
|
||||
);
|
||||
}
|
||||
|
||||
/** 平台白底子面板,统一承接结果页和创作工作台里的子面板外壳。 */
|
||||
export function PlatformSubpanel({
|
||||
as: Component = 'section',
|
||||
title,
|
||||
titleVariant = 'section',
|
||||
actions,
|
||||
children,
|
||||
interactive = false,
|
||||
padding = 'md',
|
||||
radius = 'md',
|
||||
surface = 'platform',
|
||||
className,
|
||||
headerClassName,
|
||||
titleClassName,
|
||||
actionsClassName,
|
||||
bodyClassName,
|
||||
...elementProps
|
||||
}: PlatformSubpanelProps) {
|
||||
const hasHeader = Boolean(title) || Boolean(actions);
|
||||
const subpanelClassName = [
|
||||
PLATFORM_SUBPANEL_SURFACE_CLASS[surface],
|
||||
PLATFORM_SUBPANEL_RADIUS_CLASS[radius],
|
||||
PLATFORM_SUBPANEL_PADDING_CLASS[padding],
|
||||
interactive ? PLATFORM_SUBPANEL_INTERACTIVE_CLASS : null,
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
const content = (
|
||||
<>
|
||||
{hasHeader ? (
|
||||
<div
|
||||
className={[
|
||||
'flex items-center justify-between gap-3',
|
||||
headerClassName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
{title
|
||||
? renderSubpanelTitle({
|
||||
title,
|
||||
titleClassName,
|
||||
titleVariant,
|
||||
})
|
||||
: null}
|
||||
</div>
|
||||
{actions ? (
|
||||
<div
|
||||
className={['flex shrink-0 items-center gap-2', actionsClassName]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
{actions}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{bodyClassName ? (
|
||||
<div className={bodyClassName}>{children}</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
if (Component === 'button') {
|
||||
const { type = 'button', ...buttonProps } = elementProps as Omit<
|
||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
'children' | 'title'
|
||||
>;
|
||||
|
||||
return (
|
||||
<button {...buttonProps} type={type} className={subpanelClassName}>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const StaticComponent = Component;
|
||||
const staticProps = elementProps as Omit<
|
||||
HTMLAttributes<HTMLElement>,
|
||||
'children' | 'className' | 'title'
|
||||
>;
|
||||
|
||||
return (
|
||||
<StaticComponent {...staticProps} className={subpanelClassName}>
|
||||
{content}
|
||||
</StaticComponent>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,184 @@
|
||||
export type {
|
||||
SelectFieldProps as PlatformSelectFieldProps,
|
||||
TextFieldProps as PlatformTextFieldProps,
|
||||
} from './ui';
|
||||
export {
|
||||
SelectField as PlatformSelectField,
|
||||
TextField as PlatformTextField,
|
||||
} from './ui';
|
||||
import type {
|
||||
InputHTMLAttributes,
|
||||
SelectHTMLAttributes,
|
||||
TextareaHTMLAttributes,
|
||||
} from 'react';
|
||||
|
||||
export type PlatformTextFieldSize = 'xs' | 'sm' | 'md' | 'lg';
|
||||
export type PlatformTextFieldDensity = 'default' | 'compact' | 'roomy';
|
||||
export type PlatformTextFieldTone = 'warm' | 'rose' | 'emerald' | 'sky';
|
||||
export type PlatformTextFieldSurface = 'platform' | 'editorDark';
|
||||
|
||||
type PlatformTextFieldBaseProps = {
|
||||
size?: PlatformTextFieldSize;
|
||||
density?: PlatformTextFieldDensity;
|
||||
tone?: PlatformTextFieldTone;
|
||||
surface?: PlatformTextFieldSurface;
|
||||
};
|
||||
|
||||
type PlatformTextFieldInputProps = Omit<
|
||||
InputHTMLAttributes<HTMLInputElement>,
|
||||
'size'
|
||||
> &
|
||||
PlatformTextFieldBaseProps & {
|
||||
variant?: 'input';
|
||||
};
|
||||
|
||||
type PlatformTextFieldTextareaProps =
|
||||
TextareaHTMLAttributes<HTMLTextAreaElement> &
|
||||
PlatformTextFieldBaseProps & {
|
||||
variant: 'textarea';
|
||||
};
|
||||
|
||||
export type PlatformTextFieldProps =
|
||||
| PlatformTextFieldInputProps
|
||||
| PlatformTextFieldTextareaProps;
|
||||
|
||||
export type PlatformSelectFieldProps = Omit<
|
||||
SelectHTMLAttributes<HTMLSelectElement>,
|
||||
'size'
|
||||
> &
|
||||
PlatformTextFieldBaseProps;
|
||||
|
||||
const PLATFORM_TEXT_FIELD_SIZE_CLASS: Record<PlatformTextFieldSize, string> = {
|
||||
xs: 'text-sm leading-5',
|
||||
sm: 'text-sm font-semibold',
|
||||
md: 'text-sm leading-6',
|
||||
lg: 'text-base font-semibold',
|
||||
};
|
||||
|
||||
const PLATFORM_TEXT_FIELD_PLATFORM_DENSITY_CLASS: Record<
|
||||
PlatformTextFieldDensity,
|
||||
string
|
||||
> = {
|
||||
default: 'rounded-[1rem] bg-white/86 px-3 py-3',
|
||||
compact: 'rounded-[0.85rem] bg-white/90 px-3 py-2',
|
||||
roomy: 'rounded-[1rem] bg-white/90 px-4 py-3',
|
||||
};
|
||||
|
||||
const PLATFORM_TEXT_FIELD_EDITOR_DARK_DENSITY_CLASS: Record<
|
||||
PlatformTextFieldDensity,
|
||||
string
|
||||
> = {
|
||||
default: 'rounded-[1rem] bg-black/30 px-3 py-3',
|
||||
compact: 'rounded-[0.85rem] bg-black/30 px-3 py-2',
|
||||
roomy: 'rounded-[1rem] bg-black/30 px-4 py-3',
|
||||
};
|
||||
|
||||
const PLATFORM_TEXT_FIELD_PLATFORM_TONE_CLASS: Record<
|
||||
PlatformTextFieldTone,
|
||||
string
|
||||
> = {
|
||||
warm: 'focus:border-[var(--platform-surface-hover-border)] focus:ring-[var(--platform-warm-border)]',
|
||||
rose: 'focus:border-rose-200 focus:ring-rose-100',
|
||||
emerald: 'focus:border-emerald-200 focus:ring-emerald-100',
|
||||
sky: 'focus:border-sky-200 focus:ring-sky-100',
|
||||
};
|
||||
|
||||
const PLATFORM_TEXT_FIELD_EDITOR_DARK_TONE_CLASS: Record<
|
||||
PlatformTextFieldTone,
|
||||
string
|
||||
> = {
|
||||
warm: 'focus:border-amber-300/40',
|
||||
rose: 'focus:border-rose-300/40',
|
||||
emerald: 'focus:border-emerald-400/40',
|
||||
sky: 'focus:border-sky-400/40',
|
||||
};
|
||||
|
||||
function buildPlatformTextFieldClassName({
|
||||
surface,
|
||||
size,
|
||||
density,
|
||||
tone,
|
||||
multiline,
|
||||
className,
|
||||
}: {
|
||||
surface: PlatformTextFieldSurface;
|
||||
size: PlatformTextFieldSize;
|
||||
density: PlatformTextFieldDensity;
|
||||
tone: PlatformTextFieldTone;
|
||||
multiline?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const isEditorDark = surface === 'editorDark';
|
||||
|
||||
return [
|
||||
'w-full platform-text-field outline-none transition disabled:cursor-not-allowed disabled:opacity-60',
|
||||
isEditorDark
|
||||
? 'platform-text-field--editor-dark border border-white/10 text-white'
|
||||
: 'border border-[var(--platform-subpanel-border)] text-[var(--platform-text-strong)] focus:bg-white focus:ring-2',
|
||||
isEditorDark
|
||||
? PLATFORM_TEXT_FIELD_EDITOR_DARK_DENSITY_CLASS[density]
|
||||
: PLATFORM_TEXT_FIELD_PLATFORM_DENSITY_CLASS[density],
|
||||
PLATFORM_TEXT_FIELD_SIZE_CLASS[size],
|
||||
isEditorDark
|
||||
? PLATFORM_TEXT_FIELD_EDITOR_DARK_TONE_CLASS[tone]
|
||||
: PLATFORM_TEXT_FIELD_PLATFORM_TONE_CLASS[tone],
|
||||
multiline ? 'resize-none' : null,
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* 平台白底表单输入框。
|
||||
* 统一承接结果页 / 工作台白底圆角输入框和文本域的基础 chrome。
|
||||
*/
|
||||
export function PlatformTextField({
|
||||
variant = 'input',
|
||||
size = 'sm',
|
||||
density = 'default',
|
||||
tone = 'warm',
|
||||
surface = 'platform',
|
||||
className,
|
||||
...fieldProps
|
||||
}: PlatformTextFieldProps) {
|
||||
const fieldClassName = buildPlatformTextFieldClassName({
|
||||
surface,
|
||||
size,
|
||||
density,
|
||||
tone,
|
||||
multiline: variant === 'textarea',
|
||||
className,
|
||||
});
|
||||
|
||||
if (variant === 'textarea') {
|
||||
return (
|
||||
<textarea
|
||||
{...(fieldProps as TextareaHTMLAttributes<HTMLTextAreaElement>)}
|
||||
className={fieldClassName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
{...(fieldProps as InputHTMLAttributes<HTMLInputElement>)}
|
||||
className={fieldClassName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 平台白底下拉框。
|
||||
* 与 PlatformTextField 共享输入 chrome,避免业务页为同一组表单控件重复拼样式。
|
||||
*/
|
||||
export function PlatformSelectField({
|
||||
size = 'sm',
|
||||
density = 'default',
|
||||
tone = 'warm',
|
||||
surface = 'platform',
|
||||
className,
|
||||
...selectProps
|
||||
}: PlatformSelectFieldProps) {
|
||||
const fieldClassName = buildPlatformTextFieldClassName({
|
||||
surface,
|
||||
size,
|
||||
density,
|
||||
tone,
|
||||
className,
|
||||
});
|
||||
|
||||
return <select {...selectProps} className={fieldClassName} />;
|
||||
}
|
||||
|
||||
@@ -7,31 +7,115 @@ export type {
|
||||
PlatformActionButtonTone,
|
||||
} from './PlatformActionButton';
|
||||
export { PlatformActionButton } from './PlatformActionButton';
|
||||
export { getPlatformActionButtonClassName } from './platformActionButtonModel';
|
||||
export type { PlatformAsyncStatePanelProps } from './PlatformAsyncStatePanel';
|
||||
export { PlatformAsyncStatePanel } from './PlatformAsyncStatePanel';
|
||||
export type { PlatformBadgeProps, PlatformBadgeTone } from './PlatformBadge';
|
||||
export { PlatformBadge } from './PlatformBadge';
|
||||
export type { PlatformEmptyStateProps } from './PlatformEmptyState';
|
||||
export type {
|
||||
PlatformEmptyStateProps,
|
||||
PlatformEmptyStateSize,
|
||||
PlatformEmptyStateSurface,
|
||||
PlatformEmptyStateTone,
|
||||
} from './PlatformEmptyState';
|
||||
export { PlatformEmptyState } from './PlatformEmptyState';
|
||||
export type {
|
||||
PlatformFieldLabelProps,
|
||||
PlatformFieldLabelVariant,
|
||||
} from './PlatformFieldLabel';
|
||||
export { PlatformFieldLabel } from './PlatformFieldLabel';
|
||||
export type {
|
||||
PlatformFilterToolbarProps,
|
||||
PlatformFilterToolbarTabItem,
|
||||
} from './PlatformFilterToolbar';
|
||||
export { PlatformFilterToolbar } from './PlatformFilterToolbar';
|
||||
export type {
|
||||
PlatformIconBadgeProps,
|
||||
PlatformIconBadgeShape,
|
||||
PlatformIconBadgeSize,
|
||||
PlatformIconBadgeTone,
|
||||
} from './PlatformIconBadge';
|
||||
export { PlatformIconBadge } from './PlatformIconBadge';
|
||||
export type {
|
||||
PlatformIconButtonProps,
|
||||
PlatformIconButtonSize,
|
||||
PlatformIconButtonVariant,
|
||||
} from './PlatformIconButton';
|
||||
export { PlatformIconButton } from './PlatformIconButton';
|
||||
export type {
|
||||
PlatformInfoBlockProps,
|
||||
PlatformInfoBlockVariant,
|
||||
} from './PlatformInfoBlock';
|
||||
export { PlatformInfoBlock } from './PlatformInfoBlock';
|
||||
export type { PlatformModalProps } from './PlatformModal';
|
||||
export { PlatformModal } from './PlatformModal';
|
||||
export type {
|
||||
PlatformNavigableListItemAlign,
|
||||
PlatformNavigableListItemProps,
|
||||
} from './PlatformNavigableListItem';
|
||||
export { PlatformNavigableListItem } from './PlatformNavigableListItem';
|
||||
export type {
|
||||
PlatformPillBadgeProps,
|
||||
PlatformPillBadgeSize,
|
||||
PlatformPillBadgeTone,
|
||||
} from './PlatformPillBadge';
|
||||
export { PlatformPillBadge } from './PlatformPillBadge';
|
||||
export { getPlatformPillBadgeClassName } from './platformPillBadgeModel';
|
||||
export type {
|
||||
PlatformProgressBarProps,
|
||||
PlatformProgressBarSize,
|
||||
} from './PlatformProgressBar';
|
||||
export { PlatformProgressBar } from './PlatformProgressBar';
|
||||
export type {
|
||||
PlatformRuntimeStatusShape,
|
||||
PlatformRuntimeStatusSize,
|
||||
PlatformRuntimeStatusSurface,
|
||||
PlatformRuntimeStatusToastProps,
|
||||
PlatformRuntimeStatusTone,
|
||||
} from './PlatformRuntimeStatusToast';
|
||||
export { PlatformRuntimeStatusToast } from './PlatformRuntimeStatusToast';
|
||||
export type {
|
||||
PlatformSegmentedTabItem,
|
||||
PlatformSegmentedTabsColumns,
|
||||
PlatformSegmentedTabsFrame,
|
||||
PlatformSegmentedTabsGap,
|
||||
PlatformSegmentedTabsLayout,
|
||||
PlatformSegmentedTabsProps,
|
||||
PlatformSegmentedTabsRadius,
|
||||
PlatformSegmentedTabsSemantics,
|
||||
PlatformSegmentedTabsSize,
|
||||
PlatformSegmentedTabsSurface,
|
||||
PlatformSegmentedTabsTone,
|
||||
} from './PlatformSegmentedTabs';
|
||||
export { PlatformSegmentedTabs } from './PlatformSegmentedTabs';
|
||||
export type {
|
||||
PlatformStatGridColumns,
|
||||
PlatformStatGridDensity,
|
||||
PlatformStatGridItem,
|
||||
PlatformStatGridOrder,
|
||||
PlatformStatGridProps,
|
||||
PlatformStatGridSurface,
|
||||
PlatformStatGridTextAlign,
|
||||
} from './PlatformStatGrid';
|
||||
export { PlatformStatGrid } from './PlatformStatGrid';
|
||||
export type { PlatformStatusProps, PlatformStatusTone } from './PlatformStatus';
|
||||
export { PlatformStatus } from './PlatformStatus';
|
||||
export type {
|
||||
PlatformStatusMessageProps,
|
||||
PlatformStatusMessageSize,
|
||||
PlatformStatusMessageSurface,
|
||||
PlatformStatusMessageTone,
|
||||
} from './PlatformStatusMessage';
|
||||
export { PlatformStatusMessage } from './PlatformStatusMessage';
|
||||
export type { PlatformSubpanelProps } from './PlatformSubpanel';
|
||||
export type {
|
||||
PlatformSubpanelElement,
|
||||
PlatformSubpanelPadding,
|
||||
PlatformSubpanelProps,
|
||||
PlatformSubpanelRadius,
|
||||
PlatformSubpanelStaticElement,
|
||||
PlatformSubpanelSurface,
|
||||
PlatformSubpanelTitleVariant,
|
||||
} from './PlatformSubpanel';
|
||||
export { PlatformSubpanel } from './PlatformSubpanel';
|
||||
export type {
|
||||
PlatformSelectFieldProps,
|
||||
@@ -42,10 +126,12 @@ export type {
|
||||
BadgeProps,
|
||||
BadgeTone,
|
||||
ButtonProps,
|
||||
CheckboxProps,
|
||||
EmptyStateProps,
|
||||
IconButtonProps,
|
||||
IconButtonSize,
|
||||
IconButtonVariant,
|
||||
LabelProps,
|
||||
ModalProps,
|
||||
ProgressBarProps,
|
||||
SegmentedTabItem,
|
||||
@@ -53,26 +139,39 @@ export type {
|
||||
SelectFieldProps,
|
||||
SharedButtonSize,
|
||||
SharedButtonVariant,
|
||||
SkeletonProps,
|
||||
StatusProps,
|
||||
StatusTone,
|
||||
SubpanelProps,
|
||||
SwitchProps,
|
||||
TableProps,
|
||||
TextFieldProps,
|
||||
} from './ui';
|
||||
export {
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
Divider,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Label,
|
||||
Modal,
|
||||
ProgressBar,
|
||||
SegmentedTabs,
|
||||
SelectField,
|
||||
Skeleton,
|
||||
Spinner,
|
||||
Status,
|
||||
Subpanel,
|
||||
Switch,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCaption,
|
||||
TableCell,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TextField,
|
||||
} from './ui';
|
||||
export type { BadgeProps as ShadcnBadgeProps } from './ui/badge';
|
||||
@@ -110,16 +209,11 @@ export { Textarea } from './ui/textarea';
|
||||
// existing application adapters while keeping the public API product-neutral.
|
||||
export type {
|
||||
ButtonProps as PlatformButtonProps,
|
||||
ProgressBarProps as PlatformProgressBarProps,
|
||||
SegmentedTabItem as PlatformSegmentedTabItem,
|
||||
SegmentedTabsProps as PlatformSegmentedTabsProps,
|
||||
SwitchProps as PlatformSwitchProps,
|
||||
} from './ui';
|
||||
export {
|
||||
Button as PlatformButton,
|
||||
Divider as PlatformDivider,
|
||||
ProgressBar as PlatformProgressBar,
|
||||
SegmentedTabs as PlatformSegmentedTabs,
|
||||
Spinner as PlatformSpinner,
|
||||
Switch as PlatformSwitch,
|
||||
} from './ui';
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
export type PlatformActionButtonTone =
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'ghost'
|
||||
| 'danger'
|
||||
| 'success'
|
||||
| 'warning'
|
||||
| 'accent'
|
||||
| 'accentSoft';
|
||||
export type PlatformActionButtonSurface = 'platform' | 'profile' | 'editorDark';
|
||||
export type PlatformActionButtonSize = 'xxs' | 'xs' | 'sm' | 'md' | 'lg';
|
||||
export type PlatformActionButtonShape = 'default' | 'pill';
|
||||
export type PlatformActionButtonAlign = 'center' | 'start';
|
||||
|
||||
const PLATFORM_ACTION_BUTTON_PLATFORM_TONE_CLASS: Record<
|
||||
PlatformActionButtonTone,
|
||||
string
|
||||
> = {
|
||||
primary: 'platform-button platform-button--primary',
|
||||
secondary: 'platform-button platform-button--secondary',
|
||||
ghost: 'platform-button platform-button--ghost',
|
||||
danger: 'platform-button platform-button--danger',
|
||||
success: 'platform-button platform-button--primary',
|
||||
warning: 'platform-button platform-button--secondary',
|
||||
accent:
|
||||
'platform-action-button platform-action-button--accent border border-amber-200/70 bg-amber-200 text-slate-950 hover:bg-amber-100',
|
||||
accentSoft:
|
||||
'platform-action-button platform-action-button--accent-soft [border-color:color-mix(in_srgb,var(--platform-action-accent,#c7653d)_24%,transparent)] [background:color-mix(in_srgb,var(--platform-action-accent,#c7653d)_10%,var(--platform-panel-fill)_90%)] [color:var(--platform-action-accent,#c7653d)] [box-shadow:0_0.55rem_1.2rem_color-mix(in_srgb,var(--platform-action-accent,#c7653d)_10%,transparent)]',
|
||||
};
|
||||
|
||||
const PLATFORM_ACTION_BUTTON_PROFILE_TONE_CLASS: Record<
|
||||
PlatformActionButtonTone,
|
||||
string
|
||||
> = {
|
||||
primary: 'platform-primary-button',
|
||||
secondary: 'platform-button platform-button--secondary',
|
||||
ghost: 'platform-button platform-button--ghost',
|
||||
danger: 'platform-button platform-button--danger',
|
||||
success: 'platform-primary-button',
|
||||
warning: 'platform-button platform-button--secondary',
|
||||
accent:
|
||||
'platform-action-button platform-action-button--accent border border-amber-200/70 bg-amber-200 text-slate-950 hover:bg-amber-100',
|
||||
accentSoft:
|
||||
'platform-action-button platform-action-button--accent-soft [border-color:color-mix(in_srgb,var(--platform-action-accent,#c7653d)_24%,transparent)] [background:color-mix(in_srgb,var(--platform-action-accent,#c7653d)_10%,var(--platform-panel-fill)_90%)] [color:var(--platform-action-accent,#c7653d)] [box-shadow:0_0.55rem_1.2rem_color-mix(in_srgb,var(--platform-action-accent,#c7653d)_10%,transparent)]',
|
||||
};
|
||||
|
||||
const PLATFORM_ACTION_BUTTON_EDITOR_DARK_TONE_CLASS: Record<
|
||||
PlatformActionButtonTone,
|
||||
string
|
||||
> = {
|
||||
primary:
|
||||
'platform-action-button platform-action-button--editor-dark border border-sky-300/35 bg-sky-400 text-slate-950 hover:bg-sky-300',
|
||||
secondary:
|
||||
'platform-action-button platform-action-button--editor-dark border border-white/10 bg-white/5 text-zinc-200 hover:bg-white/10 hover:text-white',
|
||||
ghost:
|
||||
'platform-action-button platform-action-button--editor-dark border border-white/10 bg-black/20 text-zinc-200 hover:text-white',
|
||||
danger:
|
||||
'platform-action-button platform-action-button--editor-dark border border-rose-300/25 bg-rose-500/10 text-rose-100 hover:bg-rose-500/15',
|
||||
success:
|
||||
'platform-action-button platform-action-button--editor-dark border border-emerald-300/35 bg-emerald-400 text-slate-950 hover:bg-emerald-300',
|
||||
warning:
|
||||
'platform-action-button platform-action-button--editor-dark border border-amber-300/30 bg-amber-500/20 text-amber-50 hover:bg-amber-500/25',
|
||||
accent:
|
||||
'platform-action-button platform-action-button--accent border border-amber-200/70 bg-amber-200 text-slate-950 hover:bg-amber-100',
|
||||
accentSoft:
|
||||
'platform-action-button platform-action-button--accent-soft [border-color:color-mix(in_srgb,var(--platform-action-accent,#c7653d)_24%,transparent)] [background:color-mix(in_srgb,var(--platform-action-accent,#c7653d)_10%,rgba(0,0,0,0.72)_90%)] [color:var(--platform-action-accent,#f5c36a)] [box-shadow:0_0.55rem_1.2rem_color-mix(in_srgb,var(--platform-action-accent,#f5c36a)_10%,transparent)]',
|
||||
};
|
||||
|
||||
const PLATFORM_ACTION_BUTTON_SIZE_CLASS: Record<
|
||||
PlatformActionButtonSize,
|
||||
string
|
||||
> = {
|
||||
xxs: 'px-3 py-1 text-[10px]',
|
||||
xs: 'px-4 py-2 text-xs',
|
||||
sm: 'px-4 py-2.5 text-sm',
|
||||
md: 'px-4 py-3 text-sm',
|
||||
lg: 'h-12 px-4 text-base',
|
||||
};
|
||||
|
||||
const PLATFORM_ACTION_BUTTON_SHAPE_CLASS: Record<
|
||||
PlatformActionButtonShape,
|
||||
string
|
||||
> = {
|
||||
default: 'rounded-2xl',
|
||||
pill: 'rounded-full',
|
||||
};
|
||||
|
||||
const PLATFORM_ACTION_BUTTON_ALIGN_CLASS: Record<
|
||||
PlatformActionButtonAlign,
|
||||
string
|
||||
> = {
|
||||
center: 'justify-center',
|
||||
start: 'justify-start text-left',
|
||||
};
|
||||
|
||||
function getPlatformActionButtonToneClass(
|
||||
surface: PlatformActionButtonSurface,
|
||||
tone: PlatformActionButtonTone,
|
||||
) {
|
||||
if (surface === 'editorDark') {
|
||||
return PLATFORM_ACTION_BUTTON_EDITOR_DARK_TONE_CLASS[tone];
|
||||
}
|
||||
|
||||
return surface === 'profile'
|
||||
? PLATFORM_ACTION_BUTTON_PROFILE_TONE_CLASS[tone]
|
||||
: PLATFORM_ACTION_BUTTON_PLATFORM_TONE_CLASS[tone];
|
||||
}
|
||||
|
||||
export function getPlatformActionButtonClassName({
|
||||
surface = 'platform',
|
||||
tone = 'primary',
|
||||
size = 'sm',
|
||||
shape = 'default',
|
||||
align = 'center',
|
||||
fullWidth = false,
|
||||
}: {
|
||||
surface?: PlatformActionButtonSurface;
|
||||
tone?: PlatformActionButtonTone;
|
||||
size?: PlatformActionButtonSize;
|
||||
shape?: PlatformActionButtonShape;
|
||||
align?: PlatformActionButtonAlign;
|
||||
fullWidth?: boolean;
|
||||
}) {
|
||||
return [
|
||||
getPlatformActionButtonToneClass(surface, tone),
|
||||
PLATFORM_ACTION_BUTTON_SIZE_CLASS[size],
|
||||
PLATFORM_ACTION_BUTTON_SHAPE_CLASS[shape],
|
||||
PLATFORM_ACTION_BUTTON_ALIGN_CLASS[align],
|
||||
fullWidth ? 'w-full' : null,
|
||||
'inline-flex items-center gap-2 transition-colors font-black disabled:cursor-not-allowed disabled:opacity-60',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
PlatformAsyncStatePanel,
|
||||
PlatformFilterToolbar,
|
||||
PlatformIconBadge,
|
||||
PlatformInfoBlock,
|
||||
PlatformNavigableListItem,
|
||||
PlatformStatGrid,
|
||||
} from './index';
|
||||
|
||||
describe('shared platform components', () => {
|
||||
test('exposes display primitives with their visual variants', () => {
|
||||
render(
|
||||
<>
|
||||
<PlatformInfoBlock label="来源" variant="compactRow">
|
||||
拼图草稿
|
||||
</PlatformInfoBlock>
|
||||
<PlatformIconBadge icon="+" label="新增" size="lg" tone="success" />
|
||||
<PlatformStatGrid
|
||||
items={[{ label: '作品数', value: '24' }]}
|
||||
columns="two"
|
||||
order="labelFirst"
|
||||
surface="plain"
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('来源').className).toContain('text-xs');
|
||||
expect(screen.getByLabelText('新增')).toBeTruthy();
|
||||
expect(screen.getByLabelText('新增').className).toContain('h-12');
|
||||
expect(screen.getByText('作品数').parentElement?.className).toContain(
|
||||
'border',
|
||||
);
|
||||
});
|
||||
|
||||
test('preserves list row button semantics and callback slots', () => {
|
||||
const onClick = vi.fn();
|
||||
render(
|
||||
<PlatformNavigableListItem
|
||||
leading={<span>图标</span>}
|
||||
trailing={<span>前往</span>}
|
||||
onClick={onClick}
|
||||
>
|
||||
项目工作台
|
||||
</PlatformNavigableListItem>,
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', {
|
||||
name: /图标.*项目工作台.*前往/u,
|
||||
});
|
||||
expect(button.getAttribute('type')).toBe('button');
|
||||
fireEvent.click(button);
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('keeps async state precedence explicit', () => {
|
||||
const { rerender } = render(
|
||||
<PlatformAsyncStatePanel
|
||||
errorState={<span>错误</span>}
|
||||
isLoading
|
||||
loadingState={<span>加载中</span>}
|
||||
isEmpty
|
||||
emptyState={<span>空态</span>}
|
||||
>
|
||||
<span>内容</span>
|
||||
</PlatformAsyncStatePanel>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('错误')).toBeTruthy();
|
||||
rerender(
|
||||
<PlatformAsyncStatePanel isLoading loadingState={<span>加载中</span>}>
|
||||
<span>内容</span>
|
||||
</PlatformAsyncStatePanel>,
|
||||
);
|
||||
expect(screen.getByText('加载中')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('forwards filter, tab and sort actions in both layout modes', () => {
|
||||
const onOpenFilter = vi.fn();
|
||||
const onTabChange = vi.fn();
|
||||
const onToggleSort = vi.fn();
|
||||
render(
|
||||
<PlatformFilterToolbar
|
||||
filterLabel="筛选"
|
||||
filterCount={2}
|
||||
tabItems={[{ id: 'all', label: '全部' }]}
|
||||
activeTabId="all"
|
||||
sortLabel="最新"
|
||||
layout="mobile"
|
||||
onOpenFilter={onOpenFilter}
|
||||
onTabChange={onTabChange}
|
||||
onToggleSort={onToggleSort}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /筛选/u }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /按最新排序/u }));
|
||||
expect(onOpenFilter).toHaveBeenCalledTimes(1);
|
||||
expect(onToggleSort).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '全部' }));
|
||||
expect(onTabChange).toHaveBeenCalledWith('all');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
export type PlatformPillBadgeTone =
|
||||
| 'muted'
|
||||
| 'neutral'
|
||||
| 'neutralSolid'
|
||||
| 'lightOverlay'
|
||||
| 'success'
|
||||
| 'warning'
|
||||
| 'danger'
|
||||
| 'cool'
|
||||
| 'profile'
|
||||
| 'profileAccent'
|
||||
| 'darkSoft'
|
||||
| 'darkNeutral'
|
||||
| 'darkSky'
|
||||
| 'darkEmerald'
|
||||
| 'darkAmber'
|
||||
| 'darkRose';
|
||||
|
||||
export type PlatformPillBadgeSize = 'xxs' | 'xs' | 'sm';
|
||||
|
||||
const PLATFORM_PILL_BADGE_TONE_CLASS: Record<PlatformPillBadgeTone, string> = {
|
||||
muted:
|
||||
'border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)] text-[var(--platform-text-soft)]',
|
||||
neutral:
|
||||
'border-[var(--platform-subpanel-border)] bg-white/72 text-[var(--platform-text-base)]',
|
||||
neutralSolid:
|
||||
'border-transparent bg-[var(--platform-neutral-bg)] text-[var(--platform-neutral-text)]',
|
||||
lightOverlay: 'border-white/30 bg-white/24 text-current',
|
||||
success: 'border-emerald-200 bg-emerald-50 text-emerald-700',
|
||||
warning:
|
||||
'border-[var(--platform-warm-border)] bg-[var(--platform-warm-bg)] text-[var(--platform-warm-text)]',
|
||||
danger:
|
||||
'border-[var(--platform-button-danger-border)] bg-[var(--platform-button-danger-fill)] text-[var(--platform-button-danger-text)]',
|
||||
cool: 'border-[var(--platform-cool-border)] bg-[var(--platform-cool-bg)] text-[var(--platform-cool-text)]',
|
||||
profile: 'border-rose-100 bg-rose-50 text-zinc-600',
|
||||
profileAccent: 'border-rose-100 bg-rose-50 text-[#ff4056]',
|
||||
darkSoft: 'border-white/10 bg-white/6 text-zinc-100',
|
||||
darkNeutral: 'border-white/10 bg-black/20 text-zinc-200',
|
||||
darkSky: 'border-sky-300/20 bg-sky-500/10 text-sky-100',
|
||||
darkEmerald: 'border-emerald-300/20 bg-emerald-500/10 text-emerald-100',
|
||||
darkAmber: 'border-amber-300/20 bg-amber-500/10 text-amber-100',
|
||||
darkRose: 'border-rose-300/20 bg-rose-500/10 text-rose-100',
|
||||
};
|
||||
|
||||
const PLATFORM_PILL_BADGE_SIZE_CLASS: Record<PlatformPillBadgeSize, string> = {
|
||||
xxs: 'px-2.5 py-1 text-[10px]',
|
||||
xs: 'px-2.5 py-0.5 text-[11px]',
|
||||
sm: 'px-3 py-1 text-xs',
|
||||
};
|
||||
|
||||
export function getPlatformPillBadgeClassName({
|
||||
tone = 'neutral',
|
||||
size = 'sm',
|
||||
className,
|
||||
}: {
|
||||
tone?: PlatformPillBadgeTone;
|
||||
size?: PlatformPillBadgeSize;
|
||||
className?: string;
|
||||
} = {}) {
|
||||
return [
|
||||
'inline-flex items-center gap-1 rounded-full border font-black',
|
||||
PLATFORM_PILL_BADGE_SIZE_CLASS[size],
|
||||
PLATFORM_PILL_BADGE_TONE_CLASS[tone],
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
@@ -25,6 +25,140 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.genarrative-ui-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
color: var(--platform-text-strong, #3d1f10);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.genarrative-ui-checkbox {
|
||||
display: inline-grid;
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
appearance: none;
|
||||
border: 1px solid var(--platform-neutral-border, rgba(226, 203, 184, 0.88));
|
||||
border-radius: 0.35rem;
|
||||
background: var(--platform-button-secondary-fill, rgba(255, 253, 250, 0.88));
|
||||
color: var(--platform-button-primary-text, #fffaf5);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 160ms ease,
|
||||
border-color 160ms ease,
|
||||
box-shadow 160ms ease,
|
||||
transform 160ms ease;
|
||||
}
|
||||
|
||||
.genarrative-ui-checkbox::after {
|
||||
content: '✓';
|
||||
font-size: 0.75rem;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
opacity: 0;
|
||||
transform: scale(0.7);
|
||||
transition:
|
||||
opacity 120ms ease,
|
||||
transform 120ms ease;
|
||||
}
|
||||
|
||||
.genarrative-ui-checkbox:checked {
|
||||
border-color: var(--platform-button-primary-border, rgba(182, 98, 63, 0.32));
|
||||
background: var(--platform-button-primary-fill, #c7653d);
|
||||
}
|
||||
|
||||
.genarrative-ui-checkbox:checked::after {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.genarrative-ui-checkbox:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.genarrative-ui-checkbox:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.genarrative-ui-checkbox:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--genarrative-ui-focus-ring);
|
||||
}
|
||||
|
||||
.genarrative-ui-checkbox:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.genarrative-ui-skeleton {
|
||||
min-height: 1rem;
|
||||
border-radius: 0.55rem;
|
||||
background: linear-gradient(
|
||||
100deg,
|
||||
rgba(226, 203, 184, 0.42) 20%,
|
||||
rgba(255, 253, 250, 0.92) 40%,
|
||||
rgba(226, 203, 184, 0.42) 60%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: genarrative-ui-skeleton-shimmer 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes genarrative-ui-skeleton-shimmer {
|
||||
to {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.genarrative-ui-table__container {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--platform-subpanel-border, rgba(226, 203, 184, 0.88));
|
||||
border-radius: 0.9rem;
|
||||
background: var(--platform-subpanel-fill, rgba(255, 253, 250, 0.72));
|
||||
}
|
||||
|
||||
.genarrative-ui-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
color: var(--platform-text-strong, #3d1f10);
|
||||
font-size: 0.8125rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.genarrative-ui-table__head,
|
||||
.genarrative-ui-table__cell {
|
||||
padding: 0.7rem 0.85rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.genarrative-ui-table__header {
|
||||
background: rgba(226, 203, 184, 0.2);
|
||||
color: var(--platform-text-soft, #755a49);
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.genarrative-ui-table__row + .genarrative-ui-table__row {
|
||||
border-top: 1px solid var(--platform-line-soft, rgba(226, 203, 184, 0.72));
|
||||
}
|
||||
|
||||
.genarrative-ui-table__body .genarrative-ui-table__row:hover {
|
||||
background: rgba(255, 255, 255, 0.58);
|
||||
}
|
||||
|
||||
.genarrative-ui-table__caption {
|
||||
padding: 0.7rem 0.85rem;
|
||||
color: var(--platform-text-soft, #755a49);
|
||||
font-size: 0.75rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.genarrative-ui-button,
|
||||
.genarrative-ui-icon-button,
|
||||
.genarrative-ui-tabs__item,
|
||||
@@ -190,6 +324,299 @@
|
||||
box-shadow: 0 0 0 3px var(--genarrative-ui-focus-ring);
|
||||
}
|
||||
|
||||
/* Platform-prefixed adapters share these small chrome primitives with the
|
||||
* web host. Keeping the selectors in the package means Tauri consumers do not
|
||||
* need to import the website's large application stylesheet. */
|
||||
.platform-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.55rem;
|
||||
min-height: 2.9rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 1rem;
|
||||
padding: 0.7rem 1rem;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
transition:
|
||||
transform 180ms ease,
|
||||
border-color 180ms ease,
|
||||
background-color 180ms ease,
|
||||
color 180ms ease,
|
||||
opacity 180ms ease,
|
||||
box-shadow 180ms ease;
|
||||
}
|
||||
|
||||
.platform-button:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.platform-button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.platform-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.platform-button--primary {
|
||||
border-color: var(--platform-button-primary-border);
|
||||
background: var(--platform-button-primary-fill);
|
||||
color: var(--platform-button-primary-text);
|
||||
box-shadow: 0 16px 34px rgba(182, 98, 63, 0.18);
|
||||
}
|
||||
|
||||
.platform-button--secondary {
|
||||
background: var(--platform-button-secondary-fill);
|
||||
color: var(--platform-button-secondary-text);
|
||||
}
|
||||
|
||||
.platform-button--ghost {
|
||||
background: var(--platform-button-ghost-fill);
|
||||
color: var(--platform-button-ghost-text);
|
||||
}
|
||||
|
||||
.platform-button--danger {
|
||||
border-color: var(--platform-button-danger-border);
|
||||
background: var(--platform-button-danger-fill);
|
||||
color: var(--platform-button-danger-text);
|
||||
}
|
||||
|
||||
.platform-icon-button {
|
||||
display: inline-flex;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--platform-icon-border);
|
||||
border-radius: 9999px;
|
||||
background: var(--platform-icon-fill);
|
||||
color: var(--platform-icon-text);
|
||||
transition:
|
||||
background-color 180ms ease,
|
||||
border-color 180ms ease,
|
||||
color 180ms ease,
|
||||
transform 180ms ease;
|
||||
}
|
||||
|
||||
.platform-icon-button:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: var(--platform-nav-active-border);
|
||||
background: var(--platform-nav-active-fill);
|
||||
color: var(--platform-text-strong);
|
||||
}
|
||||
|
||||
.platform-subpanel {
|
||||
border: 1px solid var(--platform-subpanel-border);
|
||||
background: var(--platform-subpanel-fill);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.platform-primary-button {
|
||||
border: 1px solid var(--platform-button-primary-border);
|
||||
background: var(--platform-button-primary-fill);
|
||||
color: var(--platform-button-primary-text);
|
||||
box-shadow: var(--platform-profile-action-shadow);
|
||||
transition:
|
||||
transform 180ms ease,
|
||||
filter 180ms ease,
|
||||
opacity 180ms ease;
|
||||
}
|
||||
|
||||
.platform-primary-button:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
filter: brightness(1.02);
|
||||
}
|
||||
|
||||
.platform-progress-track {
|
||||
border: 1px solid var(--platform-track-border);
|
||||
background: var(--platform-track-fill);
|
||||
}
|
||||
|
||||
/* Filter toolbar chrome is part of the shared platform surface so the
|
||||
* canonical toolbar renders the same in the web app and standalone hosts. */
|
||||
.platform-category-filter-row {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--platform-subpanel-border);
|
||||
border-radius: 1.25rem;
|
||||
background: var(--platform-subpanel-fill);
|
||||
padding: 0.55rem 0.65rem;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.platform-category-filter-button,
|
||||
.platform-category-sort-button,
|
||||
.platform-category-chip {
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 160ms ease,
|
||||
border-color 160ms ease,
|
||||
background-color 160ms ease,
|
||||
color 160ms ease,
|
||||
box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.platform-category-filter-button {
|
||||
display: inline-flex;
|
||||
position: relative;
|
||||
min-height: 2.25rem;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
border: 1px solid var(--platform-cool-border);
|
||||
border-radius: 9999px;
|
||||
background: var(--platform-cool-bg);
|
||||
color: var(--platform-cool-text);
|
||||
padding: 0 0.75rem;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 900;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.platform-category-filter-button__count {
|
||||
display: inline-flex;
|
||||
min-width: 1.18rem;
|
||||
height: 1.18rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9999px;
|
||||
background: var(--platform-warm-text);
|
||||
color: var(--platform-button-primary-text);
|
||||
padding: 0 0.28rem;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.platform-category-filter-divider {
|
||||
width: 1px;
|
||||
height: 1.85rem;
|
||||
flex: 0 0 auto;
|
||||
background: var(--platform-line-soft);
|
||||
}
|
||||
|
||||
.platform-category-chip-scroll {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
gap: 0.55rem;
|
||||
overflow-x: auto;
|
||||
padding-right: 0.1rem;
|
||||
}
|
||||
|
||||
.platform-category-chip {
|
||||
display: inline-flex;
|
||||
min-height: 2.25rem;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--platform-subpanel-border);
|
||||
border-radius: 0.78rem;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--platform-text-base);
|
||||
padding: 0 0.92rem;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.platform-category-chip--active {
|
||||
border-color: var(--platform-cool-border);
|
||||
background: var(--platform-cool-bg);
|
||||
color: var(--platform-cool-text);
|
||||
}
|
||||
|
||||
.platform-category-sort-button {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
min-height: 1.75rem;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--platform-text-soft);
|
||||
padding: 0 0.2rem;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.platform-category-filter-button:hover,
|
||||
.platform-category-chip:hover,
|
||||
.platform-category-sort-button:hover {
|
||||
transform: translateY(-1px);
|
||||
filter: brightness(1.03);
|
||||
}
|
||||
|
||||
.platform-category-filter-button:active,
|
||||
.platform-category-chip:active,
|
||||
.platform-category-sort-button:active {
|
||||
transform: translateY(1px);
|
||||
filter: brightness(0.96);
|
||||
transition-duration: 80ms;
|
||||
}
|
||||
|
||||
.platform-category-filter-button:focus-visible,
|
||||
.platform-category-chip:focus-visible,
|
||||
.platform-category-sort-button:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--platform-input-focus-ring);
|
||||
}
|
||||
|
||||
.platform-navigable-list-item {
|
||||
cursor: pointer;
|
||||
border-radius: 0.9rem;
|
||||
transition:
|
||||
transform 160ms ease,
|
||||
background-color 160ms ease,
|
||||
box-shadow 160ms ease,
|
||||
opacity 160ms ease;
|
||||
}
|
||||
|
||||
.platform-navigable-list-item:hover:not(:disabled) {
|
||||
background: var(--platform-button-ghost-fill);
|
||||
}
|
||||
|
||||
.platform-navigable-list-item:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
box-shadow: inset 0 2px 4px rgba(50, 22, 8, 0.1);
|
||||
transition-duration: 80ms;
|
||||
}
|
||||
|
||||
.platform-navigable-list-item:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.platform-navigable-list-item:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--platform-input-focus-ring);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.platform-category-filter-row {
|
||||
border-radius: 1rem;
|
||||
gap: 0.5rem;
|
||||
padding: 0.46rem 0.5rem;
|
||||
}
|
||||
|
||||
.platform-category-filter-button {
|
||||
min-height: 2.05rem;
|
||||
padding-inline: 0.62rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.platform-category-chip {
|
||||
min-height: 2.05rem;
|
||||
border-radius: 0.68rem;
|
||||
padding-inline: 0.78rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
.genarrative-ui-button:disabled,
|
||||
.genarrative-ui-icon-button:disabled,
|
||||
.genarrative-ui-icon-button[aria-disabled='true'],
|
||||
@@ -809,7 +1236,8 @@ textarea.genarrative-ui-text-field__control {
|
||||
.genarrative-ui-tabs__item,
|
||||
.genarrative-ui-switch,
|
||||
.genarrative-ui-progress__fill,
|
||||
.genarrative-ui-spinner {
|
||||
.genarrative-ui-spinner,
|
||||
.genarrative-ui-skeleton {
|
||||
transition: none;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@@ -6,17 +6,27 @@ import { describe, expect, test, vi } from 'vitest';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Label,
|
||||
Modal,
|
||||
PlatformActionButton,
|
||||
PlatformIconButton,
|
||||
ProgressBar,
|
||||
SegmentedTabs,
|
||||
SelectField,
|
||||
Skeleton,
|
||||
Status,
|
||||
Subpanel,
|
||||
Switch,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCaption,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TextField,
|
||||
} from './index';
|
||||
|
||||
@@ -103,6 +113,40 @@ describe('shared UI components', () => {
|
||||
).toBe('true');
|
||||
});
|
||||
|
||||
test('provides semantic label, checkbox, skeleton and table primitives', () => {
|
||||
render(
|
||||
<div className="genarrative-ui">
|
||||
<div>
|
||||
<Checkbox id="autosave" defaultChecked />
|
||||
<Label htmlFor="autosave">自动保存</Label>
|
||||
</div>
|
||||
<Skeleton aria-label="读取中" className="h-3 w-20" />
|
||||
<Table>
|
||||
<TableCaption>最近项目</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>珊瑚岛</TableCell>
|
||||
<TableCell>已发布</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText('自动保存')).toHaveProperty('checked', true);
|
||||
expect(screen.getByLabelText('读取中').getAttribute('data-slot')).toBe(
|
||||
'skeleton',
|
||||
);
|
||||
expect(screen.getByRole('table', { name: '最近项目' })).toBeTruthy();
|
||||
expect(screen.getByRole('cell', { name: '珊瑚岛' })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('closes modal on escape and backdrop click', () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user