7f8400fd3a
将 PlatformAsyncStatePanel 扩展到公共素材选择网格 将 PlatformAsyncStatePanel 扩展到视觉小说存档面板与账号安全子区块 将兑换码弹窗提交动作改为使用标准 profile modal footer 补充对应测试并更新 PlatformUiKit 收口计划与共享决策记录
323 lines
8.7 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|