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

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

## 验证

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

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

224 lines
5.8 KiB
TypeScript

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