Files
Genarrative/src/components/common/PlatformAssetPickerCard.tsx
T
kdletters 7f8400fd3a 继续扩展共享异步状态壳
将 PlatformAsyncStatePanel 扩展到公共素材选择网格

将 PlatformAsyncStatePanel 扩展到视觉小说存档面板与账号安全子区块

将兑换码弹窗提交动作改为使用标准 profile modal footer

补充对应测试并更新 PlatformUiKit 收口计划与共享决策记录
2026-06-11 04:11:32 +08:00

323 lines
8.7 KiB
TypeScript

import type { ButtonHTMLAttributes, Key, ReactNode } from 'react';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import { PlatformAsyncStatePanel } from './PlatformAsyncStatePanel';
import { PlatformEmptyState } from './PlatformEmptyState';
import { PlatformStatusMessage } from './PlatformStatusMessage';
type PlatformAssetPickerSurface = 'platform' | 'editorDark';
type PlatformAssetPickerCardProps = Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
'children'
> & {
imageSrc: string;
imageAlt: string;
assetTitle?: ReactNode;
subtitle?: ReactNode;
surface?: PlatformAssetPickerSurface;
selectLabel?: ReactNode;
selected?: boolean;
cardRadiusClassName?: string;
imageShellClassName?: string;
imageClassName?: string;
bodyClassName?: string;
};
type PlatformAssetPickerGridProps<TItem> = {
items: readonly TItem[];
isLoading?: boolean;
error?: ReactNode;
loadingLabel: ReactNode;
emptyLabel: ReactNode;
disabled?: boolean;
getKey: (item: TItem) => Key;
getImageSrc: (item: TItem) => string;
getImageAlt: (item: TItem) => string;
getTitle?: (item: TItem) => ReactNode;
getSubtitle?: (item: TItem) => ReactNode;
getAriaLabel?: (item: TItem) => string;
isSelected?: (item: TItem) => boolean;
onSelect: (item: TItem) => void;
surface?: PlatformAssetPickerSurface;
selectLabel?: ReactNode;
gridClassName?: string;
emptyClassName?: string;
statusClassName?: string;
cardClassName?: string;
cardRadiusClassName?: string;
imageShellClassName?: string;
imageClassName?: string;
bodyClassName?: string;
};
const PLATFORM_ASSET_PICKER_CARD_CLASS: Record<
PlatformAssetPickerSurface,
string
> = {
platform:
'bg-white/82 text-left transition hover:border-amber-300/70 hover:bg-white',
editorDark:
'bg-black/20 text-left transition hover:border-sky-200/40 hover:bg-slate-900/70',
};
const PLATFORM_ASSET_PICKER_CARD_BORDER_CLASS: Record<
PlatformAssetPickerSurface,
string
> = {
platform: 'border-[var(--platform-subpanel-border)]',
editorDark: 'border-white/10',
};
const PLATFORM_ASSET_PICKER_CARD_SELECTED_CLASS: Record<
PlatformAssetPickerSurface,
string
> = {
platform:
'border-[var(--platform-warm-border)] ring-2 ring-[var(--platform-warm-bg)]',
editorDark: 'border-sky-200/50 ring-2 ring-sky-300/22',
};
const PLATFORM_ASSET_PICKER_IMAGE_SHELL_CLASS: Record<
PlatformAssetPickerSurface,
string
> = {
platform: 'bg-[var(--platform-subpanel-fill)]',
editorDark:
'bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.16),transparent_48%),linear-gradient(180deg,rgba(19,24,39,0.95),rgba(8,10,17,0.92))]',
};
const PLATFORM_ASSET_PICKER_TITLE_CLASS: Record<
PlatformAssetPickerSurface,
string
> = {
platform: 'text-[var(--platform-text-strong)]',
editorDark: 'text-zinc-100',
};
const PLATFORM_ASSET_PICKER_SUBTITLE_CLASS: Record<
PlatformAssetPickerSurface,
string
> = {
platform: 'text-[var(--platform-text-base)]',
editorDark: 'text-zinc-400',
};
const PLATFORM_ASSET_PICKER_SELECT_LABEL_CLASS: Record<
PlatformAssetPickerSurface,
string
> = {
platform:
'border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)] text-[var(--platform-text-strong)]',
editorDark:
'border-sky-300/22 bg-sky-500/12 text-sky-50 group-hover:border-sky-200/40 group-hover:text-white',
};
const PLATFORM_ASSET_PICKER_GRID_STATUS_SURFACE: Record<
PlatformAssetPickerSurface,
'platform' | 'tinted'
> = {
platform: 'platform',
editorDark: 'tinted',
};
const PLATFORM_ASSET_PICKER_GRID_EMPTY_CLASS: Record<
PlatformAssetPickerSurface,
string
> = {
platform: '',
editorDark:
'rounded-2xl border-white/8 bg-black/20 text-zinc-300 min-h-0 py-8',
};
/**
* 平台历史素材选择卡片。
* 统一承载历史图片 / 素材选择里的缩略图、禁用态和双行文案外观。
*/
export function PlatformAssetPickerCard({
imageSrc,
imageAlt,
assetTitle,
subtitle,
surface = 'platform',
selectLabel,
selected = false,
disabled,
className,
cardRadiusClassName = 'rounded-[1.1rem]',
imageShellClassName = 'aspect-square',
imageClassName,
bodyClassName,
...buttonProps
}: PlatformAssetPickerCardProps) {
return (
<button
{...buttonProps}
type="button"
disabled={disabled}
className={[
'group overflow-hidden border',
PLATFORM_ASSET_PICKER_CARD_CLASS[surface],
cardRadiusClassName,
selected
? PLATFORM_ASSET_PICKER_CARD_SELECTED_CLASS[surface]
: PLATFORM_ASSET_PICKER_CARD_BORDER_CLASS[surface],
disabled ? 'cursor-not-allowed opacity-55' : null,
className,
]
.filter(Boolean)
.join(' ')}
>
<div
className={[
'overflow-hidden',
imageShellClassName,
PLATFORM_ASSET_PICKER_IMAGE_SHELL_CLASS[surface],
].join(' ')}
>
<ResolvedAssetImage
src={imageSrc}
alt={imageAlt}
className={['h-full w-full object-cover', imageClassName]
.filter(Boolean)
.join(' ')}
/>
</div>
{assetTitle || subtitle || selectLabel ? (
<div className={bodyClassName ?? 'space-y-1 px-3 py-3'}>
{assetTitle ? (
<div
className={[
'truncate text-xs font-black',
PLATFORM_ASSET_PICKER_TITLE_CLASS[surface],
].join(' ')}
>
{assetTitle}
</div>
) : null}
{subtitle ? (
<div
className={[
'text-[11px] leading-4',
PLATFORM_ASSET_PICKER_SUBTITLE_CLASS[surface],
].join(' ')}
>
{subtitle}
</div>
) : null}
{selectLabel ? (
<div
className={[
'rounded-full border px-4 py-2 text-center text-sm font-semibold transition-colors',
PLATFORM_ASSET_PICKER_SELECT_LABEL_CLASS[surface],
].join(' ')}
>
{selectLabel}
</div>
) : null}
</div>
) : null}
</button>
);
}
/**
* 平台历史素材选择网格。
* 统一承载历史图片 / 素材选择里的错误、读取、空态、网格和卡片渲染节奏。
*/
export function PlatformAssetPickerGrid<TItem>({
items,
isLoading = false,
error = null,
loadingLabel,
emptyLabel,
disabled,
getKey,
getImageSrc,
getImageAlt,
getTitle,
getSubtitle,
getAriaLabel,
isSelected,
onSelect,
surface = 'platform',
selectLabel,
gridClassName = 'grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5',
emptyClassName,
statusClassName,
cardClassName,
cardRadiusClassName,
imageShellClassName,
imageClassName,
bodyClassName,
}: PlatformAssetPickerGridProps<TItem>) {
const sharedEmptyStateClassName = [
PLATFORM_ASSET_PICKER_GRID_EMPTY_CLASS[surface],
emptyClassName,
]
.filter(Boolean)
.join(' ');
return (
<>
{error ? (
<PlatformStatusMessage
tone="error"
surface={PLATFORM_ASSET_PICKER_GRID_STATUS_SURFACE[surface]}
size="md"
className={statusClassName}
>
{error}
</PlatformStatusMessage>
) : null}
<PlatformAsyncStatePanel
isLoading={isLoading}
loadingState={
<PlatformEmptyState
surface="dashed"
size="panel"
className={sharedEmptyStateClassName}
>
{loadingLabel}
</PlatformEmptyState>
}
isEmpty={items.length <= 0}
emptyState={
<PlatformEmptyState
surface="dashed"
size="panel"
className={sharedEmptyStateClassName}
>
{emptyLabel}
</PlatformEmptyState>
}
>
{items.length > 0 ? (
<div className={gridClassName}>
{items.map((item) => (
<PlatformAssetPickerCard
key={getKey(item)}
disabled={disabled}
aria-label={getAriaLabel?.(item)}
onClick={() => onSelect(item)}
imageSrc={getImageSrc(item)}
imageAlt={getImageAlt(item)}
assetTitle={getTitle?.(item)}
subtitle={getSubtitle?.(item)}
surface={surface}
selectLabel={selectLabel}
selected={isSelected?.(item) ?? false}
className={cardClassName}
cardRadiusClassName={cardRadiusClassName}
imageShellClassName={imageShellClassName}
imageClassName={imageClassName}
bodyClassName={bodyClassName}
/>
))}
</div>
) : null}
</PlatformAsyncStatePanel>
</>
);
}