新增 PlatformUiKit 通用弹窗、按钮、状态、空态、媒体、表单和标签等公共组件 迁移结果页、创作工作台、认证入口、RPG 暗色面板和运行态弹窗的重复 UI chrome 补充组件测试、页面回归测试、技术文档和 Hermes 共享决策记录
223 lines
5.7 KiB
TypeScript
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>
|
|
);
|
|
}
|