Files
Genarrative/src/components/common/PlatformSubpanel.tsx
kdletters 1ad25e30f8 收口前端平台组件库能力
新增 PlatformUiKit 通用弹窗、按钮、状态、空态、媒体、表单和标签等公共组件
迁移结果页、创作工作台、认证入口、RPG 暗色面板和运行态弹窗的重复 UI chrome
补充组件测试、页面回归测试、技术文档和 Hermes 共享决策记录
2026-06-10 10:24:18 +08:00

223 lines
5.7 KiB
TypeScript

import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from 'react';
import { PlatformFieldLabel } from './PlatformFieldLabel';
type PlatformSubpanelStaticElement = 'section' | 'div' | 'article' | 'aside';
type PlatformSubpanelElement = PlatformSubpanelStaticElement | 'button';
type PlatformSubpanelPadding =
| 'tight'
| 'row'
| 'xs'
| 'sm'
| 'md'
| 'lg'
| 'none';
type PlatformSubpanelRadius = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
type PlatformSubpanelSurface =
| 'platform'
| 'flat'
| 'soft'
| 'dark'
| 'darkSky'
| 'darkEmerald'
| 'darkAmber'
| 'darkRose'
| 'danger';
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';
};
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>
);
}
/**
* 平台白底子面板。
* 统一承接结果页和创作工作台里的 subpanel 外壳、标题行和右侧动作区。
*/
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>
);
}