合并泥点弹窗透明修复

# Conflicts:
#	src/components/common/PublishShareModal.test.tsx
#	src/components/common/PublishShareModal.tsx
#	src/index.test.ts
This commit is contained in:
2026-06-12 15:35:19 +08:00
324 changed files with 36177 additions and 12743 deletions

View File

@@ -0,0 +1,73 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { expect, test } from 'vitest';
import { CopyCodeButton } from './CopyCodeButton';
test('renders public work code with default accessible copy label', () => {
render(
<CopyCodeButton
state="idle"
code="PZ-001"
className="code-chip"
codeClassName="code-value"
/>,
);
const button = screen.getByRole('button', { name: '复制作品号 PZ-001' });
expect(button.className).toContain('code-chip');
expect(button.getAttribute('title')).toBe('复制作品号');
expect(screen.getByText('作品号')).toBeTruthy();
expect(screen.getByText('PZ-001').className).toContain('code-value');
});
test('renders copied and failed suffixes without business-side fragments', () => {
const { rerender } = render(<CopyCodeButton state="copied" code="CW-001" />);
expect(screen.getByText('已复制')).toBeTruthy();
rerender(<CopyCodeButton state="failed" code="CW-001" />);
expect(screen.getByText('复制失败')).toBeTruthy();
});
test('supports compact code-only chips', () => {
render(
<CopyCodeButton
state="copied"
code="PZ-001"
codeLabel={null}
showIcon={false}
accessibleLabel="复制作品号 PZ-001"
title="复制作品号"
/>,
);
const button = screen.getByRole('button', { name: '复制作品号 PZ-001' });
expect(button.textContent).toBe('PZ-001已复制');
expect(button.querySelector('svg')).toBeNull();
expect(button.getAttribute('title')).toBe('复制作品号');
});
test('can opt into shared pill action chrome for short codes', () => {
render(
<CopyCodeButton
state="idle"
code="RPG-001"
actionAppearance="pill"
actionPillSize="xxs"
className="tracking-[0.18em]"
/>,
);
const button = screen.getByRole('button', { name: '复制作品号 RPG-001' });
expect(button.className).toContain('rounded-full');
expect(button.className).toContain('bg-white/72');
expect(button.className).toContain('text-[10px]');
expect(button.className).toContain('tracking-[0.18em]');
expect(button.className).not.toContain('platform-pill');
});

View File

@@ -0,0 +1,133 @@
import { Copy } from 'lucide-react';
import type { ButtonHTMLAttributes, ReactNode } from 'react';
import {
CopyFeedbackButton,
type CopyFeedbackButtonActionAppearance,
} from './CopyFeedbackButton';
import type {
PlatformPillBadgeSize,
PlatformPillBadgeTone,
} from './platformPillBadgeModel';
import type { CopyFeedbackState } from './useCopyFeedback';
type CopyCodeButtonProps = Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
'children'
> & {
state: CopyFeedbackState;
code: string;
codeLabel?: ReactNode;
copiedSuffix?: ReactNode;
failedSuffix?: ReactNode;
idleIcon?: ReactNode;
copiedIcon?: ReactNode;
failedIcon?: ReactNode;
showIcon?: boolean;
labelClassName?: string;
codeClassName?: string;
suffixClassName?: string;
accessibleLabel?: string;
title?: string;
actionAppearance?: CopyFeedbackButtonActionAppearance;
actionPillTone?: PlatformPillBadgeTone;
actionPillSize?: PlatformPillBadgeSize;
};
function resolveCodeLabelText(codeLabel: ReactNode) {
return typeof codeLabel === 'string' ? codeLabel : '内容';
}
function renderCodeLabel({
code,
codeLabel,
codeClassName,
labelClassName,
suffix,
suffixClassName,
}: {
code: string;
codeLabel: ReactNode;
codeClassName?: string;
labelClassName?: string;
suffix?: ReactNode;
suffixClassName?: string;
}) {
return (
<>
{codeLabel ? <span className={labelClassName}>{codeLabel}</span> : null}
<span className={codeClassName}>{code}</span>
{suffix ? <span className={suffixClassName}>{suffix}</span> : null}
</>
);
}
/**
* 统一代码复制按钮。
* 用于作品号、用户号等短代码 chip收口三态文案和默认可访问名称。
*/
export function CopyCodeButton({
state,
code,
codeLabel = '作品号',
copiedSuffix = '已复制',
failedSuffix = '复制失败',
idleIcon = <Copy className="h-4 w-4" />,
copiedIcon,
failedIcon,
showIcon = true,
labelClassName,
codeClassName,
suffixClassName,
accessibleLabel,
title,
actionAppearance,
actionPillTone,
actionPillSize,
...buttonProps
}: CopyCodeButtonProps) {
const labelText = resolveCodeLabelText(codeLabel);
const defaultAccessibleLabel =
labelText === '内容' ? `复制 ${code}` : `复制${labelText} ${code}`;
const defaultTitle = labelText === '内容' ? '复制' : `复制${labelText}`;
return (
<CopyFeedbackButton
{...buttonProps}
state={state}
idleLabel={renderCodeLabel({
code,
codeLabel,
codeClassName,
labelClassName,
})}
copiedLabel={renderCodeLabel({
code,
codeLabel,
codeClassName,
labelClassName,
suffix: copiedSuffix,
suffixClassName,
})}
failedLabel={renderCodeLabel({
code,
codeLabel,
codeClassName,
labelClassName,
suffix: failedSuffix,
suffixClassName,
})}
idleIcon={idleIcon}
copiedIcon={copiedIcon ?? idleIcon}
failedIcon={failedIcon ?? idleIcon}
showIcon={showIcon}
actionAppearance={actionAppearance}
actionPillTone={actionPillTone}
actionPillSize={actionPillSize}
aria-label={
buttonProps['aria-label'] ?? accessibleLabel ?? defaultAccessibleLabel
}
title={title ?? defaultTitle}
/>
);
}

View File

@@ -0,0 +1,141 @@
/* @vitest-environment jsdom */
import { render, screen, within } from '@testing-library/react';
import { expect, test } from 'vitest';
import { CopyFeedbackButton } from './CopyFeedbackButton';
test('renders idle copy label and icon by default', () => {
render(
<CopyFeedbackButton
state="idle"
idleLabel="分享"
className="platform-button"
/>,
);
const button = screen.getByRole('button', { name: '分享' });
expect(button.className).toContain('platform-button');
expect(within(button).getByText('分享')).toBeTruthy();
expect(button.querySelector('svg')).toBeTruthy();
});
test('switches copied and failed feedback labels', () => {
const { rerender } = render(
<CopyFeedbackButton
state="copied"
idleLabel="复制作品号"
copiedLabel="作品号已复制"
failedLabel="作品号复制失败"
/>,
);
expect(screen.getByRole('button', { name: '作品号已复制' })).toBeTruthy();
rerender(
<CopyFeedbackButton
state="failed"
idleLabel="复制作品号"
copiedLabel="作品号已复制"
failedLabel="作品号复制失败"
/>,
);
expect(screen.getByRole('button', { name: '作品号复制失败' })).toBeTruthy();
});
test('keeps custom accessible label for compact buttons', () => {
render(
<CopyFeedbackButton
state="copied"
idleLabel="作品号 PZ-001"
aria-label="复制作品号 PZ-001"
title="复制作品号"
showIcon={false}
/>,
);
const button = screen.getByRole('button', { name: '复制作品号 PZ-001' });
expect(button.textContent).toBe('已复制');
expect(button.getAttribute('title')).toBe('复制作品号');
});
test('supports icon-only buttons with feedback labels kept in accessibility', () => {
render(
<CopyFeedbackButton
state="copied"
idleLabel="分享作品"
copiedLabel="分享内容已复制"
showLabel={false}
className="icon-button"
/>,
);
const button = screen.getByRole('button', { name: '分享内容已复制' });
expect(button.textContent).toBe('');
expect(button.querySelector('svg')).toBeTruthy();
});
test('allows overriding accessible label without business-side state branches', () => {
render(
<CopyFeedbackButton
state="failed"
idleLabel="分享作品"
failedLabel="复制失败"
accessibleLabel="分享内容复制失败"
showLabel={false}
/>,
);
const button = screen.getByRole('button', {
name: '分享内容复制失败',
});
expect(button.getAttribute('title')).toBe('分享内容复制失败');
});
test('can opt into platform action button chrome', () => {
render(
<CopyFeedbackButton
state="idle"
idleLabel="复制报错"
actionSurface="platform"
actionShape="pill"
actionFullWidth
aria-label="复制错误详情"
title="复制错误详情"
/>,
);
const button = screen.getByRole('button', { name: '复制错误详情' });
expect(button.className).toContain('platform-button--primary');
expect(button.className).toContain('w-full');
expect(button.className).toContain('rounded-full');
expect(button.className).toContain('disabled:cursor-not-allowed');
expect(button.getAttribute('title')).toBe('复制错误详情');
expect(button.textContent).toContain('复制报错');
});
test('can opt into shared pill action chrome', () => {
render(
<CopyFeedbackButton
state="idle"
idleLabel="分享作品"
actionAppearance="pill"
actionPillSize="xxs"
className="tracking-[0.18em]"
/>,
);
const button = screen.getByRole('button', { name: '分享作品' });
expect(button.className).toContain('rounded-full');
expect(button.className).toContain('bg-white/72');
expect(button.className).toContain('text-[10px]');
expect(button.className).toContain('tracking-[0.18em]');
expect(button.className).not.toContain('platform-pill');
});

View File

@@ -0,0 +1,157 @@
import { Check, Copy } from 'lucide-react';
import type { ButtonHTMLAttributes, ReactNode } from 'react';
import {
type PlatformActionButtonSize,
type PlatformActionButtonShape,
type PlatformActionButtonSurface,
type PlatformActionButtonTone,
} from './platformActionButtonModel';
import { PlatformActionButton } from './PlatformActionButton';
import {
getPlatformPillBadgeClassName,
type PlatformPillBadgeSize,
type PlatformPillBadgeTone,
} from './platformPillBadgeModel';
import type { CopyFeedbackState } from './useCopyFeedback';
export type CopyFeedbackButtonActionAppearance = 'plain' | 'pill';
type CopyFeedbackButtonProps = Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
'children'
> & {
state: CopyFeedbackState;
idleLabel: ReactNode;
copiedLabel?: ReactNode;
failedLabel?: ReactNode;
idleIcon?: ReactNode;
copiedIcon?: ReactNode;
failedIcon?: ReactNode;
showIcon?: boolean;
showLabel?: boolean;
labelClassName?: string;
accessibleLabel?: string;
actionSurface?: PlatformActionButtonSurface;
actionTone?: PlatformActionButtonTone;
actionSize?: PlatformActionButtonSize;
actionShape?: PlatformActionButtonShape;
actionFullWidth?: boolean;
actionAppearance?: CopyFeedbackButtonActionAppearance;
actionPillTone?: PlatformPillBadgeTone;
actionPillSize?: PlatformPillBadgeSize;
};
function resolveCopyFeedbackLabel(
state: CopyFeedbackState,
idleLabel: ReactNode,
copiedLabel: ReactNode,
failedLabel: ReactNode,
) {
if (state === 'copied') {
return copiedLabel;
}
if (state === 'failed') {
return failedLabel;
}
return idleLabel;
}
/**
* 统一复制反馈按钮。
* useCopyFeedback 负责复制状态,这里只收口按钮里的图标、文案和可访问名称。
*/
export function CopyFeedbackButton({
state,
idleLabel,
copiedLabel = '已复制',
failedLabel = '复制失败',
idleIcon = <Copy className="h-4 w-4" />,
copiedIcon = <Check className="h-4 w-4" />,
failedIcon,
showIcon = true,
showLabel = true,
labelClassName,
accessibleLabel: accessibleLabelOverride,
actionSurface,
actionTone = 'primary',
actionSize = 'sm',
actionShape = 'default',
actionFullWidth = false,
actionAppearance = 'plain',
actionPillTone = 'neutral',
actionPillSize = 'xs',
className,
'aria-label': ariaLabel,
title,
...buttonProps
}: CopyFeedbackButtonProps) {
const label = resolveCopyFeedbackLabel(
state,
idleLabel,
copiedLabel,
failedLabel,
);
const icon =
state === 'copied'
? copiedIcon
: state === 'failed'
? (failedIcon ?? idleIcon)
: idleIcon;
const accessibleLabel =
accessibleLabelOverride ??
(typeof label === 'string'
? label
: typeof idleLabel === 'string'
? idleLabel
: undefined);
const resolvedAriaLabel = ariaLabel ?? accessibleLabel;
const resolvedTitle =
title ?? (typeof accessibleLabel === 'string' ? accessibleLabel : undefined);
const content = (
<>
{showIcon ? icon : null}
{showLabel ? <span className={labelClassName}>{label}</span> : null}
</>
);
if (actionSurface) {
return (
<PlatformActionButton
surface={actionSurface}
tone={actionTone}
size={actionSize}
shape={actionShape}
fullWidth={actionFullWidth}
className={className}
{...buttonProps}
aria-label={resolvedAriaLabel}
title={resolvedTitle}
>
{content}
</PlatformActionButton>
);
}
return (
<button
type="button"
className={[
actionAppearance === 'pill'
? getPlatformPillBadgeClassName({
tone: actionPillTone,
size: actionPillSize,
})
: null,
className,
]
.filter(Boolean)
.join(' ')}
{...buttonProps}
aria-label={resolvedAriaLabel}
title={resolvedTitle}
>
{content}
</button>
);
}

View File

@@ -0,0 +1,39 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { expect, test } from 'vitest';
import { CopyFeedbackMessage } from './CopyFeedbackMessage';
test('renders nothing while copy feedback is idle', () => {
const { container } = render(
<CopyFeedbackMessage state="idle" className="copy-toast" />,
);
expect(container.textContent).toBe('');
});
test('renders copied and failed feedback labels', () => {
const { rerender } = render(
<CopyFeedbackMessage
state="copied"
copiedLabel="分享内容已复制"
failedLabel="分享失败"
className="copy-toast"
/>,
);
const copied = screen.getByText('分享内容已复制');
expect(copied.className).toContain('copy-toast');
rerender(
<CopyFeedbackMessage
state="failed"
copiedLabel="分享内容已复制"
failedLabel="分享失败"
className="copy-toast"
/>,
);
expect(screen.getByText('分享失败')).toBeTruthy();
});

View File

@@ -0,0 +1,29 @@
import type { HTMLAttributes, ReactNode } from 'react';
import type { CopyFeedbackState } from './useCopyFeedback';
type CopyFeedbackMessageProps = Omit<
HTMLAttributes<HTMLDivElement>,
'children'
> & {
state: CopyFeedbackState;
copiedLabel?: ReactNode;
failedLabel?: ReactNode;
};
/**
* 统一复制反馈提示。
* 非按钮区域只负责展示成功 / 失败,不在业务页重复写 copied / failed 分支。
*/
export function CopyFeedbackMessage({
state,
copiedLabel = '已复制',
failedLabel = '复制失败',
...divProps
}: CopyFeedbackMessageProps) {
if (state === 'idle') {
return null;
}
return <div {...divProps}>{state === 'copied' ? copiedLabel : failedLabel}</div>;
}

View File

@@ -4,10 +4,8 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import type { ComponentProps } from 'react';
import { afterEach, expect, test, vi } from 'vitest';
import {
CreativeAudioInputPanel,
} from './CreativeAudioInputPanel';
import type { CreativeAudioAsset } from './creativeAudioFileAsset';
import { CreativeAudioInputPanel } from './CreativeAudioInputPanel';
type TestAudioAsset = CreativeAudioAsset;
@@ -37,16 +35,19 @@ function buildAsset(overrides: Partial<TestAudioAsset> = {}): TestAudioAsset {
}
function renderPanel(
overrides: Partial<ComponentProps<typeof CreativeAudioInputPanel<TestAudioAsset>>> = {},
overrides: Partial<
ComponentProps<typeof CreativeAudioInputPanel<TestAudioAsset>>
> = {},
) {
const onAssetChange = vi.fn();
const onError = vi.fn();
const readFileAsAsset = vi.fn(async (file: File, source: 'uploaded' | 'recorded') =>
buildAsset({
audioSrc: `blob:${source}`,
source,
prompt: file.name,
}),
const readFileAsAsset = vi.fn(
async (file: File, source: 'uploaded' | 'recorded') =>
buildAsset({
audioSrc: `blob:${source}`,
source,
prompt: file.name,
}),
);
const rendered = render(
@@ -77,7 +78,16 @@ function getUploadInput() {
test('音频面板按需显示最长限制标签', () => {
renderPanel({ limitLabel: '最长 1 秒' });
expect(screen.getByText('最长 1 秒')).toBeTruthy();
const limitBadge = screen.getByText('最长 1 秒');
expect(limitBadge.className).toContain('rounded-full');
expect(limitBadge.className).toContain(
'border-[var(--platform-subpanel-border)]',
);
expect(limitBadge.className).toContain('bg-[var(--platform-subpanel-fill)]');
expect(limitBadge.className).toContain('text-[var(--platform-text-soft)]');
expect(limitBadge.className).toContain('px-2');
expect(limitBadge.className).toContain('py-1');
});
test('音频面板未传限制标签时不渲染限制提示', () => {
@@ -239,7 +249,9 @@ test('录音停止后按 recorded 来源读取音频', async () => {
const { readFileAsAsset, onAssetChange } = renderPanel();
fireEvent.click(screen.getByRole('button', { name: '录音' }));
await waitFor(() => expect(screen.getByRole('button', { name: '停止' })).toBeTruthy());
await waitFor(() =>
expect(screen.getByRole('button', { name: '停止' })).toBeTruthy(),
);
fireEvent.click(screen.getByRole('button', { name: '停止' }));
await waitFor(() => expect(readFileAsAsset).toHaveBeenCalledTimes(1));
@@ -275,7 +287,9 @@ test('录音保存失败时提示错误', async () => {
renderPanel({ readFileAsAsset, onError });
fireEvent.click(screen.getByRole('button', { name: '录音' }));
await waitFor(() => expect(screen.getByRole('button', { name: '停止' })).toBeTruthy());
await waitFor(() =>
expect(screen.getByRole('button', { name: '停止' })).toBeTruthy(),
);
fireEvent.click(screen.getByRole('button', { name: '停止' }));
await waitFor(() =>

View File

@@ -5,6 +5,9 @@ import {
type CreativeAudioAsset,
readCreativeAudioFileAsAsset,
} from './creativeAudioFileAsset';
import { PlatformActionButton } from './PlatformActionButton';
import { PlatformPillBadge } from './PlatformPillBadge';
import { PlatformSubpanel } from './PlatformSubpanel';
type CreativeAudioInputPanelProps<TAsset extends CreativeAudioAsset> = {
disabled?: boolean;
@@ -93,91 +96,95 @@ export function CreativeAudioInputPanel<TAsset extends CreativeAudioAsset>({
};
return (
<section className="platform-subpanel rounded-[1.25rem] p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<div className="text-sm font-black text-[var(--platform-text-strong)]">
{title}
</div>
<PlatformSubpanel
title={
<span className="flex min-w-0 items-center gap-2">
<span>{title}</span>
{limitLabel ? (
<div className="rounded-full bg-white/70 px-2 py-1 text-[11px] font-black text-[var(--platform-text-soft)]">
<PlatformPillBadge tone="muted" size="xs" className="px-2 py-1">
{limitLabel}
</div>
</PlatformPillBadge>
) : null}
</div>
{asset ? (
<button
type="button"
</span>
}
titleVariant="strong"
actions={
asset ? (
<PlatformActionButton
onClick={() => onAssetChange(null)}
disabled={disabled}
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-xs"
tone="ghost"
size="xs"
className="min-h-0"
>
</button>
) : null}
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<label
className={`platform-button platform-button--secondary min-h-10 cursor-pointer gap-2 px-3 py-2 text-sm ${
disabled ? 'pointer-events-none opacity-55' : ''
}`}
>
<Upload className="h-4 w-4" />
<input
type="file"
accept="audio/*"
disabled={disabled}
className="sr-only"
onChange={(event) => {
const file = event.currentTarget.files?.[0] ?? null;
event.currentTarget.value = '';
if (!file) {
return;
}
void readFileAsAsset(file, 'uploaded')
.then((nextAsset) => {
onError(null);
onAssetChange(nextAsset);
})
.catch((caughtError) => {
onError(
caughtError instanceof Error
? caughtError.message
: '音频读取失败。',
);
});
}}
/>
</label>
<button
type="button"
</PlatformActionButton>
) : null
}
bodyClassName="mt-3 flex flex-wrap items-center gap-2"
>
<PlatformActionButton
asChild="label"
tone="secondary"
className={`min-h-10 cursor-pointer gap-2 px-3 ${
disabled ? 'pointer-events-none opacity-55' : ''
}`}
>
<Upload className="h-4 w-4" />
<input
type="file"
accept="audio/*"
disabled={disabled}
onClick={() => {
if (isRecording) {
stopRecording();
className="sr-only"
onChange={(event) => {
const file = event.currentTarget.files?.[0] ?? null;
event.currentTarget.value = '';
if (!file) {
return;
}
void startRecording();
void readFileAsAsset(file, 'uploaded')
.then((nextAsset) => {
onError(null);
onAssetChange(nextAsset);
})
.catch((caughtError) => {
onError(
caughtError instanceof Error
? caughtError.message
: '音频读取失败。',
);
});
}}
className="platform-button platform-button--ghost min-h-10 gap-2 px-3 py-2 text-sm"
>
{isRecording ? (
<Pause className="h-4 w-4" />
) : (
<Mic className="h-4 w-4" />
)}
{isRecording ? '停止' : '录音'}
</button>
{asset?.audioSrc ? (
<audio controls src={asset.audioSrc} className="h-10 max-w-full" />
/>
</PlatformActionButton>
<PlatformActionButton
disabled={disabled}
onClick={() => {
if (isRecording) {
stopRecording();
return;
}
void startRecording();
}}
tone="ghost"
className="min-h-10 gap-2 px-3"
>
{isRecording ? (
<Pause className="h-4 w-4" />
) : (
<div className="text-xs font-bold text-[var(--platform-text-soft)]">
{asset ? '音效已选择' : defaultLabel}
</div>
<Mic className="h-4 w-4" />
)}
</div>
</section>
{isRecording ? '停止' : '录音'}
</PlatformActionButton>
{asset?.audioSrc ? (
<audio controls src={asset.audioSrc} className="h-10 max-w-full" />
) : (
<div className="text-xs font-bold text-[var(--platform-text-soft)]">
{asset ? '音效已选择' : defaultLabel}
</div>
)}
</PlatformSubpanel>
);
}

View File

@@ -40,6 +40,7 @@ test('creative image input panel handles reference uploads and preview', () => {
]}
imageModelPicker={<div />}
submitLabel="生成"
submitCostLabel="2泥点"
submitDisabled={false}
labels={{
imageField: '拼图画面',
@@ -66,6 +67,24 @@ test('creative image input panel handles reference uploads and preview', () => {
const promptReferenceInput = screen.getByLabelText('上传参考图', {
selector: 'input',
});
const promptTextarea = screen.getByLabelText('画面描述');
const imageFieldLabel = screen.getByText('拼图画面');
const promptFieldLabel = screen.getByText('画面描述');
const emptyMainImageIconBadge = document.querySelector(
'[aria-hidden="true"]',
);
expect(imageFieldLabel.className).toContain('shrink-0');
expect(imageFieldLabel.className).toContain('text-sm font-black');
expect(promptFieldLabel.className).toContain('mb-0');
expect(promptFieldLabel.className).toContain('text-sm font-black');
expect(promptTextarea.className).toContain(
'border border-[var(--platform-subpanel-border)]',
);
expect(promptTextarea.className).toContain('rounded-[1.15rem]');
expect(promptTextarea.className).toContain('pb-14');
expect(emptyMainImageIconBadge?.className).toContain('h-14');
expect(emptyMainImageIconBadge?.className).toContain('rounded-full');
expect(emptyMainImageIconBadge?.className).toContain('bg-white/92');
expect((promptReferenceInput as HTMLInputElement).multiple).toBe(true);
fireEvent.change(promptReferenceInput, {
@@ -77,27 +96,25 @@ test('creative image input panel handles reference uploads and preview', () => {
},
});
expect(onPromptReferenceFilesSelect).toHaveBeenCalledWith(
expect.arrayContaining([
expect.any(File),
expect.any(File),
]),
expect.arrayContaining([expect.any(File), expect.any(File)]),
);
fireEvent.click(
screen.getByRole('button', { name: '预览参考图 参考图 1' }),
);
expect(
screen.getByRole('dialog', { name: '参考图 1' }),
).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '预览参考图 参考图 1' }));
expect(screen.getByRole('dialog', { name: '参考图 1' })).toBeTruthy();
expect(screen.getByAltText('参考图预览')).toHaveProperty(
'src',
expect.stringContaining('ref-1'),
);
fireEvent.click(screen.getByRole('button', { name: '关闭参考图预览' }));
expect(screen.queryByRole('dialog', { name: '参考图 1' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '移除参考图 参考图 1' }));
expect(onPromptReferenceRemove).toHaveBeenCalledWith('ref-1');
fireEvent.click(screen.getByRole('button', { name: '生成' }));
const costBadge = screen.getByText('2泥点');
expect(costBadge.className).toContain('rounded-full');
expect(costBadge.className).toContain('bg-white/24');
expect(costBadge.className).toContain('text-current');
fireEvent.click(screen.getByRole('button', { name: //u }));
expect(onSubmit).toHaveBeenCalledTimes(1);
});
@@ -138,7 +155,9 @@ test('creative image input panel can opt out of filling the parent height', () =
const panel = container.querySelector('.creative-image-input-panel');
const body = container.querySelector('.creative-image-input-panel__body');
const section = container.querySelector('.creative-image-input-panel__section');
const section = container.querySelector(
'.creative-image-input-panel__section',
);
expect(panel?.className).toContain('flex-none');
expect(panel?.className).not.toContain('flex-1');
expect(body?.className).toContain('flex-none');
@@ -183,7 +202,9 @@ test('creative image input panel fills the parent height by default', () => {
const panel = container.querySelector('.creative-image-input-panel');
const body = container.querySelector('.creative-image-input-panel__body');
const section = container.querySelector('.creative-image-input-panel__section');
const section = container.querySelector(
'.creative-image-input-panel__section',
);
expect(panel?.className).toContain('flex-1');
expect(panel?.className).not.toContain('flex-none');
expect(body?.className).toContain('flex-1');
@@ -235,6 +256,54 @@ test('creative image input panel confirms before removing uploaded image', () =>
expect(onMainImageRemove).toHaveBeenCalledTimes(1);
});
test('creative image input panel closes reference preview on backdrop click', () => {
render(
<CreativeImageInputPanel
uploadedImageSrc=""
uploadedImageAlt="拼图图片"
mainImageInputId="image-upload-input"
promptTextareaId="image-prompt-input"
prompt="旧街灯牌下的猫。"
promptLabel="画面描述"
aiRedraw
promptReferenceImages={[
{
id: 'ref-1',
label: '参考图 1',
imageSrc: 'data:image/png;base64,ref-1',
},
]}
imageModelPicker={null}
submitLabel="生成"
submitDisabled={false}
labels={{
imageField: '拼图画面',
uploadImage: '上传拼图图片',
replaceImage: '更换拼图图片',
emptyImageHint: '上传图片/填写画面描述',
removeImage: '移除拼图图片',
removeImageConfirmTitle: '移除拼图图片?',
removeImageConfirmBody: '移除后需要重新上传图片。',
promptReferenceUpload: '上传参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
}}
onMainImageFileSelect={() => {}}
onMainImageRemove={() => {}}
onAiRedrawChange={() => {}}
onPromptChange={() => {}}
onPromptReferenceFilesSelect={() => {}}
onPromptReferenceRemove={() => {}}
onSubmit={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '预览参考图 参考图 1' }));
const dialog = screen.getByRole('dialog', { name: '参考图 1' });
fireEvent.click(dialog.parentElement as HTMLElement);
expect(screen.queryByRole('dialog', { name: '参考图 1' })).toBeNull();
});
test('creative image input panel supports a preview-only main image mode', () => {
const onSubmit = vi.fn();
@@ -332,25 +401,76 @@ test('creative image input panel can preview the main image and keep upload on a
fireEvent.click(screen.getByRole('button', { name: '查看关卡图片' }));
expect(screen.getByRole('dialog', { name: '查看关卡图片' })).toBeTruthy();
expect(screen.getAllByAltText('拼图关卡图').length).toBeGreaterThanOrEqual(2);
fireEvent.click(
screen.getByRole('button', { name: '关闭关卡图片预览' }),
expect(screen.getAllByAltText('拼图关卡图').length).toBeGreaterThanOrEqual(
2,
);
fireEvent.click(screen.getByRole('button', { name: '关闭关卡图片预览' }));
expect(
screen.queryByRole('dialog', { name: '查看关卡图片' }),
).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '更换参考图' }));
expect(inputClickSpy).toHaveBeenCalledTimes(1);
fireEvent.change(screen.getByLabelText('上传参考图', { selector: 'input' }), {
target: {
files: [new File(['a'], 'level-reference.png', { type: 'image/png' })],
fireEvent.change(
screen.getByLabelText('上传参考图', { selector: 'input' }),
{
target: {
files: [
new File(['a'], 'level-reference.png', { type: 'image/png' }),
],
},
},
});
);
expect(onMainImageFileSelect).toHaveBeenCalledWith(expect.any(File));
} finally {
inputClickSpy.mockRestore();
}
});
test('creative image input panel closes main image preview on backdrop click', () => {
render(
<CreativeImageInputPanel
mainImageClickMode="preview"
uploadedImageSrc="/generated-puzzle-assets/session/level/image.png"
uploadedImageAlt="拼图关卡图"
mainImageInputId="level-image-upload-input"
promptTextareaId="level-prompt-input"
prompt="旧街灯牌下的猫。"
promptLabel="画面描述"
aiRedraw
promptReferenceImages={[]}
imageModelPicker={null}
submitLabel="重新生成画面"
submitDisabled={false}
labels={{
imageField: '画面图',
uploadImage: '上传参考图',
replaceImage: '更换参考图',
emptyImageHint: '上传图片/填写画面描述',
removeImage: '移除参考图',
removeImageConfirmTitle: '移除参考图?',
removeImageConfirmBody: '移除后可重新上传或选择历史图片。',
promptReferenceUpload: '上传参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
previewMainImage: '查看关卡图片',
closeMainImagePreview: '关闭关卡图片预览',
}}
onMainImageFileSelect={() => {}}
onMainImageRemove={() => {}}
onAiRedrawChange={() => {}}
onPromptChange={() => {}}
onSubmit={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '查看关卡图片' }));
const dialog = screen.getByRole('dialog', { name: '查看关卡图片' });
fireEvent.click(dialog.parentElement as HTMLElement);
expect(screen.queryByRole('dialog', { name: '查看关卡图片' })).toBeNull();
});
test('creative image input panel can hide upload and history controls independently', () => {
render(
<CreativeImageInputPanel
@@ -399,6 +519,55 @@ test('creative image input panel can hide upload and history controls independen
expect(screen.queryByRole('button', { name: '选择历史图片' })).toBeNull();
});
test('creative image input panel uses floating icon button for history action', () => {
const onHistoryClick = vi.fn();
render(
<CreativeImageInputPanel
uploadedImageSrc="/generated-puzzle-assets/session/level/image.png"
uploadedImageAlt="拼图关卡图"
mainImageInputId="level-image-upload-input"
promptTextareaId="level-prompt-input"
prompt="旧街灯牌下的猫。"
promptLabel="画面描述"
aiRedraw
promptReferenceImages={[]}
imageModelPicker={null}
submitLabel="重新生成画面"
submitDisabled={false}
labels={{
imageField: '画面图',
uploadImage: '上传参考图',
replaceImage: '更换参考图',
emptyImageHint: '上传图片/填写画面描述',
removeImage: '移除参考图',
removeImageConfirmTitle: '移除参考图?',
removeImageConfirmBody: '移除后可重新上传或选择历史图片。',
promptReferenceUpload: '上传描述参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
history: '选择历史图片',
}}
onMainImageFileSelect={() => {}}
onMainImageRemove={() => {}}
onAiRedrawChange={() => {}}
onPromptChange={() => {}}
onHistoryClick={onHistoryClick}
onSubmit={() => {}}
/>,
);
const historyButton = screen.getByRole('button', { name: '选择历史图片' });
expect(within(historyButton).getByText('历史')).toBeTruthy();
expect(historyButton.className).toContain('bg-white/94');
expect(historyButton.className).toContain('backdrop-blur');
expect(historyButton.className).toContain('gap-1.5');
fireEvent.click(historyButton);
expect(onHistoryClick).toHaveBeenCalledTimes(1);
});
test('creative image input panel does not show empty upload hint over a non-removable image', () => {
render(
<CreativeImageInputPanel
@@ -532,9 +701,7 @@ test('creative image input panel can upload prompt references while showing a ma
},
});
expect(onPromptReferenceFilesSelect).toHaveBeenCalledWith([
expect.any(File),
]);
expect(onPromptReferenceFilesSelect).toHaveBeenCalledWith([expect.any(File)]);
expect(
screen.getByRole('button', { name: '预览参考图 描述参考图 1' }),
).toBeTruthy();

View File

@@ -1,14 +1,18 @@
import {
History,
ImagePlus,
Loader2,
Sparkles,
Trash2,
X,
} from 'lucide-react';
import { History, ImagePlus, Loader2, Sparkles, Trash2 } from 'lucide-react';
import { type ReactNode, useEffect, useRef, useState } from 'react';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import { PlatformActionButton } from './PlatformActionButton';
import { PlatformFieldLabel } from './PlatformFieldLabel';
import { PlatformIconBadge } from './PlatformIconBadge';
import { PlatformIconButton } from './PlatformIconButton';
import { PlatformPillBadge } from './PlatformPillBadge';
import { PlatformPillSwitch } from './PlatformPillSwitch';
import { PlatformStatusMessage } from './PlatformStatusMessage';
import { PlatformTextField } from './PlatformTextField';
import { PlatformUploadPreviewCard } from './PlatformUploadPreviewCard';
import { UnifiedConfirmDialog } from './UnifiedConfirmDialog';
import { UnifiedModal } from './UnifiedModal';
export type CreativeImageInputReferenceImage = {
id: string;
@@ -131,7 +135,8 @@ export function CreativeImageInputPanel({
const [isMainImagePreviewOpen, setIsMainImagePreviewOpen] = useState(false);
const [isRemoveImageConfirmOpen, setIsRemoveImageConfirmOpen] =
useState(false);
const showPrompt = mainImageMode === 'preview' || !uploadedImageSrc || aiRedraw;
const showPrompt =
mainImageMode === 'preview' || !uploadedImageSrc || aiRedraw;
const shouldShowPromptReferences =
canUploadPromptReferences ?? !uploadedImageSrc;
const promptReferenceUploadDisabled =
@@ -201,9 +206,9 @@ export function CreativeImageInputPanel({
disabled ? 'opacity-55' : ''
}`}
>
<div className="mb-2 shrink-0 text-sm font-black text-[var(--platform-text-strong)]">
<PlatformFieldLabel variant="form" className="shrink-0">
{labels.imageField}
</div>
</PlatformFieldLabel>
<div className={imageFrameClassName}>
<div className={imageCardClassName}>
{isMainImageUploadEnabled ? (
@@ -258,76 +263,63 @@ export function CreativeImageInputPanel({
/>
) : (
<span className="pointer-events-none flex h-full items-center justify-center bg-[radial-gradient(circle_at_50%_28%,rgba(255,255,255,0.9),transparent_38%),linear-gradient(135deg,rgba(255,255,255,0.96),rgba(255,241,229,0.86))]">
<span className="flex h-14 w-14 items-center justify-center rounded-full border border-[var(--platform-subpanel-border)] bg-white/92 text-[var(--platform-text-strong)] shadow-sm sm:h-20 sm:w-20">
<ImagePlus className="h-6 w-6 sm:h-8 sm:w-8" />
</span>
<PlatformIconBadge
icon={<ImagePlus className="h-6 w-6 sm:h-8 sm:w-8" />}
size="xl"
tone="soft"
className="border border-[var(--platform-subpanel-border)] bg-white/92 sm:h-20 sm:w-20"
/>
</span>
)}
<div className="pointer-events-none absolute inset-0 z-[1] bg-[linear-gradient(180deg,rgba(255,255,255,0.12)_0%,rgba(255,255,255,0.04)_42%,rgba(255,255,255,0.18)_100%)]" />
{shouldShowMainImageUploadButton ? (
<button
type="button"
<PlatformIconButton
variant="surfaceFloating"
label={labels.replaceImage}
title={labels.replaceImage}
disabled={disabled}
onClick={() => mainImageInputRef.current?.click()}
className="absolute bottom-3 right-3 z-10 inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/80 bg-white/94 text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[var(--platform-accent)] disabled:cursor-not-allowed disabled:opacity-55"
aria-label={labels.replaceImage}
title={labels.replaceImage}
>
<ImagePlus className="h-4 w-4" />
</button>
icon={<ImagePlus className="h-4 w-4" />}
className="absolute bottom-3 right-3 z-10 h-10 w-10"
/>
) : null}
{shouldShowHistoryButton ? (
<button
type="button"
<PlatformIconButton
variant="surfaceFloating"
label={labels.history ?? '选择历史图片'}
title={labels.history ?? '选择历史图片'}
disabled={disabled}
onClick={onHistoryClick}
className={`absolute right-3 top-3 z-10 inline-flex items-center gap-1.5 rounded-full border border-white/80 bg-white/94 px-3 py-2 text-[11px] font-black text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[var(--platform-accent)] ${
disabled ? 'cursor-not-allowed opacity-55' : ''
}`}
aria-label={labels.history ?? '选择历史图片'}
title={labels.history ?? '选择历史图片'}
icon={<History className="h-3.5 w-3.5" />}
className="absolute right-3 top-3 z-10 gap-1.5 px-3 py-2 text-[11px] font-black"
>
<History className="h-3.5 w-3.5" />
<span></span>
</button>
</PlatformIconButton>
) : null}
{canEditMainImage && uploadedImageSrc && canToggleAiRedraw ? (
<label className="absolute bottom-3 left-3 z-10 inline-flex cursor-pointer items-center gap-2 rounded-full border border-white/80 bg-white/94 px-3 py-2 text-xs font-black text-[var(--platform-text-strong)] shadow-sm backdrop-blur">
<span>AI重绘</span>
<input
role="switch"
type="checkbox"
checked={aiRedraw}
disabled={disabled}
onChange={(event) => onAiRedrawChange(event.target.checked)}
className="sr-only"
aria-label="AI重绘"
/>
<span
aria-hidden="true"
className={`relative h-5 w-9 rounded-full transition ${
aiRedraw ? 'bg-[var(--platform-accent)]' : 'bg-zinc-300'
}`}
>
<span
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition ${
aiRedraw ? 'left-[1.125rem]' : 'left-0.5'
}`}
/>
</span>
</label>
<PlatformPillSwitch
label="AI重绘"
aria-label="AI重绘"
checked={aiRedraw}
disabled={disabled}
onChange={(event) =>
onAiRedrawChange(event.target.checked)
}
className="absolute bottom-3 left-3 z-10"
/>
) : null}
{canEditMainImage && uploadedImageSrc && canRemoveMainImage ? (
<button
type="button"
{canEditMainImage &&
uploadedImageSrc &&
canRemoveMainImage ? (
<PlatformIconButton
variant="surfaceFloating"
label={labels.removeImage}
title={labels.removeImage}
disabled={disabled}
onClick={() => setIsRemoveImageConfirmOpen(true)}
className="absolute left-3 top-3 z-10 inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/80 bg-white/94 text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[var(--platform-accent)] disabled:cursor-not-allowed disabled:opacity-55"
aria-label={labels.removeImage}
title={labels.removeImage}
>
<Trash2 className="h-4 w-4" />
</button>
icon={<Trash2 className="h-4 w-4" />}
className="absolute left-3 top-3 z-10 h-10 w-10"
/>
) : isMainImageUploadEnabled && !uploadedImageSrc ? (
<label
htmlFor={mainImageInputId}
@@ -342,7 +334,9 @@ export function CreativeImageInputPanel({
) : null}
</div>
</div>
{mainImageMeta ? <div className="mt-3 shrink-0">{mainImageMeta}</div> : null}
{mainImageMeta ? (
<div className="mt-3 shrink-0">{mainImageMeta}</div>
) : null}
{imageLimitHint ? (
<div className="mt-2 shrink-0 text-center text-[11px] font-semibold text-[var(--platform-text-soft)]">
{imageLimitHint}
@@ -352,88 +346,89 @@ export function CreativeImageInputPanel({
{showPrompt ? (
<div className="block shrink-0 lg:min-h-0">
<label
htmlFor={promptTextareaId}
className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]"
>
{promptLabel}
<label htmlFor={promptTextareaId} className="mb-2 block">
<PlatformFieldLabel variant="form" className="mb-0">
{promptLabel}
</PlatformFieldLabel>
</label>
<div className="relative">
<textarea
<PlatformTextField
variant="textarea"
id={promptTextareaId}
value={prompt}
disabled={disabled}
rows={promptRows}
placeholder=""
onChange={(event) => onPromptChange(event.target.value)}
className="h-[6rem] min-h-[6rem] w-full resize-none rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 pb-14 text-base leading-6 text-[var(--platform-text-strong)] outline-none placeholder:text-zinc-400 sm:h-[7.5rem] sm:min-h-[7.5rem] lg:h-[9.25rem] lg:min-h-[9.25rem]"
size="lg"
density="roomy"
className="h-[6rem] min-h-[6rem] rounded-[1.15rem] pb-14 font-normal placeholder:text-zinc-400 sm:h-[7.5rem] sm:min-h-[7.5rem] lg:h-[9.25rem] lg:min-h-[9.25rem]"
aria-label={promptAriaLabel ?? promptLabel}
/>
{imageModelPicker}
{shouldShowPromptReferences && onPromptReferenceFilesSelect ? (
<label
className={`absolute bottom-3 right-3 z-10 inline-flex h-8 w-8 items-center justify-center rounded-full border border-[var(--platform-subpanel-border)] bg-white/96 text-[var(--platform-text-strong)] shadow-sm transition hover:bg-[var(--platform-subpanel-fill)] hover:text-[var(--platform-accent)] ${
{shouldShowPromptReferences &&
onPromptReferenceFilesSelect ? (
<PlatformIconButton
asChild="label"
variant="surfaceFloating"
label={labels.promptReferenceUpload}
title={labels.promptReferenceUpload}
icon={
<>
<ImagePlus className="h-4 w-4" />
<input
type="file"
accept={mainImageAccept}
multiple
aria-label={labels.promptReferenceUpload}
disabled={promptReferenceUploadDisabled}
onChange={(event) => {
const files = Array.from(
event.currentTarget.files ?? [],
);
event.currentTarget.value = '';
if (files.length > 0) {
onPromptReferenceFilesSelect(files);
}
}}
className="sr-only"
/>
</>
}
className={`absolute bottom-3 right-3 z-10 h-8 w-8 border-[var(--platform-subpanel-border)] bg-white/96 hover:bg-[var(--platform-subpanel-fill)] ${
promptReferenceUploadDisabled
? 'cursor-not-allowed opacity-55'
: 'cursor-pointer'
}`}
aria-label={labels.promptReferenceUpload}
title={labels.promptReferenceUpload}
>
<ImagePlus className="h-4 w-4" />
<input
type="file"
accept={mainImageAccept}
multiple
aria-label={labels.promptReferenceUpload}
disabled={promptReferenceUploadDisabled}
onChange={(event) => {
const files = Array.from(event.currentTarget.files ?? []);
event.currentTarget.value = '';
if (files.length > 0) {
onPromptReferenceFilesSelect(files);
}
}}
className="sr-only"
/>
</label>
/>
) : null}
</div>
{shouldShowPromptReferences && promptReferenceImages.length > 0 ? (
{shouldShowPromptReferences &&
promptReferenceImages.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-2">
{promptReferenceImages.map((reference) => (
<div
<PlatformUploadPreviewCard
key={reference.id}
className="relative h-12 w-12 overflow-hidden rounded-[0.75rem] border border-[var(--platform-subpanel-border)] bg-white/90 shadow-sm"
>
<button
type="button"
disabled={disabled}
onClick={() => setPreviewReferenceImage(reference)}
className="block h-full w-full"
aria-label={`预览参考图 ${reference.label}`}
title={reference.label}
>
<ResolvedAssetImage
src={reference.imageSrc}
alt=""
aria-hidden="true"
className="h-full w-full object-cover"
/>
</button>
{onPromptReferenceRemove ? (
<button
type="button"
disabled={disabled}
onClick={() => onPromptReferenceRemove(reference.id)}
className="absolute right-0.5 top-0.5 inline-flex h-5 w-5 items-center justify-center rounded-full bg-white/94 text-[var(--platform-text-strong)] shadow-sm transition hover:text-[var(--platform-accent)] disabled:opacity-55"
aria-label={`移除参考图 ${reference.label}`}
title="移除参考图"
>
<X className="h-3 w-3" />
</button>
) : null}
</div>
imageSrc={reference.imageSrc}
imageAlt=""
previewLabel={`预览参考图 ${reference.label}`}
removeLabel={`移除参考图 ${reference.label}`}
onPreview={() => setPreviewReferenceImage(reference)}
onRemove={
onPromptReferenceRemove
? () => onPromptReferenceRemove(reference.id)
: undefined
}
disabled={disabled}
resolveAsset
className="h-12 w-12 rounded-[0.75rem] bg-white/90 shadow-sm"
previewButtonProps={{ title: reference.label }}
removeButtonProps={{
title: '移除参考图',
className:
'right-0.5 top-0.5 bg-white/94 text-[var(--platform-text-strong)] shadow-sm hover:bg-white hover:text-[var(--platform-accent)]',
}}
/>
))}
</div>
) : null}
@@ -443,14 +438,24 @@ export function CreativeImageInputPanel({
<div className="mt-2 shrink-0 space-y-3">
{inputError ? (
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
<PlatformStatusMessage
tone="error"
surface="profile"
size="md"
className="rounded-2xl"
>
{inputError}
</div>
</PlatformStatusMessage>
) : null}
{error ? (
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
<PlatformStatusMessage
tone="error"
surface="profile"
size="md"
className="rounded-2xl"
>
{error}
</div>
</PlatformStatusMessage>
) : null}
</div>
</section>
@@ -458,11 +463,10 @@ export function CreativeImageInputPanel({
{showSubmitButton ? (
<div className="mt-2 flex shrink-0 justify-center pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:mt-3">
<button
type="button"
<PlatformActionButton
disabled={disabled || submitDisabled}
onClick={onSubmit}
className={`platform-button platform-button--primary min-h-10 px-4 py-2 text-sm sm:min-h-11 sm:px-5 ${
className={`min-h-10 px-4 sm:min-h-11 sm:px-5 ${
submitDisabled ? 'cursor-not-allowed opacity-55' : ''
}`}
>
@@ -473,135 +477,89 @@ export function CreativeImageInputPanel({
<Sparkles className="h-4 w-4" />
<span>{submitLabel}</span>
{submitCostLabel ? (
<span className="rounded-full bg-white/24 px-2 py-0.5 text-[11px] font-bold">
<PlatformPillBadge
tone="lightOverlay"
size="xs"
className="px-2 font-bold"
>
{submitCostLabel}
</span>
</PlatformPillBadge>
) : null}
</span>
</button>
</PlatformActionButton>
</div>
) : null}
{previewReferenceImage ? (
<div
className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6"
onClick={() => setPreviewReferenceImage(null)}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="creative-image-reference-preview-title"
className="platform-modal-shell platform-remap-surface w-full max-w-2xl rounded-[1.35rem] p-3 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
onClick={(event) => event.stopPropagation()}
>
<div className="mb-3 flex items-center justify-between gap-3 px-1">
<div
id="creative-image-reference-preview-title"
className="min-w-0 truncate text-sm font-black text-[var(--platform-text-strong)]"
>
{previewReferenceImage.label}
</div>
<button
type="button"
aria-label={labels.closePromptReferencePreview}
onClick={() => setPreviewReferenceImage(null)}
className="platform-profile-icon-button flex h-8 w-8 shrink-0 items-center justify-center rounded-full"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="max-h-[72vh] overflow-hidden rounded-[1rem] bg-black/5">
<ResolvedAssetImage
src={previewReferenceImage.imageSrc}
alt={labels.promptReferencePreviewAlt}
className="h-full max-h-[72vh] w-full object-contain"
/>
</div>
<UnifiedModal
open={Boolean(previewReferenceImage)}
title={previewReferenceImage?.label ?? labels.promptReferencePreviewAlt}
onClose={() => setPreviewReferenceImage(null)}
closeLabel={labels.closePromptReferencePreview}
closeVariant="profileCompact"
size="lg"
zIndexClassName="z-[80]"
overlayClassName="px-4 py-6"
panelClassName="platform-remap-surface rounded-[1.35rem] p-3 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
headerClassName="mb-3 items-center border-b-0 px-1 py-0"
titleClassName="text-sm font-black"
bodyClassName="px-0 py-0"
>
{previewReferenceImage ? (
<div className="max-h-[72vh] overflow-hidden rounded-[1rem] bg-black/5">
<ResolvedAssetImage
src={previewReferenceImage.imageSrc}
alt={labels.promptReferencePreviewAlt}
className="h-full max-h-[72vh] w-full object-contain"
/>
</div>
</div>
) : null}
) : null}
</UnifiedModal>
{isMainImagePreviewOpen && uploadedImageSrc ? (
<div
className="platform-modal-backdrop fixed inset-0 z-[82] flex items-center justify-center px-4 py-6"
onClick={() => setIsMainImagePreviewOpen(false)}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="creative-image-main-preview-title"
className="platform-modal-shell platform-remap-surface w-full max-w-4xl rounded-[1.35rem] p-3 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
onClick={(event) => event.stopPropagation()}
>
<div className="mb-3 flex items-center justify-between gap-3 px-1">
<div
id="creative-image-main-preview-title"
className="min-w-0 truncate text-sm font-black text-[var(--platform-text-strong)]"
>
{labels.previewMainImage ?? uploadedImageAlt}
</div>
<button
type="button"
aria-label={
labels.closeMainImagePreview ?? labels.closePromptReferencePreview
}
onClick={() => setIsMainImagePreviewOpen(false)}
className="platform-profile-icon-button flex h-8 w-8 shrink-0 items-center justify-center rounded-full"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="max-h-[82vh] overflow-hidden rounded-[1rem] bg-black/5">
<ResolvedAssetImage
src={uploadedImageSrc}
refreshKey={uploadedImageRefreshKey}
alt={uploadedImageAlt}
className="h-full max-h-[82vh] w-full object-contain"
/>
</div>
<UnifiedModal
open={isMainImagePreviewOpen && Boolean(uploadedImageSrc)}
title={labels.previewMainImage ?? uploadedImageAlt}
onClose={() => setIsMainImagePreviewOpen(false)}
closeLabel={
labels.closeMainImagePreview ?? labels.closePromptReferencePreview
}
closeVariant="profileCompact"
size="xl"
zIndexClassName="z-[82]"
overlayClassName="px-4 py-6"
panelClassName="platform-remap-surface rounded-[1.35rem] p-3 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
headerClassName="mb-3 items-center border-b-0 px-1 py-0"
titleClassName="text-sm font-black"
bodyClassName="px-0 py-0"
>
{uploadedImageSrc ? (
<div className="max-h-[82vh] overflow-hidden rounded-[1rem] bg-black/5">
<ResolvedAssetImage
src={uploadedImageSrc}
refreshKey={uploadedImageRefreshKey}
alt={uploadedImageAlt}
className="h-full max-h-[82vh] w-full object-contain"
/>
</div>
</div>
) : null}
) : null}
</UnifiedModal>
{isRemoveImageConfirmOpen ? (
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
<div
role="dialog"
aria-modal="true"
aria-labelledby="creative-image-remove-confirm-title"
className="platform-modal-shell platform-remap-surface w-full max-w-xs rounded-[1.35rem] p-5 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
>
<div
id="creative-image-remove-confirm-title"
className="text-base font-black text-[var(--platform-text-strong)]"
>
{labels.removeImageConfirmTitle}
</div>
<div className="mt-2 text-sm leading-6 text-[var(--platform-text-base)]">
{labels.removeImageConfirmBody}
</div>
<div className="mt-5 grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setIsRemoveImageConfirmOpen(false)}
className="platform-button platform-button--secondary justify-center"
>
</button>
<button
type="button"
onClick={() => {
onMainImageRemove();
setIsRemoveImageConfirmOpen(false);
}}
className="platform-button platform-button--primary justify-center"
>
</button>
</div>
</div>
</div>
) : null}
<UnifiedConfirmDialog
open={isRemoveImageConfirmOpen}
title={labels.removeImageConfirmTitle}
description={labels.removeImageConfirmBody}
onClose={() => setIsRemoveImageConfirmOpen(false)}
confirmLabel="移除"
cancelLabel="取消"
showCancel
onConfirm={() => {
onMainImageRemove();
setIsRemoveImageConfirmOpen(false);
}}
size="sm"
zIndexClassName="z-[80]"
overlayClassName="px-4 py-6"
panelClassName="platform-remap-surface max-w-xs rounded-[1.35rem] shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
/>
</div>
);
}

View File

@@ -5,6 +5,7 @@ import type {
LegalDocument,
LegalDocumentBlock,
} from './legalDocuments';
import { PlatformActionButton } from './PlatformActionButton';
import { UnifiedModal } from './UnifiedModal';
type LegalDocumentModalProps = {
@@ -95,13 +96,14 @@ export function LegalDocumentModal({
bodyClassName="px-4 py-0 sm:px-5"
footerClassName="justify-stretch sm:justify-end"
footer={
<button
type="button"
<PlatformActionButton
onClick={onClose}
className="platform-button platform-button--secondary min-h-0 w-full rounded-[0.9rem] px-4 py-2.5 text-sm sm:w-auto"
tone="secondary"
fullWidth
className="min-h-0 rounded-[0.9rem] sm:w-auto"
>
</button>
</PlatformActionButton>
}
>
<div className="max-h-[min(64vh,34rem)] overflow-y-auto py-4 pr-1">

View File

@@ -0,0 +1,48 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import { PlatformAcknowledgeStatusDialog } from './PlatformAcknowledgeStatusDialog';
test('renders a standard acknowledge action and closes through 知道了', () => {
const onClose = vi.fn();
render(
<PlatformAcknowledgeStatusDialog
status="error"
title="提示"
description="至少保留一个可扮演角色。"
onClose={onClose}
/>,
);
const action = screen.getByRole('button', { name: '知道了' });
expect(action.className).toContain('platform-button');
fireEvent.click(action);
expect(onClose).toHaveBeenCalledTimes(1);
});
test('supports custom action styling and header notice layout', () => {
render(
<PlatformAcknowledgeStatusDialog
status="error"
title="发布失败"
description="还缺少 16 个基础动作"
onClose={() => {}}
showHeader
showCloseButton
closeOnBackdrop
iconLabel="发布失败提示"
actionClassName="border-slate-950 bg-slate-950 text-white"
/>,
);
const action = screen.getByRole('button', { name: '知道了' });
const dialog = screen.getByRole('dialog', { name: '发布失败' });
expect(action.className).toContain('border-slate-950');
expect(action.className).toContain('bg-slate-950');
expect(dialog.querySelector('[aria-label="发布失败提示"]')).toBeTruthy();
});

View File

@@ -0,0 +1,111 @@
import type { ReactNode } from 'react';
import type {
PlatformActionButtonSize,
PlatformActionButtonSurface,
PlatformActionButtonTone,
} from './platformActionButtonModel';
import {
PlatformStatusDialog,
type PlatformStatusDialogStatus,
} from './PlatformStatusDialog';
type PlatformAcknowledgeStatusDialogProps = {
open?: boolean;
status: PlatformStatusDialogStatus;
title: string;
description?: ReactNode;
children?: ReactNode;
onClose: () => void;
actionLabel?: string;
actionDisabled?: boolean;
actionTone?: PlatformActionButtonTone;
actionSurface?: PlatformActionButtonSurface;
actionSize?: PlatformActionButtonSize;
actionFullWidth?: boolean;
actionClassName?: string;
showHeader?: boolean;
showBodyTitle?: boolean;
showCloseButton?: boolean;
closeOnBackdrop?: boolean;
closeOnEscape?: boolean;
closeLabel?: string;
closeDisabled?: boolean;
zIndexClassName?: string;
overlayClassName?: string;
panelClassName?: string;
bodyClassName?: string;
iconClassName?: string;
icon?: ReactNode;
iconLabel?: string;
};
/**
* 平台已读确认状态弹窗。
* 统一承接“状态提示 + 知道了”这一类单按钮确认已读的弹窗语义。
*/
export function PlatformAcknowledgeStatusDialog({
open,
status,
title,
description,
children,
onClose,
actionLabel = '知道了',
actionDisabled = false,
actionTone,
actionSurface = 'platform',
actionSize,
actionFullWidth,
actionClassName,
showHeader,
showBodyTitle,
showCloseButton,
closeOnBackdrop,
closeOnEscape,
closeLabel,
closeDisabled,
zIndexClassName,
overlayClassName,
panelClassName,
bodyClassName,
iconClassName,
icon,
iconLabel,
}: PlatformAcknowledgeStatusDialogProps) {
return (
<PlatformStatusDialog
open={open}
status={status}
title={title}
description={description}
onClose={onClose}
showHeader={showHeader}
showBodyTitle={showBodyTitle}
showCloseButton={showCloseButton}
closeOnBackdrop={closeOnBackdrop}
closeOnEscape={closeOnEscape}
closeLabel={closeLabel}
closeDisabled={closeDisabled}
zIndexClassName={zIndexClassName}
overlayClassName={overlayClassName}
panelClassName={panelClassName}
bodyClassName={bodyClassName}
iconClassName={iconClassName}
icon={icon}
iconLabel={iconLabel}
action={{
label: actionLabel,
onClick: onClose,
disabled: actionDisabled,
tone: actionTone,
surface: actionSurface,
size: actionSize,
fullWidth: actionFullWidth,
className: actionClassName,
}}
>
{children}
</PlatformStatusDialog>
);
}

View File

@@ -0,0 +1,111 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { expect, test } from 'vitest';
import { PlatformActionButton } from './PlatformActionButton';
test('renders platform primary action button by default', () => {
render(<PlatformActionButton></PlatformActionButton>);
const button = screen.getByRole('button', { name: '确认' });
expect(button.className).toContain('platform-button');
expect(button.className).toContain('platform-button--primary');
expect(button.className).toContain('rounded-2xl');
expect(button.className).toContain('disabled:cursor-not-allowed');
});
test('supports profile primary button surface', () => {
render(
<PlatformActionButton surface="profile" fullWidth size="md">
</PlatformActionButton>,
);
const button = screen.getByRole('button', { name: '兑换' });
expect(button.className).toContain('platform-primary-button');
expect(button.className).toContain('w-full');
expect(button.className).toContain('py-3');
});
test('supports secondary and pill variants', () => {
render(
<PlatformActionButton tone="secondary" shape="pill" size="xs" align="start">
</PlatformActionButton>,
);
const button = screen.getByRole('button', { name: '重新加载' });
expect(button.className).toContain('platform-button--secondary');
expect(button.className).toContain('rounded-full');
expect(button.className).toContain('text-xs');
expect(button.className).toContain('justify-start');
expect(button.className).toContain('text-left');
});
test('supports label child chrome for file upload controls', () => {
render(
<PlatformActionButton asChild="label" tone="secondary" htmlFor="upload">
</PlatformActionButton>,
);
const label = screen.getByText('上传');
expect(label.tagName).toBe('LABEL');
expect(label.getAttribute('for')).toBe('upload');
expect(label.className).toContain('platform-button--secondary');
});
test('supports editor dark action surface', () => {
render(
<PlatformActionButton surface="editorDark" tone="warning" size="xxs">
</PlatformActionButton>,
);
const button = screen.getByRole('button', { name: '确认前往' });
expect(button.className).toContain('platform-action-button--editor-dark');
expect(button.className).toContain('border-amber-300/30');
expect(button.className).toContain('bg-amber-500/20');
expect(button.className).toContain('text-[10px]');
});
test('supports accent action tone', () => {
render(
<PlatformActionButton surface="editorDark" tone="accent" size="lg" fullWidth>
</PlatformActionButton>,
);
const button = screen.getByRole('button', { name: '生成' });
expect(button.className).toContain('platform-action-button--accent');
expect(button.className).toContain('bg-amber-200');
expect(button.className).toContain('text-slate-950');
expect(button.className).toContain('h-12');
expect(button.className).toContain('w-full');
});
test('supports accent soft action tone', () => {
render(
<PlatformActionButton
tone="accentSoft"
className="[--platform-action-accent:var(--platform-work-like-accent,#c7653d)]"
>
</PlatformActionButton>,
);
const button = screen.getByRole('button', { name: '点赞' });
expect(button.className).toContain('platform-action-button--accent-soft');
expect(button.className).toContain('[color:var(--platform-action-accent,#c7653d)]');
expect(button.className).toContain(
'[--platform-action-accent:var(--platform-work-like-accent,#c7653d)]',
);
});

View File

@@ -0,0 +1,95 @@
import type {
ButtonHTMLAttributes,
LabelHTMLAttributes,
ReactNode,
} from 'react';
import {
getPlatformActionButtonClassName,
type PlatformActionButtonAlign,
type PlatformActionButtonShape,
type PlatformActionButtonSize,
type PlatformActionButtonSurface,
type PlatformActionButtonTone,
} from './platformActionButtonModel';
type PlatformActionButtonBaseProps = {
children: ReactNode;
tone?: PlatformActionButtonTone;
surface?: PlatformActionButtonSurface;
size?: PlatformActionButtonSize;
shape?: PlatformActionButtonShape;
align?: PlatformActionButtonAlign;
fullWidth?: boolean;
};
type PlatformActionButtonButtonProps = Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
'children'
> &
PlatformActionButtonBaseProps & {
asChild?: false;
};
type PlatformActionButtonLabelProps = Omit<
LabelHTMLAttributes<HTMLLabelElement>,
'children'
> &
PlatformActionButtonBaseProps & {
asChild: 'label';
};
type PlatformActionButtonProps =
| PlatformActionButtonButtonProps
| PlatformActionButtonLabelProps;
/**
* 平台通用动作按钮。
* 收口平台与个人中心主动作按钮的样式族、尺寸、圆角和禁用态 class。
*/
export function PlatformActionButton({
children,
tone = 'primary',
surface = 'platform',
size = 'sm',
shape = 'default',
align = 'center',
fullWidth = false,
className,
asChild,
...buttonProps
}: PlatformActionButtonProps) {
const actionClassName = [
getPlatformActionButtonClassName({
surface,
tone,
size,
shape,
align,
fullWidth,
}),
className,
]
.filter(Boolean)
.join(' ');
if (asChild === 'label') {
return (
<label
{...(buttonProps as LabelHTMLAttributes<HTMLLabelElement>)}
className={actionClassName}
>
{children}
</label>
);
}
const { type = 'button', ...restButtonProps } =
buttonProps as ButtonHTMLAttributes<HTMLButtonElement>;
return (
<button {...restButtonProps} type={type} className={actionClassName}>
{children}
</button>
);
}

View File

@@ -0,0 +1,264 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import {
PlatformAssetPickerCard,
PlatformAssetPickerGrid,
} from './PlatformAssetPickerCard';
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: ({
src,
alt,
className,
}: {
src?: string | null;
alt?: string;
className?: string;
}) => <img src={src ?? ''} alt={alt} className={className} />,
}));
test('renders historical asset thumbnail with title and subtitle', () => {
render(
<PlatformAssetPickerCard
imageSrc="/history/a.png"
imageAlt="历史图片"
assetTitle="封面图"
subtitle="2026-06-09 10:00"
aria-label="选择封面图"
/>,
);
const button = screen.getByRole('button', { name: '选择封面图' });
const image = screen.getByRole('img', { name: '历史图片' });
expect(button.className).toContain(
'border-[var(--platform-subpanel-border)]',
);
expect(button.className).toContain('hover:border-amber-300/70');
expect(image.getAttribute('src')).toBe('/history/a.png');
expect(screen.getByText('封面图')).toBeTruthy();
expect(screen.getByText('2026-06-09 10:00')).toBeTruthy();
});
test('keeps disabled picker card inert', () => {
const onClick = vi.fn();
render(
<PlatformAssetPickerCard
imageSrc="/history/a.png"
imageAlt=""
assetTitle="历史素材"
disabled
onClick={onClick}
/>,
);
const button = screen.getByRole('button', { name: '历史素材' });
fireEvent.click(button);
expect(button).toHaveProperty('disabled', true);
expect(button.className).toContain('cursor-not-allowed');
expect(onClick).not.toHaveBeenCalled();
});
test('supports local radius and body classes for dense pickers', () => {
render(
<PlatformAssetPickerCard
imageSrc="/history/a.png"
imageAlt="历史图片"
subtitle="刚刚"
cardRadiusClassName="rounded-[1.25rem]"
bodyClassName="px-4 py-3"
aria-label="选择历史图片"
/>,
);
const button = screen.getByRole('button', { name: '选择历史图片' });
expect(button.className).toContain('rounded-[1.25rem]');
expect(screen.getByText('刚刚').parentElement?.className).toContain('px-4');
});
test('supports local image shell classes for landscape assets', () => {
render(
<PlatformAssetPickerCard
imageSrc="/history/scene.png"
imageAlt="场景图"
imageShellClassName="aspect-[16/9]"
aria-label="选择场景图"
/>,
);
expect(
screen.getByRole('img', { name: '场景图' }).parentElement?.className,
).toContain('aspect-[16/9]');
});
test('renders selected picker cards with shared selected chrome', () => {
render(
<PlatformAssetPickerCard
imageSrc="/history/a.png"
imageAlt="历史图片"
assetTitle="已选择素材"
aria-label="选择历史图片"
selected
/>,
);
const button = screen.getByRole('button', { name: '选择历史图片' });
expect(button.className).toContain('ring-2');
expect(button.className).toContain('border-[var(--platform-warm-border)]');
});
test('renders shared loading and empty states for asset grids', () => {
const { rerender } = render(
<PlatformAssetPickerGrid
items={[]}
isLoading
loadingLabel="读取中..."
emptyLabel="暂无历史素材"
getKey={(item: { id: string }) => item.id}
getImageSrc={(item) => item.id}
getImageAlt={() => ''}
onSelect={() => {}}
/>,
);
expect(screen.getByText('读取中...').className).toContain('border-dashed');
rerender(
<PlatformAssetPickerGrid
items={[]}
loadingLabel="读取中..."
emptyLabel="暂无历史素材"
getKey={(item: { id: string }) => item.id}
getImageSrc={(item) => item.id}
getImageAlt={() => ''}
onSelect={() => {}}
/>,
);
expect(screen.getByText('暂无历史素材').className).toContain('border-dashed');
});
test('renders selectable asset grid cards with shared error chrome', () => {
const onSelect = vi.fn();
render(
<PlatformAssetPickerGrid
items={[
{
id: 'asset-1',
imageSrc: '/history/a.png',
title: '历史素材',
createdAt: '2026-06-09',
},
]}
error="历史素材读取失败。"
loadingLabel="读取中..."
emptyLabel="暂无历史素材"
getKey={(item) => item.id}
getImageSrc={(item) => item.imageSrc}
getImageAlt={() => '历史素材'}
getTitle={(item) => item.title}
getSubtitle={(item) => item.createdAt}
getAriaLabel={(item) => `选择${item.title}`}
isSelected={(item) => item.id === 'asset-1'}
onSelect={onSelect}
gridClassName="grid grid-cols-1"
/>,
);
expect(screen.getByText('历史素材读取失败。').className).toContain(
'text-[var(--platform-button-danger-text)]',
);
fireEvent.click(screen.getByRole('button', { name: '选择历史素材' }));
expect(
screen.getByRole('button', { name: '选择历史素材' }).className,
).toContain('ring-2');
expect(onSelect).toHaveBeenCalledWith(
expect.objectContaining({ id: 'asset-1' }),
);
});
test('asset grid keeps error banner while loading state remains mutually exclusive with empty state', () => {
render(
<PlatformAssetPickerGrid
items={[]}
isLoading
error="历史素材读取失败。"
loadingLabel="读取中..."
emptyLabel="暂无历史素材"
getKey={(item: { id: string }) => item.id}
getImageSrc={(item) => item.id}
getImageAlt={() => ''}
onSelect={() => {}}
/>,
);
expect(screen.getByText('历史素材读取失败。')).toBeTruthy();
expect(screen.getByText('读取中...')).toBeTruthy();
expect(screen.queryByText('暂无历史素材')).toBeNull();
});
test('supports dark editor surface with an in-card select affordance', () => {
const onSelect = vi.fn();
render(
<PlatformAssetPickerGrid
items={[
{
id: 'asset-dark-1',
imageSrc: '/history/dark.png',
title: '角色立绘',
createdAt: '2026-06-09',
},
]}
surface="editorDark"
selectLabel="使用"
loadingLabel="读取中..."
emptyLabel="暂无历史素材"
getKey={(item) => item.id}
getImageSrc={(item) => item.imageSrc}
getImageAlt={(item) => item.title}
getTitle={(item) => item.title}
getSubtitle={(item) => item.createdAt}
onSelect={onSelect}
/>,
);
const button = screen.getByRole('button', { name: //u });
expect(button.className).toContain('bg-black/20');
expect(screen.getByText('使用').className).toContain('bg-sky-500/12');
fireEvent.click(button);
expect(onSelect).toHaveBeenCalledWith(
expect.objectContaining({ id: 'asset-dark-1' }),
);
});
test('uses dark empty-state chrome for editor asset grids', () => {
render(
<PlatformAssetPickerGrid
items={[]}
surface="editorDark"
loadingLabel="读取中..."
emptyLabel="暂无历史素材"
getKey={(item: { id: string }) => item.id}
getImageSrc={(item) => item.id}
getImageAlt={() => ''}
onSelect={() => {}}
/>,
);
expect(screen.getByText('暂无历史素材').className).toContain('bg-black/20');
expect(screen.getByText('暂无历史素材').className).toContain('text-zinc-300');
});

View File

@@ -0,0 +1,322 @@
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>
</>
);
}

View File

@@ -0,0 +1,61 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { describe, expect, test } from 'vitest';
import { PlatformAsyncStatePanel } from './PlatformAsyncStatePanel';
describe('PlatformAsyncStatePanel', () => {
test('prefers error state over loading and content', () => {
render(
<PlatformAsyncStatePanel
errorState={<div></div>}
isLoading
loadingState={<div></div>}
>
<div></div>
</PlatformAsyncStatePanel>,
);
expect(screen.getByText('出错了')).toBeTruthy();
expect(screen.queryByText('读取中')).toBeNull();
expect(screen.queryByText('内容')).toBeNull();
});
test('renders loading state before empty state', () => {
render(
<PlatformAsyncStatePanel
isLoading
isEmpty
loadingState={<div></div>}
emptyState={<div></div>}
>
<div></div>
</PlatformAsyncStatePanel>,
);
expect(screen.getByText('读取中')).toBeTruthy();
expect(screen.queryByText('暂无内容')).toBeNull();
});
test('renders empty state when requested', () => {
render(
<PlatformAsyncStatePanel isEmpty emptyState={<div></div>}>
<div></div>
</PlatformAsyncStatePanel>,
);
expect(screen.getByText('暂无内容')).toBeTruthy();
expect(screen.queryByText('内容')).toBeNull();
});
test('falls back to content when no async state is active', () => {
render(
<PlatformAsyncStatePanel>
<div></div>
</PlatformAsyncStatePanel>,
);
expect(screen.getByText('内容')).toBeTruthy();
});
});

View File

@@ -0,0 +1,37 @@
import type { ReactNode } from 'react';
type PlatformAsyncStatePanelProps = {
errorState?: ReactNode;
isLoading?: boolean;
loadingState?: ReactNode;
isEmpty?: boolean;
emptyState?: ReactNode;
children: ReactNode;
};
/**
* 平台异步状态面板骨架。
* 只负责在错误、读取、空态和内容之间切换,具体文案与外观继续交给调用方传入 slot。
*/
export function PlatformAsyncStatePanel({
errorState,
isLoading = false,
loadingState = null,
isEmpty = false,
emptyState = null,
children,
}: PlatformAsyncStatePanelProps) {
if (errorState !== undefined && errorState !== null) {
return <>{errorState}</>;
}
if (isLoading) {
return <>{loadingState}</>;
}
if (isEmpty) {
return <>{emptyState}</>;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,35 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { expect, test } from 'vitest';
import { PlatformBackActionButton } from './PlatformBackActionButton';
test('renders compact back action button by default', () => {
render(<PlatformBackActionButton />);
const button = screen.getByRole('button', { name: '返回' });
expect(button.className).toContain('platform-button--ghost');
expect(button.className).toContain('min-h-0');
expect(button.className).toContain('text-[11px]');
expect(button.className).toContain('gap-1.5');
expect(button.querySelector('svg')?.className.baseVal).toContain('h-3.5');
});
test('supports regular variant and editor dark surface', () => {
render(
<PlatformBackActionButton
label="返回编辑"
variant="regular"
surface="editorDark"
/>,
);
const button = screen.getByRole('button', { name: '返回编辑' });
expect(button.className).toContain('platform-action-button--editor-dark');
expect(button.className).toContain('text-sm');
expect(button.className).toContain('gap-2');
expect(button.querySelector('svg')?.className.baseVal).toContain('h-4');
});

View File

@@ -0,0 +1,58 @@
import type { ButtonHTMLAttributes } from 'react';
import { ArrowLeft } from 'lucide-react';
import { PlatformActionButton } from './PlatformActionButton';
import type { PlatformActionButtonSurface } from './platformActionButtonModel';
type PlatformBackActionButtonVariant = 'compact' | 'regular';
type PlatformBackActionButtonProps = Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
'children'
> & {
label?: string;
variant?: PlatformBackActionButtonVariant;
surface?: PlatformActionButtonSurface;
};
const VARIANT_CLASS: Record<PlatformBackActionButtonVariant, string> = {
compact: 'gap-1.5 py-1.5 text-[11px]',
regular: 'gap-2 py-2 text-sm',
};
const ICON_CLASS: Record<PlatformBackActionButtonVariant, string> = {
compact: 'h-3.5 w-3.5',
regular: 'h-4 w-4',
};
/**
* 平台轻量返回动作按钮。
* 统一结果页、工作台等白底场景里的“左箭头 + 返回文案”按钮骨架。
*/
export function PlatformBackActionButton({
label = '返回',
variant = 'compact',
surface = 'platform',
className,
...buttonProps
}: PlatformBackActionButtonProps) {
return (
<PlatformActionButton
{...buttonProps}
surface={surface}
tone="ghost"
size="xs"
className={[
'min-h-0 self-start',
VARIANT_CLASS[variant],
className,
]
.filter(Boolean)
.join(' ')}
>
<ArrowLeft className={ICON_CLASS[variant]} />
{label}
</PlatformActionButton>
);
}

View File

@@ -0,0 +1,68 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, within } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import { PlatformDangerConfirmDialog } from './PlatformDangerConfirmDialog';
test('renders a standard danger confirmation with cancel and confirm actions', () => {
const onClose = vi.fn();
const onConfirm = vi.fn();
render(
<PlatformDangerConfirmDialog
open
title="删除作品"
description="确认删除《潮雾列岛》吗?"
onClose={onClose}
onConfirm={onConfirm}
confirmLabel="确认删除"
>
</PlatformDangerConfirmDialog>,
);
const dialog = screen.getByRole('dialog', { name: '删除作品' });
expect(within(dialog).getByText('确认删除《潮雾列岛》吗?')).toBeTruthy();
expect(within(dialog).getByText('删除后不可恢复。')).toBeTruthy();
fireEvent.click(within(dialog).getByRole('button', { name: '取消' }));
expect(onClose).toHaveBeenCalledTimes(1);
fireEvent.click(within(dialog).getByRole('button', { name: '确认删除' }));
expect(onConfirm).toHaveBeenCalledTimes(1);
});
test('forwards busy state and custom busy label for destructive actions', () => {
const onClose = vi.fn();
const onConfirm = vi.fn();
render(
<PlatformDangerConfirmDialog
open
title="删除作品"
onClose={onClose}
onConfirm={onConfirm}
confirmLabel="确认删除"
busyConfirmLabel="删除中"
busy
closeOnBackdrop={false}
>
</PlatformDangerConfirmDialog>,
);
const dialog = screen.getByRole('dialog', { name: '删除作品' });
const confirmButton = within(dialog).getByRole('button', { name: '删除中' });
const cancelButton = within(dialog).getByRole('button', { name: '取消' });
expect((confirmButton as HTMLButtonElement).disabled).toBe(true);
expect((cancelButton as HTMLButtonElement).disabled).toBe(true);
fireEvent.click(confirmButton);
fireEvent.click(cancelButton);
expect(onClose).not.toHaveBeenCalled();
expect(onConfirm).not.toHaveBeenCalled();
});

View File

@@ -0,0 +1,78 @@
import type { ReactNode } from 'react';
import { UnifiedConfirmDialog } from './UnifiedConfirmDialog';
type PlatformDangerConfirmDialogProps = {
open: boolean;
title: string;
onClose: () => void;
onConfirm?: () => void;
description?: ReactNode;
children?: ReactNode;
confirmLabel?: string;
busy?: boolean;
busyConfirmLabel?: string;
cancelLabel?: string;
closeOnBackdrop?: boolean;
showCloseButton?: boolean;
portal?: boolean;
size?: 'sm' | 'md';
variant?: 'platform' | 'pixel';
overlayClassName?: string;
panelClassName?: string;
footerClassName?: string;
confirmClassName?: string;
};
/**
* 平台危险确认弹窗。
* 统一承接需要“确认 / 取消 + 危险主动作”语义的标准弹窗壳层。
*/
export function PlatformDangerConfirmDialog({
open,
title,
onClose,
onConfirm,
description,
children,
confirmLabel = '确认',
busy = false,
busyConfirmLabel,
cancelLabel = '取消',
closeOnBackdrop = true,
showCloseButton = true,
portal = true,
size = 'sm',
variant = 'platform',
overlayClassName,
panelClassName,
footerClassName,
confirmClassName,
}: PlatformDangerConfirmDialogProps) {
return (
<UnifiedConfirmDialog
open={open}
title={title}
description={description}
onClose={onClose}
onConfirm={onConfirm}
confirmLabel={confirmLabel}
busy={busy}
busyConfirmLabel={busyConfirmLabel}
cancelLabel={cancelLabel}
closeOnBackdrop={closeOnBackdrop}
showCloseButton={showCloseButton}
showCancel
confirmTone="danger"
portal={portal}
size={size}
variant={variant}
overlayClassName={overlayClassName}
panelClassName={panelClassName}
footerClassName={footerClassName}
confirmClassName={confirmClassName}
>
{children}
</UnifiedConfirmDialog>
);
}

View File

@@ -0,0 +1,65 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { describe, expect, test } from 'vitest';
import { PlatformDarkModalFooter } from './PlatformDarkModalFooter';
describe('PlatformDarkModalFooter', () => {
test('renders bordered action footer with shared action row chrome', () => {
render(
<PlatformDarkModalFooter data-testid="footer">
<button type="button"></button>
<button type="button"></button>
</PlatformDarkModalFooter>,
);
const footer = screen.getByTestId('footer');
const actions = footer.querySelector('.platform-dark-modal-footer__actions');
expect(footer.className).toContain('platform-dark-modal-footer');
expect(footer.className).toContain('border-t');
expect(footer.className).toContain('border-white/10');
expect(footer.className).toContain('px-4');
expect(footer.className).toContain('py-3');
expect(actions?.className).toContain('justify-end');
expect(actions?.className).toContain('gap-3');
});
test('supports unbordered bottom padding actions for modal footer tails', () => {
render(
<PlatformDarkModalFooter
bordered={false}
padding="bottom"
gap="sm"
data-testid="footer"
>
<button type="button"></button>
</PlatformDarkModalFooter>,
);
const footer = screen.getByTestId('footer');
const actions = footer.querySelector('.platform-dark-modal-footer__actions');
expect(footer.className).not.toContain('border-t');
expect(footer.className).toContain('px-5');
expect(footer.className).toContain('pb-5');
expect(actions?.className).toContain('gap-2');
});
test('supports content layout without wrapping children in an actions row', () => {
render(
<PlatformDarkModalFooter layout="content" data-testid="footer">
<div data-testid="content"></div>
</PlatformDarkModalFooter>,
);
const footer = screen.getByTestId('footer');
expect(screen.getByTestId('content')).toBeTruthy();
expect(
footer.querySelector('.platform-dark-modal-footer__actions'),
).toBeNull();
expect(footer.className).toContain('platform-dark-modal-footer');
});
});

View File

@@ -0,0 +1,86 @@
import type { ComponentPropsWithoutRef, ReactNode } from 'react';
type PlatformDarkModalFooterLayout = 'actions' | 'content';
type PlatformDarkModalFooterPadding = 'compact' | 'roomy' | 'bottom';
type PlatformDarkModalFooterGap = 'sm' | 'md';
type PlatformDarkModalFooterAlign = 'start' | 'center' | 'end' | 'between';
type PlatformDarkModalFooterProps = ComponentPropsWithoutRef<'div'> & {
children: ReactNode;
bordered?: boolean;
layout?: PlatformDarkModalFooterLayout;
padding?: PlatformDarkModalFooterPadding;
gap?: PlatformDarkModalFooterGap;
wrap?: boolean;
align?: PlatformDarkModalFooterAlign;
contentClassName?: string;
};
const PADDING_CLASS: Record<PlatformDarkModalFooterPadding, string> = {
compact: 'px-4 py-3 sm:px-5 sm:py-4',
roomy: 'px-5 py-4',
bottom: 'px-5 pb-5',
};
const GAP_CLASS: Record<PlatformDarkModalFooterGap, string> = {
sm: 'gap-2',
md: 'gap-3',
};
const ALIGN_CLASS: Record<PlatformDarkModalFooterAlign, string> = {
start: 'justify-start',
center: 'justify-center',
end: 'justify-end',
between: 'justify-between',
};
/**
* 暗色 / 像素弹层底部 footer 骨架。
* 统一承接 border、padding 和常见的动作按钮排布,避免业务页重复手写同一套 chrome。
*/
export function PlatformDarkModalFooter({
children,
bordered = true,
layout = 'actions',
padding = 'compact',
gap = 'md',
wrap = false,
align = 'end',
className,
contentClassName,
...props
}: PlatformDarkModalFooterProps) {
const frameClassName = [
'platform-dark-modal-footer',
bordered ? 'border-t border-white/10' : null,
PADDING_CLASS[padding],
className ?? null,
]
.filter(Boolean)
.join(' ');
if (layout === 'content') {
return (
<div className={frameClassName} {...props}>
{children}
</div>
);
}
const actionsClassName = [
'platform-dark-modal-footer__actions',
'flex items-center',
ALIGN_CLASS[align],
GAP_CLASS[gap],
wrap ? 'flex-wrap' : null,
contentClassName ?? null,
]
.filter(Boolean)
.join(' ');
return (
<div className={frameClassName} {...props}>
<div className={actionsClassName}>{children}</div>
</div>
);
}

View File

@@ -0,0 +1,95 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test, vi } from 'vitest';
import { PlatformDarkOptionCard } from './PlatformDarkOptionCard';
test('renders selected dark option card with tone classes', () => {
render(
<PlatformDarkOptionCard selected tone="rose" className="w-full">
</PlatformDarkOptionCard>,
);
const card = screen.getByRole('button', { name: '玫瑰信物' });
expect(card.className).toContain('platform-dark-option-card');
expect(card.className).toContain('border-rose-400/60');
expect(card.className).toContain('bg-rose-500/10');
expect(card.className).toContain('w-full');
});
test('renders idle dark option card and forwards button behavior', async () => {
const user = userEvent.setup();
const handleClick = vi.fn();
render(
<PlatformDarkOptionCard
selected={false}
radius="sm"
padding="md"
onClick={handleClick}
>
</PlatformDarkOptionCard>,
);
const card = screen.getByRole('button', { name: '月壳' });
expect(card.getAttribute('type')).toBe('button');
expect(card.className).toContain('border-white/8');
expect(card.className).toContain('hover:border-white/15');
expect(card.className).toContain('rounded-lg');
expect(card.className).toContain('py-2.5');
await user.click(card);
expect(handleClick).toHaveBeenCalledTimes(1);
});
test('supports emerald, sky, and amber selected tones', () => {
const { rerender } = render(
<PlatformDarkOptionCard selected tone="emerald">
</PlatformDarkOptionCard>,
);
expect(screen.getByRole('button', { name: '购买物品' }).className).toContain(
'border-emerald-400/45',
);
rerender(
<PlatformDarkOptionCard selected tone="sky">
</PlatformDarkOptionCard>,
);
expect(screen.getByRole('button', { name: '出售物品' }).className).toContain(
'border-sky-400/45',
);
rerender(
<PlatformDarkOptionCard selected tone="amber">
</PlatformDarkOptionCard>,
);
expect(screen.getByRole('button', { name: '调整同行' }).className).toContain(
'border-amber-400/60',
);
});
test('supports large radius and spacing for dark option grids', () => {
render(
<PlatformDarkOptionCard selected tone="sky" radius="lg" padding="lg">
</PlatformDarkOptionCard>,
);
const card = screen.getByRole('button', { name: '奔跑' });
expect(card.className).toContain('rounded-2xl');
expect(card.className).toContain('py-3');
});

View File

@@ -0,0 +1,82 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react';
type PlatformDarkOptionTone = 'emerald' | 'sky' | 'rose' | 'amber';
type PlatformDarkOptionRadius = 'sm' | 'md' | 'lg';
type PlatformDarkOptionPadding = 'sm' | 'md' | 'lg';
type PlatformDarkOptionCardProps = Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
'children'
> & {
selected: boolean;
tone?: PlatformDarkOptionTone;
radius?: PlatformDarkOptionRadius;
padding?: PlatformDarkOptionPadding;
children: ReactNode;
};
const PLATFORM_DARK_OPTION_RADIUS_CLASS: Record<
PlatformDarkOptionRadius,
string
> = {
sm: 'rounded-lg',
md: 'rounded-xl',
lg: 'rounded-2xl',
};
const PLATFORM_DARK_OPTION_PADDING_CLASS: Record<
PlatformDarkOptionPadding,
string
> = {
sm: 'px-3 py-2',
md: 'px-3 py-2.5',
lg: 'px-3 py-3',
};
const PLATFORM_DARK_OPTION_SELECTED_CLASS: Record<
PlatformDarkOptionTone,
string
> = {
emerald: 'border-emerald-400/45 bg-emerald-500/10 text-emerald-100',
sky: 'border-sky-400/45 bg-sky-500/10 text-sky-100',
rose: 'border-rose-400/60 bg-rose-500/10 text-rose-100',
amber: 'border-amber-400/60 bg-amber-500/10 text-amber-100',
};
const PLATFORM_DARK_OPTION_IDLE_CLASS =
'border-white/8 bg-black/20 text-zinc-300 hover:border-white/15';
/**
* 暗色面板中的可选项按钮。
* 统一承接 selected / idle / hover / disabled 的暗色卡片外观。
*/
export function PlatformDarkOptionCard({
selected,
tone = 'emerald',
radius = 'md',
padding = 'sm',
children,
className,
type = 'button',
...buttonProps
}: PlatformDarkOptionCardProps) {
return (
<button
{...buttonProps}
type={type}
className={[
'platform-dark-option-card border text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/20 disabled:cursor-not-allowed disabled:opacity-55',
PLATFORM_DARK_OPTION_RADIUS_CLASS[radius],
PLATFORM_DARK_OPTION_PADDING_CLASS[padding],
selected
? PLATFORM_DARK_OPTION_SELECTED_CLASS[tone]
: PLATFORM_DARK_OPTION_IDLE_CLASS,
className,
]
.filter(Boolean)
.join(' ')}
>
{children}
</button>
);
}

View File

@@ -0,0 +1,52 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import { PlatformDetailShareActions } from './PlatformDetailShareActions';
test('renders overlay detail share actions with copied share state', () => {
render(
<PlatformDetailShareActions
workCode="CW-001"
copyState="idle"
onCopyWorkCode={vi.fn()}
shareState="copied"
onShare={vi.fn()}
shareAriaLabel="分享作品 测试世界"
leading={<span></span>}
variant="overlay"
/>,
);
const codeButton = screen.getByRole('button', { name: '复制作品号 CW-001' });
const shareButton = screen.getByRole('button', { name: '分享作品 测试世界' });
expect(screen.getByText('已发布')).toBeTruthy();
expect(codeButton.className).toContain('bg-white/72');
expect(codeButton.className).toContain('tracking-[0.18em]');
expect(shareButton.className).toContain('bg-white/72');
expect(screen.getByText('已复制')).toBeTruthy();
});
test('renders solid detail share actions with compact work code chip', () => {
render(
<PlatformDetailShareActions
workCode="PZ-001"
copyState="idle"
onCopyWorkCode={vi.fn()}
shareState="idle"
onShare={vi.fn()}
shareAriaLabel="分享作品 拼图世界"
leading={<span></span>}
variant="solid"
/>,
);
const codeButton = screen.getByRole('button', { name: 'PZ-001' });
const shareButton = screen.getByRole('button', { name: '分享作品 拼图世界' });
expect(codeButton.className).toContain('bg-[var(--platform-neutral-bg)]');
expect(shareButton.className).toContain('bg-[var(--platform-neutral-bg)]');
expect(screen.getByText('已发布')).toBeTruthy();
});

View File

@@ -0,0 +1,143 @@
import { Copy, Share2 } from 'lucide-react';
import type { ReactNode } from 'react';
import { CopyCodeButton } from './CopyCodeButton';
import { CopyFeedbackButton } from './CopyFeedbackButton';
import type { CopyFeedbackState } from './useCopyFeedback';
type PlatformDetailShareActionsProps = {
workCode?: string | null;
copyState: CopyFeedbackState;
onCopyWorkCode?: () => void;
shareState: CopyFeedbackState;
onShare?: () => void;
shareAriaLabel?: string;
shareTitle?: string;
leading?: ReactNode;
showCopyAction?: boolean;
showShareAction?: boolean;
variant?: 'overlay' | 'solid';
className?: string;
copyClassName?: string;
shareClassName?: string;
copyCodeLabel?: ReactNode;
copyAccessibleLabel?: string;
};
const VARIANT_COPY_CLASS = {
overlay: 'px-3 tracking-[0.18em]',
solid: '',
} as const;
const VARIANT_SHARE_CLASS = {
overlay: 'px-3 tracking-[0.18em]',
solid: '',
} as const;
const VARIANT_PILL_TONE = {
overlay: 'neutral',
solid: 'neutralSolid',
} as const;
const VARIANT_PILL_SIZE = {
overlay: 'xxs',
solid: 'sm',
} as const;
const VARIANT_ICON_CLASS = {
overlay: 'h-3 w-3',
solid: 'h-4 w-4',
} as const;
const VARIANT_SUFFIX_CLASS = {
overlay: 'text-xs',
solid: 'text-[11px]',
} as const;
function renderShareLabel(suffix: ReactNode | null, suffixClassName: string) {
return (
<>
<span></span>
{suffix ? <span className={suffixClassName}>{suffix}</span> : null}
</>
);
}
/**
* 详情页作品号 / 分享动作组合。
* 共享层只承接状态 badge 槽位、复制作品号和分享按钮这组稳定骨架。
*/
export function PlatformDetailShareActions({
workCode,
copyState,
onCopyWorkCode,
shareState,
onShare,
shareAriaLabel,
shareTitle = '分享作品',
leading,
showCopyAction = true,
showShareAction = true,
variant = 'overlay',
className,
copyClassName,
shareClassName,
copyCodeLabel,
copyAccessibleLabel,
}: PlatformDetailShareActionsProps) {
const canShowCopyAction = showCopyAction && Boolean(workCode);
const canShowShareAction = showShareAction && Boolean(workCode);
if (!leading && !canShowCopyAction && !canShowShareAction) {
return null;
}
const iconClassName = VARIANT_ICON_CLASS[variant];
const shareSuffixClassName = VARIANT_SUFFIX_CLASS[variant];
const resolvedCopyCodeLabel =
copyCodeLabel ?? (variant === 'solid' ? null : '作品号');
const resolvedCopyAccessibleLabel =
copyAccessibleLabel ?? (variant === 'solid' ? workCode ?? undefined : undefined);
return (
<div className={['flex flex-wrap items-center gap-2', className].filter(Boolean).join(' ')}>
{leading}
{canShowCopyAction ? (
<CopyCodeButton
state={copyState}
code={workCode ?? ''}
codeLabel={resolvedCopyCodeLabel}
accessibleLabel={resolvedCopyAccessibleLabel}
title="复制作品号"
onClick={onCopyWorkCode}
disabled={!onCopyWorkCode}
actionAppearance="pill"
actionPillTone={VARIANT_PILL_TONE[variant]}
actionPillSize={VARIANT_PILL_SIZE[variant]}
className={[VARIANT_COPY_CLASS[variant], copyClassName].filter(Boolean).join(' ')}
idleIcon={<Copy className={iconClassName} />}
copiedIcon={<Copy className={iconClassName} />}
suffixClassName={shareSuffixClassName}
/>
) : null}
{canShowShareAction ? (
<CopyFeedbackButton
state={shareState}
onClick={onShare}
disabled={!onShare}
actionAppearance="pill"
actionPillTone={VARIANT_PILL_TONE[variant]}
actionPillSize={VARIANT_PILL_SIZE[variant]}
className={[VARIANT_SHARE_CLASS[variant], shareClassName].filter(Boolean).join(' ')}
aria-label={shareAriaLabel}
title={shareTitle}
idleLabel={renderShareLabel(null, shareSuffixClassName)}
copiedLabel={renderShareLabel('已复制', shareSuffixClassName)}
failedLabel={renderShareLabel('复制失败', shareSuffixClassName)}
idleIcon={<Share2 className={iconClassName} />}
copiedIcon={<Share2 className={iconClassName} />}
/>
) : null}
</div>
);
}

View File

@@ -0,0 +1,49 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import { PlatformDetailTopbar } from './PlatformDetailTopbar';
test('renders pill back action with trailing slot', () => {
const onBack = vi.fn();
render(
<PlatformDetailTopbar
onBack={onBack}
className="grid-cols-[auto,minmax(0,1fr),auto]"
backButtonClassName="px-3"
trailing={<span></span>}
/>,
);
const button = screen.getByRole('button', { name: '返回' });
expect(button.className).toContain('platform-button--ghost');
expect(button.className).toContain('px-3');
expect(screen.getByText('已发布')).toBeTruthy();
fireEvent.click(button);
expect(onBack).toHaveBeenCalledTimes(1);
});
test('renders icon back action and centered title', () => {
render(
<PlatformDetailTopbar
onBack={vi.fn()}
backVariant="icon"
backButtonClassName="detail-icon-back"
title="详情"
titleClassName="detail-topbar-title"
trailing={<span className="invisible"></span>}
/>,
);
const button = screen.getByRole('button', { name: '返回' });
const title = screen.getByText('详情');
expect(button.className).toContain('platform-icon-button');
expect(button.className).toContain('detail-icon-back');
expect(title.className).toContain('detail-topbar-title');
});

View File

@@ -0,0 +1,89 @@
import { ArrowLeft } from 'lucide-react';
import type { ReactNode } from 'react';
import { PlatformBackActionButton } from './PlatformBackActionButton';
import { PlatformIconButton } from './PlatformIconButton';
type PlatformDetailTopbarProps = {
onBack: () => void;
title?: ReactNode;
trailing?: ReactNode;
backVariant?: 'icon' | 'pill';
backLabel?: string;
className?: string;
backButtonClassName?: string;
titleClassName?: string;
trailingClassName?: string;
};
/**
* 详情页顶部动作骨架。
* 只统一返回、标题和右侧动作槽位的布局,不吸收页面自己的标题文案或业务动作。
*/
export function PlatformDetailTopbar({
onBack,
title,
trailing,
backVariant = 'pill',
backLabel = '返回',
className,
backButtonClassName,
titleClassName,
trailingClassName,
}: PlatformDetailTopbarProps) {
const backAction =
backVariant === 'icon' ? (
<PlatformIconButton
label={backLabel}
title={backLabel}
className={backButtonClassName}
onClick={onBack}
icon={<ArrowLeft className="h-6 w-6" />}
/>
) : (
<PlatformBackActionButton
onClick={onBack}
label={backLabel}
className={backButtonClassName}
/>
);
return (
<div
className={[
'grid min-w-0 grid-cols-[auto,minmax(0,1fr),auto] items-center gap-3',
className,
]
.filter(Boolean)
.join(' ')}
>
<div className="min-w-0 justify-self-start">
{backAction}
</div>
{title ? (
<div
className={[
'min-w-0 text-center',
titleClassName,
]
.filter(Boolean)
.join(' ')}
>
{title}
</div>
) : (
<div aria-hidden="true" />
)}
<div
className={[
'min-w-0 justify-self-end',
trailingClassName,
]
.filter(Boolean)
.join(' ')}
>
{trailing}
</div>
</div>
);
}

View File

@@ -0,0 +1,73 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { expect, test } from 'vitest';
import { PlatformEmptyState } from './PlatformEmptyState';
test('renders compact soft platform empty state', () => {
render(<PlatformEmptyState></PlatformEmptyState>);
const emptyState = screen.getByText('暂无作品');
expect(emptyState.className).toContain('platform-empty-state');
expect(emptyState.className).toContain('platform-surface--soft');
expect(emptyState.className).toContain('rounded-[1.35rem]');
expect(emptyState.className).toContain('px-4');
});
test('supports dashed panel empty state for picker dialogs', () => {
render(
<PlatformEmptyState surface="dashed" size="panel" className="mt-2">
...
</PlatformEmptyState>,
);
const emptyState = screen.getByText('读取中...');
expect(emptyState.className).toContain('border-dashed');
expect(emptyState.className).toContain('min-h-[14rem]');
expect(emptyState.className).toContain('mt-2');
});
test('supports inline subpanel empty state for runtime panels', () => {
render(
<PlatformEmptyState surface="subpanel" size="inline">
</PlatformEmptyState>,
);
const emptyState = screen.getByText('暂无历史');
expect(emptyState.className).toContain('rounded-[1rem]');
expect(emptyState.className).toContain('bg-white/74');
expect(emptyState.className).toContain('py-5');
expect(emptyState.className).toContain('text-[var(--platform-text-soft)]');
});
test('allows explicit tone override', () => {
render(
<PlatformEmptyState surface="subpanel" size="inline" tone="base">
</PlatformEmptyState>,
);
const emptyState = screen.getByText('暂无属性');
expect(emptyState.className).toContain('text-[var(--platform-text-base)]');
});
test('supports dark editor dashed empty state', () => {
render(
<PlatformEmptyState surface="editorDark" size="compact" tone="soft">
</PlatformEmptyState>,
);
const emptyState = screen.getByText('还没有配置角色技能。');
expect(emptyState.className).toContain('border-dashed');
expect(emptyState.className).toContain('border-white/12');
expect(emptyState.className).toContain('bg-black/20');
expect(emptyState.className).toContain('text-[var(--platform-text-soft)]');
});

View File

@@ -0,0 +1,75 @@
import type { HTMLAttributes, ReactNode } from 'react';
type PlatformEmptyStateSurface = 'soft' | 'dashed' | 'subpanel' | 'editorDark';
type PlatformEmptyStateSize = 'compact' | 'panel' | 'inline';
type PlatformEmptyStateTone = 'base' | 'soft';
type PlatformEmptyStateProps = Omit<
HTMLAttributes<HTMLDivElement>,
'children'
> & {
children: ReactNode;
surface?: PlatformEmptyStateSurface;
size?: PlatformEmptyStateSize;
tone?: PlatformEmptyStateTone;
};
const PLATFORM_EMPTY_STATE_SURFACE_CLASS: Record<
PlatformEmptyStateSurface,
string
> = {
soft: 'platform-surface platform-surface--soft rounded-[1.35rem]',
dashed:
'rounded-[1.35rem] border border-dashed border-[var(--platform-subpanel-border)] bg-white/52',
subpanel:
'rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/74',
editorDark: 'rounded-2xl border border-dashed border-white/12 bg-black/20',
};
const PLATFORM_EMPTY_STATE_SIZE_CLASS: Record<PlatformEmptyStateSize, string> =
{
compact: 'px-4 py-3 text-sm leading-6',
panel:
'flex min-h-[14rem] items-center justify-center px-6 text-center text-sm',
inline: 'px-4 py-5 text-center text-sm font-semibold',
};
const PLATFORM_EMPTY_STATE_TONE_CLASS: Record<PlatformEmptyStateTone, string> =
{
base: 'text-[var(--platform-text-base)]',
soft: 'text-[var(--platform-text-soft)]',
};
/**
* 平台通用空态和轻量加载态。
* 收口平台列表、作品架和素材选择弹窗中重复的空面板外观。
*/
export function PlatformEmptyState({
children,
surface = 'soft',
size = 'compact',
tone,
className,
...divProps
}: PlatformEmptyStateProps) {
const resolvedTone =
tone ?? (surface === 'subpanel' || size === 'inline' ? 'soft' : 'base');
return (
<div
{...divProps}
className={[
'min-w-0',
'platform-empty-state',
PLATFORM_EMPTY_STATE_SURFACE_CLASS[surface],
PLATFORM_EMPTY_STATE_SIZE_CLASS[size],
PLATFORM_EMPTY_STATE_TONE_CLASS[resolvedTone],
className,
]
.filter(Boolean)
.join(' ')}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,68 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { expect, test } from 'vitest';
import { PlatformFieldLabel } from './PlatformFieldLabel';
test('renders compact field label by default', () => {
render(<PlatformFieldLabel></PlatformFieldLabel>);
const label = screen.getByText('作品名称');
expect(label.className).toContain('text-xs');
expect(label.className).toContain('text-[var(--platform-text-soft)]');
});
test('renders section label with tracking', () => {
render(<PlatformFieldLabel variant="section"></PlatformFieldLabel>);
const label = screen.getByText('作品信息');
expect(label.className).toContain('tracking-[0.18em]');
expect(label.className).toContain('font-bold');
});
test('renders form label and keeps local classes', () => {
render(
<PlatformFieldLabel variant="form" className="mt-1">
</PlatformFieldLabel>,
);
const label = screen.getByText('一句话创作');
expect(label.className).toContain('mb-2');
expect(label.className).toContain('font-black');
expect(label.className).toContain('mt-1');
});
test('renders inline form label without block spacing', () => {
render(
<PlatformFieldLabel variant="inlineForm" className="shrink-0">
</PlatformFieldLabel>,
);
const label = screen.getByText('关卡数');
expect(label.className).toContain('inline-flex');
expect(label.className).toContain('text-sm');
expect(label.className).toContain('font-bold');
expect(label.className).toContain('shrink-0');
expect(label.className).not.toContain('mb-2');
});
test('renders pill and accent pill labels', () => {
render(
<>
<PlatformFieldLabel variant="pill"></PlatformFieldLabel>
<PlatformFieldLabel variant="accentPill">
/
</PlatformFieldLabel>
</>,
);
expect(screen.getByText('作品标题').className).toContain('rounded-full');
expect(screen.getByText('主题/场景描述').className).toContain('bg-rose-50');
});

View File

@@ -0,0 +1,47 @@
import type { ReactNode } from 'react';
type PlatformFieldLabelVariant =
| 'field'
| 'section'
| 'form'
| 'inlineForm'
| 'pill'
| 'accentPill';
type PlatformFieldLabelProps = {
children: ReactNode;
variant?: PlatformFieldLabelVariant;
className?: string;
};
const PLATFORM_FIELD_LABEL_CLASS: Record<PlatformFieldLabelVariant, string> = {
field: 'text-xs font-bold text-[var(--platform-text-soft)]',
section:
'text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]',
form: 'mb-2 block text-sm font-black text-[var(--platform-text-strong)]',
inlineForm:
'inline-flex items-center text-sm font-bold text-[var(--platform-text-base)]',
pill: 'mb-2 inline-flex rounded-full px-2 py-0.5 text-sm font-black text-[var(--platform-text-strong)]',
accentPill:
'mb-2 inline-flex rounded-full border border-rose-200/70 bg-rose-50/88 px-2.5 py-1 text-sm font-black text-rose-700 shadow-sm',
};
/**
* 平台字段标签。
* 统一承接结果页和创作工作台内重复出现的字段标题视觉。
*/
export function PlatformFieldLabel({
children,
variant = 'field',
className,
}: PlatformFieldLabelProps) {
return (
<span
className={[PLATFORM_FIELD_LABEL_CLASS[variant], className]
.filter(Boolean)
.join(' ')}
>
{children}
</span>
);
}

View File

@@ -0,0 +1,72 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import { PlatformFilterToolbar } from './PlatformFilterToolbar';
const TAB_ITEMS = [
{ id: 'all', label: '全部' },
{ id: 'story', label: '剧情' },
] as const;
test('renders mobile platform filter toolbar with divider and prefixed sort label', () => {
const onOpenFilter = vi.fn();
const onTabChange = vi.fn();
const onToggleSort = vi.fn();
const { container } = render(
<PlatformFilterToolbar
filterLabel="筛选"
filterCount={12}
tabItems={TAB_ITEMS}
activeTabId="all"
sortLabel="最热"
layout="mobile"
onOpenFilter={onOpenFilter}
onTabChange={onTabChange}
onToggleSort={onToggleSort}
/>,
);
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
expect(container.querySelector('.platform-category-filter-divider')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '剧情' }));
fireEvent.click(screen.getByRole('button', { name: //u }));
expect(onTabChange).toHaveBeenCalledWith('story');
expect(onToggleSort).toHaveBeenCalledTimes(1);
});
test('renders desktop platform filter toolbar with inline sort button', () => {
const onOpenFilter = vi.fn();
const { container } = render(
<PlatformFilterToolbar
filterLabel="剧情"
filterCount={3}
tabItems={TAB_ITEMS}
activeTabId="story"
sortLabel="最新"
layout="desktop"
onOpenFilter={onOpenFilter}
onTabChange={vi.fn()}
onToggleSort={vi.fn()}
/>,
);
const filterButton = container.querySelector(
'.platform-category-filter-button',
) as HTMLButtonElement | null;
const sortButton = screen.getByRole('button', { name: //u });
expect(filterButton).toBeTruthy();
expect(filterButton?.textContent).toContain('剧情');
expect(sortButton).toBeTruthy();
expect(sortButton.className).toContain('shrink-0');
expect(container.querySelector('.platform-category-filter-divider')).toBeNull();
fireEvent.click(filterButton!);
expect(onOpenFilter).toHaveBeenCalledTimes(1);
});

View File

@@ -0,0 +1,108 @@
import { ChevronDown, SlidersHorizontal } from 'lucide-react';
import { PlatformSegmentedTabs } from './PlatformSegmentedTabs';
export interface PlatformFilterToolbarTabItem {
id: string;
label: string;
}
export interface PlatformFilterToolbarProps {
filterLabel: string;
filterCount: number;
tabItems: readonly PlatformFilterToolbarTabItem[];
activeTabId: string;
sortLabel: string;
layout: 'mobile' | 'desktop';
onOpenFilter: () => void;
onTabChange: (id: string) => void;
onToggleSort: () => void;
}
function buildToolbarTabItemClassName(active: boolean) {
return [
'platform-category-chip shrink-0 !min-h-[2.35rem] !rounded-none !border-0 !bg-transparent !px-0 !shadow-none hover:!bg-transparent',
active ? 'platform-category-chip--active' : null,
]
.filter(Boolean)
.join(' ');
}
export function PlatformFilterToolbar({
filterLabel,
filterCount,
tabItems,
activeTabId,
sortLabel,
layout,
onOpenFilter,
onTabChange,
onToggleSort,
}: PlatformFilterToolbarProps) {
const isMobileLayout = layout === 'mobile';
return (
<>
<div
className={
isMobileLayout
? 'platform-category-filter-row'
: 'mb-4 flex min-w-0 items-center gap-2'
}
>
<button
type="button"
onClick={onOpenFilter}
aria-haspopup="dialog"
className="platform-category-filter-button"
>
<SlidersHorizontal className="h-4 w-4" />
<span>{filterLabel}</span>
<span className="platform-category-filter-button__count">
{filterCount}
</span>
</button>
{isMobileLayout ? (
<span className="platform-category-filter-divider" />
) : null}
<PlatformSegmentedTabs
items={tabItems}
activeId={activeTabId}
onChange={onTabChange}
layout="scroll"
gap="md"
frame="bare"
surface="transparent"
size="sm"
tone="neutral"
className={
isMobileLayout
? 'platform-category-chip-scroll min-w-0 flex-1'
: 'min-w-0 flex-1 pb-1'
}
itemClassName={(_, active) => buildToolbarTabItemClassName(active)}
/>
{!isMobileLayout ? (
<button
type="button"
onClick={onToggleSort}
className="platform-category-sort-button shrink-0"
>
<span>{sortLabel}</span>
<ChevronDown className="h-3.5 w-3.5" />
</button>
) : null}
</div>
{isMobileLayout ? (
<button
type="button"
onClick={onToggleSort}
className="platform-category-sort-button"
>
<span>{sortLabel}</span>
<ChevronDown className="h-3.5 w-3.5" />
</button>
) : null}
</>
);
}

View File

@@ -0,0 +1,126 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { Play } from 'lucide-react';
import { expect, test } from 'vitest';
import { PlatformIconBadge } from './PlatformIconBadge';
test('renders neutral circular icon badge by default', () => {
render(<PlatformIconBadge icon={<Play className="h-4 w-4" />} />);
const badge = document.querySelector('[aria-hidden="true"]');
expect(badge?.className).toContain('platform-icon-badge');
expect(badge?.className).toContain('h-9');
expect(badge?.className).toContain('rounded-full');
expect(badge?.className).toContain('bg-[var(--platform-neutral-bg)]');
});
test('supports rounded medium icon badge with label', () => {
render(
<PlatformIconBadge
icon={<Play className="h-4 w-4" />}
label="继续"
size="md"
shape="rounded"
className="custom-badge"
/>,
);
const badge = screen.getByLabelText('继续');
expect(badge.className).toContain('h-11');
expect(badge.className).toContain('rounded-[0.85rem]');
expect(badge.className).toContain('custom-badge');
});
test('supports extra small soft icon badge', () => {
render(
<PlatformIconBadge
icon={<Play className="h-3.5 w-3.5" />}
size="xs"
tone="soft"
/>,
);
const badge = document.querySelector('[aria-hidden="true"]');
expect(badge?.className).toContain('h-7');
expect(badge?.className).toContain('bg-white/82');
expect(badge?.className).toContain('shadow-sm');
});
test('supports larger creative icon badge tones', () => {
const { rerender } = render(
<PlatformIconBadge
icon={<Play className="h-4 w-4" />}
size="base"
tone="softBright"
/>,
);
let badge = document.querySelector('[aria-hidden="true"]');
expect(badge?.className).toContain('h-10');
expect(badge?.className).toContain('bg-white/84');
rerender(
<PlatformIconBadge
icon={<Play className="h-5 w-5" />}
size="lg"
tone="hero"
/>,
);
badge = document.querySelector('[aria-hidden="true"]');
expect(badge?.className).toContain('h-12');
expect(badge?.className).toContain('bg-white/18');
rerender(
<PlatformIconBadge
icon={<Play className="h-3.5 w-3.5" />}
size="xs"
tone="heroMuted"
/>,
);
badge = document.querySelector('[aria-hidden="true"]');
expect(badge?.className).toContain('h-7');
expect(badge?.className).toContain('text-white/72');
rerender(
<PlatformIconBadge
icon={<Play className="h-6 w-6" />}
size="xl"
tone="success"
/>,
);
badge = document.querySelector('[aria-hidden="true"]');
expect(badge?.className).toContain('h-14');
expect(badge?.className).toContain('bg-[var(--platform-success-bg)]');
rerender(
<PlatformIconBadge icon={<Play className="h-4 w-4" />} tone="danger" />,
);
badge = document.querySelector('[aria-hidden="true"]');
expect(badge?.className).toContain('bg-[var(--platform-button-danger-fill)]');
expect(badge?.className).toContain(
'text-[var(--platform-button-danger-text)]',
);
});
test('supports extra large dark amber icon badge', () => {
render(
<PlatformIconBadge
icon={<Play className="h-8 w-8" />}
size="xxl"
shape="xl"
tone="darkAmber"
/>,
);
const badge = document.querySelector('[aria-hidden="true"]');
expect(badge?.className).toContain('h-20');
expect(badge?.className).toContain('rounded-2xl');
expect(badge?.className).toContain('border-amber-400/30');
expect(badge?.className).toContain('bg-amber-500/15');
});

View File

@@ -0,0 +1,85 @@
import type { ReactNode } from 'react';
type PlatformIconBadgeSize = 'xs' | 'sm' | 'base' | 'md' | 'lg' | 'xl' | 'xxl';
type PlatformIconBadgeShape = 'circle' | 'rounded' | 'xl';
type PlatformIconBadgeTone =
| 'neutral'
| 'soft'
| 'softBright'
| 'hero'
| 'heroMuted'
| 'darkAmber'
| 'success'
| 'danger';
type PlatformIconBadgeProps = {
icon: ReactNode;
label?: string;
size?: PlatformIconBadgeSize;
shape?: PlatformIconBadgeShape;
tone?: PlatformIconBadgeTone;
className?: string;
};
const PLATFORM_ICON_BADGE_SIZE_CLASS: Record<PlatformIconBadgeSize, string> = {
xs: 'h-7 w-7',
sm: 'h-9 w-9',
base: 'h-10 w-10',
md: 'h-11 w-11',
lg: 'h-12 w-12',
xl: 'h-14 w-14',
xxl: 'h-20 w-20',
};
const PLATFORM_ICON_BADGE_SHAPE_CLASS: Record<PlatformIconBadgeShape, string> =
{
circle: 'rounded-full',
rounded: 'rounded-[0.85rem]',
xl: 'rounded-2xl',
};
const PLATFORM_ICON_BADGE_TONE_CLASS: Record<PlatformIconBadgeTone, string> = {
neutral:
'bg-[var(--platform-neutral-bg)] text-[var(--platform-neutral-text)]',
soft: 'bg-white/82 text-[var(--platform-text-strong)] shadow-sm',
softBright: 'bg-white/84 text-[var(--platform-text-strong)] shadow-sm',
hero: 'bg-white/18 text-white',
heroMuted: 'bg-white/18 text-white/72',
darkAmber: 'border border-amber-400/30 bg-amber-500/15 text-amber-50',
success:
'bg-[var(--platform-success-bg)] text-[var(--platform-success-text)]',
danger:
'bg-[var(--platform-button-danger-fill)] text-[var(--platform-button-danger-text)]',
};
/**
* 平台中性图标徽章。
* 统一承接弹窗标题、列表项和小卡片里的非交互图标槽。
*/
export function PlatformIconBadge({
icon,
label,
size = 'sm',
shape = 'circle',
tone = 'neutral',
className,
}: PlatformIconBadgeProps) {
return (
<span
aria-label={label}
aria-hidden={label ? undefined : true}
className={[
'platform-icon-badge',
'grid shrink-0 place-items-center',
PLATFORM_ICON_BADGE_SIZE_CLASS[size],
PLATFORM_ICON_BADGE_SHAPE_CLASS[shape],
PLATFORM_ICON_BADGE_TONE_CLASS[tone],
className,
]
.filter(Boolean)
.join(' ')}
>
{icon}
</span>
);
}

View File

@@ -0,0 +1,167 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { expect, test } from 'vitest';
import { PlatformIconButton } from './PlatformIconButton';
test('renders platform icon button with accessible label and icon chrome', () => {
render(
<PlatformIconButton
label="关闭"
icon={<span aria-hidden="true">×</span>}
/>,
);
const button = screen.getByRole('button', { name: '关闭' });
expect(button.className).toContain('platform-icon-button');
expect(button.getAttribute('type')).toBe('button');
expect(button.textContent).toBe('×');
});
test('keeps local class names and explicit title', () => {
render(
<PlatformIconButton
label="发送"
title="发送"
icon={<span aria-hidden="true"></span>}
className="h-11 w-11"
/>,
);
const button = screen.getByRole('button', { name: '发送' });
expect(button.className).toContain('h-11');
expect(button.getAttribute('title')).toBe('发送');
});
test('supports floating surface icon action chrome', () => {
render(
<PlatformIconButton
label="更换图片"
title="更换图片"
variant="surfaceFloating"
icon={<span aria-hidden="true">+</span>}
className="h-10 w-10"
/>,
);
const button = screen.getByRole('button', { name: '更换图片' });
expect(button.className).toContain('bg-white/94');
expect(button.className).toContain('backdrop-blur');
expect(button.className).toContain('h-10');
expect(button.className).not.toContain('platform-icon-button');
});
test('supports visible short label on floating surface actions', () => {
render(
<PlatformIconButton
label="选择历史图片"
title="选择历史图片"
variant="surfaceFloating"
icon={<span aria-hidden="true"></span>}
className="gap-1.5 px-3"
>
<span></span>
</PlatformIconButton>,
);
const button = screen.getByRole('button', { name: '选择历史图片' });
expect(button.textContent).toContain('历史');
expect(button.className).toContain('bg-white/94');
expect(button.className).toContain('gap-1.5');
expect(button.className).toContain('px-3');
});
test('supports dark mini icon action chrome', () => {
render(
<PlatformIconButton
label="字段说明"
variant="darkMini"
icon={<span aria-hidden="true">?</span>}
className="h-4 w-4 text-[10px]"
/>,
);
const button = screen.getByRole('button', { name: '字段说明' });
expect(button.className).toContain('bg-black/55');
expect(button.className).toContain('border-white/16');
expect(button.className).toContain('hover:bg-black/70');
expect(button.className).toContain('h-4');
expect(button.textContent).toBe('?');
});
test('supports label child chrome for icon upload controls', () => {
const { container } = render(
<>
<PlatformIconButton
asChild="label"
htmlFor="reference-image"
label="上传参考图"
title="上传参考图"
icon={<span aria-hidden="true" />}
className="h-9 w-9 cursor-pointer"
/>
<input id="reference-image" type="file" />
</>,
);
const input = screen.getByLabelText('上传参考图');
const label = container.querySelector('label[for="reference-image"]');
expect(input.getAttribute('type')).toBe('file');
expect(label?.tagName).toBe('LABEL');
expect(label?.getAttribute('for')).toBe('reference-image');
expect(label?.className).toContain('platform-icon-button');
expect(label?.className).toContain('cursor-pointer');
expect(label?.getAttribute('title')).toBe('上传参考图');
});
test('supports floating surface label upload controls', () => {
const { container } = render(
<PlatformIconButton
asChild="label"
label="上传参考图"
variant="surfaceFloating"
title="上传参考图"
icon={
<>
<span aria-hidden="true">+</span>
<input type="file" />
</>
}
className="h-8 w-8 cursor-pointer"
/>,
);
const label = container.querySelector('label');
const input = label?.querySelector('input');
expect(label?.textContent).toContain('上传参考图');
expect(input?.getAttribute('type')).toBe('file');
expect(label?.className).toContain('bg-white/94');
expect(label?.className).toContain('cursor-pointer');
});
test('keeps nested file input associated with the label name', () => {
render(
<PlatformIconButton
asChild="label"
label="上传参考图"
icon={
<>
<span aria-hidden="true" />
<input type="file" />
</>
}
/>,
);
const input = screen.getByLabelText('上传参考图', { selector: 'input' });
expect(input.getAttribute('type')).toBe('file');
});

View File

@@ -0,0 +1,89 @@
import type {
ButtonHTMLAttributes,
LabelHTMLAttributes,
ReactNode,
} from 'react';
type PlatformIconButtonBaseProps = {
label: string;
icon: ReactNode;
children?: ReactNode;
variant?: 'platformIcon' | 'surfaceFloating' | 'darkMini';
};
type PlatformIconButtonButtonProps = Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
'aria-label' | 'children'
> &
PlatformIconButtonBaseProps & {
asChild?: false;
};
type PlatformIconButtonLabelProps = Omit<
LabelHTMLAttributes<HTMLLabelElement>,
'aria-label' | 'children'
> &
PlatformIconButtonBaseProps & {
asChild: 'label';
};
type PlatformIconButtonProps =
| PlatformIconButtonButtonProps
| PlatformIconButtonLabelProps;
/**
* 平台通用图标动作按钮。
* 统一承接纯图标动作、图标上传 label 和带短标签的浮动图标动作。
*/
export function PlatformIconButton({
label,
icon,
children,
variant = 'platformIcon',
title,
className,
asChild,
...actionProps
}: PlatformIconButtonProps) {
const variantClassName = {
platformIcon: 'platform-icon-button',
surfaceFloating:
'inline-flex items-center justify-center rounded-full border border-white/80 bg-white/94 text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[var(--platform-accent)] disabled:cursor-not-allowed disabled:opacity-55',
darkMini:
'inline-flex items-center justify-center rounded-full border border-white/16 bg-black/55 text-white transition-colors hover:bg-black/70 disabled:cursor-not-allowed disabled:opacity-55',
}[variant];
const actionClassName = [variantClassName, className]
.filter(Boolean)
.join(' ');
if (asChild === 'label') {
return (
<label
{...(actionProps as LabelHTMLAttributes<HTMLLabelElement>)}
title={title}
className={actionClassName}
>
<span className="sr-only">{label}</span>
{icon}
{children}
</label>
);
}
const { type = 'button', ...buttonProps } =
actionProps as ButtonHTMLAttributes<HTMLButtonElement>;
return (
<button
{...buttonProps}
type={type}
aria-label={label}
title={title}
className={actionClassName}
>
{icon}
{children}
</button>
);
}

View File

@@ -0,0 +1,78 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { expect, test } from 'vitest';
import { PlatformInfoBlock } from './PlatformInfoBlock';
test('renders platform info block label and value chrome', () => {
render(<PlatformInfoBlock label="来源">稿</PlatformInfoBlock>);
const label = screen.getByText('来源');
const value = screen.getByText('拼图草稿');
const block = label.closest('div')?.parentElement;
expect(block?.className).toContain('bg-white/72');
expect(block?.className).toContain('rounded-[1rem]');
expect(value.className).toContain('font-semibold');
expect(value.className).toContain('leading-5');
});
test('supports multiline value layout and class overrides', () => {
render(
<PlatformInfoBlock
label="错误"
multiline
className="custom-block"
labelClassName="custom-label"
valueClassName="custom-value"
>
{'\n'}
</PlatformInfoBlock>,
);
const value = screen.getByText(//u);
const block = value.closest('div')?.parentElement;
expect(block?.className).toContain('custom-block');
expect(screen.getByText('错误').className).toContain('custom-label');
expect(value.className).toContain('whitespace-pre-wrap');
expect(value.className).toContain('leading-6');
expect(value.className).toContain('custom-value');
});
test('supports plain multiline value without a visible label', () => {
render(
<PlatformInfoBlock multiline className="rounded-[1.25rem]">
</PlatformInfoBlock>,
);
const value = screen.getByText('分享正文');
const block = value.closest('div')?.parentElement;
expect(block?.className).toContain('bg-white/72');
expect(block?.className).toContain('rounded-[1.25rem]');
expect(value.className.split(/\s+/u)).not.toContain('mt-1');
expect(value.className).toContain('whitespace-pre-wrap');
});
test('supports compact row variant for dense preview metadata', () => {
render(
<PlatformInfoBlock label="场景" variant="compactRow">
</PlatformInfoBlock>,
);
const label = screen.getByText('场景');
const value = screen.getByText('霓虹公园擂台');
const block = label.closest('div')?.parentElement;
expect(block?.className).toContain('bg-white/74');
expect(block?.className).toContain('rounded-[0.85rem]');
expect(block?.className).toContain('sm:px-3');
expect(label.className).toContain('text-[var(--platform-text-muted)]');
expect(value.className).toContain('text-right');
expect(value.className).toContain('font-black');
expect(value.className.split(/\s+/u)).not.toContain('mt-1');
});

View File

@@ -0,0 +1,100 @@
import type { ReactNode } from 'react';
type PlatformInfoBlockVariant = 'default' | 'compactRow';
type PlatformInfoBlockProps = {
label?: ReactNode;
children: ReactNode;
variant?: PlatformInfoBlockVariant;
multiline?: boolean;
className?: string;
labelClassName?: string;
valueClassName?: string;
};
const PLATFORM_INFO_BLOCK_CLASS: Record<PlatformInfoBlockVariant, string> = {
default:
'rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-2',
compactRow:
'flex justify-between gap-2 rounded-[0.85rem] border-0 bg-white/74 px-2.5 py-1.5 sm:gap-3 sm:px-3 sm:py-2',
};
const PLATFORM_INFO_BLOCK_LABEL_CLASS: Record<
PlatformInfoBlockVariant,
string
> = {
default: 'text-xs font-bold text-[var(--platform-text-soft)]',
compactRow: 'text-xs font-bold text-[var(--platform-text-muted)] sm:text-sm',
};
function getValueClassName({
hasLabel,
multiline,
variant,
valueClassName,
}: {
hasLabel: boolean;
multiline: boolean;
variant: PlatformInfoBlockVariant;
valueClassName?: string;
}) {
if (variant === 'compactRow') {
return [
'break-words text-right text-xs font-black leading-5 text-[var(--platform-text-strong)] sm:text-sm',
valueClassName,
]
.filter(Boolean)
.join(' ');
}
return [
'break-words text-sm text-[var(--platform-text-strong)]',
hasLabel ? 'mt-1' : null,
multiline ? 'whitespace-pre-wrap leading-6' : 'font-semibold leading-5',
valueClassName,
]
.filter(Boolean)
.join(' ');
}
/**
* 平台信息展示块。
* 统一承接弹窗和详情页中“短标签 + 白底内容”的只读信息 chrome。
*/
export function PlatformInfoBlock({
label,
children,
variant = 'default',
multiline = false,
className,
labelClassName,
valueClassName,
}: PlatformInfoBlockProps) {
return (
<div
className={[PLATFORM_INFO_BLOCK_CLASS[variant], className]
.filter(Boolean)
.join(' ')}
>
{label ? (
<div
className={[PLATFORM_INFO_BLOCK_LABEL_CLASS[variant], labelClassName]
.filter(Boolean)
.join(' ')}
>
{label}
</div>
) : null}
<div
className={getValueClassName({
hasLabel: Boolean(label),
multiline,
variant,
valueClassName,
})}
>
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,295 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { createRef, type ImgHTMLAttributes } from 'react';
import { expect, test, vi } from 'vitest';
import { PlatformMediaFrame } from './PlatformMediaFrame';
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: ({
src,
fallbackSrc,
alt,
className,
refreshKey,
...imageProps
}: {
src?: string;
fallbackSrc?: string;
alt: string;
className?: string;
refreshKey?: string | number | null;
} & ImgHTMLAttributes<HTMLImageElement>) => (
<img
{...imageProps}
src={src ?? fallbackSrc}
data-fallback-src={fallbackSrc}
data-refresh-key={refreshKey ?? undefined}
alt={alt}
className={className}
/>
),
}));
test('renders warm media frame with image and landscape aspect', () => {
render(
<PlatformMediaFrame
src="/scene.png"
alt="潮灯居"
fallbackLabel="场景"
aspect="landscape"
/>,
);
const image = screen.getByRole('img', { name: '潮灯居' });
const frame = image.closest('div');
expect(frame?.className).toContain('platform-media-frame');
expect(frame?.className).toContain('aspect-[16/9]');
expect(frame?.className).toContain(
'border-[var(--platform-subpanel-border)]',
);
expect(frame?.className).toContain('radial-gradient');
expect(image.className).toContain('object-cover');
});
test('renders fallback label when no image source is available', () => {
render(
<PlatformMediaFrame
alt="角色"
fallbackLabel="角色"
aspect="square"
surface="plain"
/>,
);
const fallback = screen.getByText('角色');
const frame = fallback.closest('div.relative');
expect(frame?.className).toContain('aspect-square');
expect(frame?.className).toContain('bg-[var(--platform-subpanel-fill)]');
expect(fallback.className).toContain('tracking-[0.18em]');
});
test('supports custom fallback content', () => {
render(
<PlatformMediaFrame
alt="封面图"
fallbackLabel="封面图占位"
fallbackContent={<span data-testid="fallback-icon"></span>}
aspect="standard"
surface="plain"
fallbackShellClassName="bg-rainbow"
fallbackClassName="tracking-normal"
/>,
);
const fallbackIcon = screen.getByTestId('fallback-icon');
const fallback = fallbackIcon.closest('div');
const frame = fallbackIcon.closest('div.relative');
expect(fallbackIcon.textContent).toBe('图标');
expect(screen.queryByText('封面图占位')).toBeNull();
expect(fallback?.className).toContain('bg-rainbow');
expect(fallback?.className).toContain('tracking-normal');
expect(frame?.className).toContain('aspect-[4/3]');
});
test('supports soft media frame surface', () => {
render(
<PlatformMediaFrame
src="/template-preview.webp"
alt="创意模板"
fallbackLabel="创意模板"
aspect="landscape"
surface="soft"
/>,
);
const image = screen.getByRole('img', { name: '创意模板' });
const frame = image.closest('div');
expect(frame?.className).toContain('aspect-[16/9]');
expect(frame?.className).toContain(
'border-[var(--platform-subpanel-border)]',
);
expect(frame?.className).toContain('bg-white/68');
});
test('supports bright media frame surface', () => {
render(
<PlatformMediaFrame
src="/match3d/item.png"
alt="物品素材"
fallbackLabel="物品素材"
aspect="square"
surface="bright"
/>,
);
const image = screen.getByRole('img', { name: '物品素材' });
const frame = image.closest('div');
expect(frame?.className).toContain('aspect-square');
expect(frame?.className).toContain(
'border-[var(--platform-subpanel-border)]',
);
expect(frame?.className).toContain('bg-white/82');
});
test('passes div attributes to the media frame container', () => {
render(
<PlatformMediaFrame
src="/match3d/item.png"
alt="物品素材"
fallbackLabel="物品素材"
surface="bright"
data-testid="match3d-preview-frame"
aria-label="物品素材预览"
/>,
);
const frame = screen.getByTestId('match3d-preview-frame');
expect(frame.getAttribute('aria-label')).toBe('物品素材预览');
expect(frame.className).toContain('bg-white/82');
});
test('supports none surface for nested interactive shells', () => {
render(
<PlatformMediaFrame
src="/match3d/thumb.png"
alt="缩略图"
fallbackLabel="缩略图"
aspect="square"
surface="none"
className="rounded-[0.65rem]"
/>,
);
const image = screen.getByRole('img', { name: '缩略图' });
const frame = image.closest('div');
expect(frame?.className).toContain('aspect-square');
expect(frame?.className).toContain('rounded-[0.65rem]');
expect(frame?.className).not.toContain('border-[');
expect(frame?.className).not.toContain('bg-white');
});
test('supports editor dark surface, fallback source and overlay', () => {
render(
<PlatformMediaFrame
src=""
fallbackSrc="/fallback.png"
alt="第1幕"
fallbackLabel="第1幕"
surface="editorDark"
loading="lazy"
overlayInteractive
previewOverlay={<span></span>}
/>,
);
const image = screen.getByRole('img', { name: '第1幕' });
const frame = image.closest('div');
const overlay = screen.getByText('覆盖层').parentElement;
expect(image.getAttribute('src')).toBe('/fallback.png');
expect(frame?.className).toContain('border-white/10');
expect(frame?.className).toContain('rgba(19,24,39,0.95)');
expect(overlay?.className).toContain('pointer-events-auto');
});
test('supports standard aspect, bare surface and refresh key', () => {
render(
<PlatformMediaFrame
src="/puzzle/level.png"
alt="雨夜猫街"
fallbackLabel="暂无正式图"
aspect="standard"
surface="bare"
refreshKey="session-1:level-1"
/>,
);
const image = screen.getByRole('img', { name: '雨夜猫街' });
const frame = image.closest('div');
expect(frame?.className).toContain('aspect-[4/3]');
expect(frame?.className).toContain('bg-[var(--platform-subpanel-fill)]');
expect(frame?.className).not.toContain('border');
expect(image.getAttribute('data-refresh-key')).toBe('session-1:level-1');
});
test('supports portrait aspect for vertical preview assets', () => {
render(
<PlatformMediaFrame
src="/puzzle-clear/board-background.png"
alt="场地底图"
fallbackLabel="底图"
aspect="portrait"
surface="bare"
className="rounded-none bg-white/80"
/>,
);
const image = screen.getByRole('img', { name: '场地底图' });
const frame = image.closest('div');
expect(frame?.className).toContain('aspect-[9/16]');
expect(frame?.className).toContain('bg-white/80');
expect(frame?.className).toContain('rounded-none');
});
test('supports wide aspect for broad preview assets', () => {
render(
<PlatformMediaFrame
src="/big-fish/asset-preview.png"
alt="大鱼素材候选"
fallbackLabel="AI 资产候选预览"
aspect="wide"
surface="plain"
/>,
);
const image = screen.getByRole('img', { name: '大鱼素材候选' });
const frame = image.closest('div');
expect(frame?.className).toContain('aspect-[9/5]');
expect(frame?.className).toContain(
'border-[var(--platform-subpanel-border)]',
);
});
test('supports auto aspect, image props and container refs', () => {
const frameRef = createRef<HTMLDivElement>();
render(
<PlatformMediaFrame
ref={frameRef}
src="/cover-upload.png"
alt="封面裁剪预览"
fallbackLabel="封面"
aspect="auto"
surface="none"
imageClassName="absolute max-w-none object-fill"
imageProps={{
draggable: false,
style: { left: '-10%', width: '120%' },
}}
data-testid="cover-crop-frame"
/>,
);
const frame = screen.getByTestId('cover-crop-frame');
const image = screen.getByRole('img', { name: '封面裁剪预览' });
expect(frameRef.current).toBe(frame);
expect(frame.className).toContain('platform-media-frame');
expect(frame.className).not.toContain('aspect-[');
expect(frame.className).not.toContain('aspect-square');
expect(image.getAttribute('draggable')).toBe('false');
expect(image.getAttribute('style')).toContain('left: -10%');
expect(image.className).toContain('absolute max-w-none object-fill');
});

View File

@@ -0,0 +1,163 @@
import {
forwardRef,
type HTMLAttributes,
type ImgHTMLAttributes,
type ReactNode,
} from 'react';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
type PlatformMediaFrameAspect =
| 'auto'
| 'square'
| 'standard'
| 'landscape'
| 'wide'
| 'portrait'
| 'video';
type PlatformMediaFrameSurface =
| 'warm'
| 'editorDark'
| 'plain'
| 'soft'
| 'bright'
| 'none'
| 'bare';
type PlatformMediaFrameProps = Omit<
HTMLAttributes<HTMLDivElement>,
'children' | 'className'
> & {
src?: string | null;
fallbackSrc?: string | null;
alt: string;
fallbackLabel: string;
aspect?: PlatformMediaFrameAspect;
surface?: PlatformMediaFrameSurface;
loading?: 'eager' | 'lazy';
refreshKey?: string | number | null;
imageClassName?: string;
imageProps?: Omit<
ImgHTMLAttributes<HTMLImageElement>,
'alt' | 'className' | 'loading' | 'src'
>;
className?: string;
fallbackClassName?: string;
fallbackShellClassName?: string;
fallbackContent?: ReactNode;
children?: ReactNode;
previewOverlay?: ReactNode;
overlayInteractive?: boolean;
};
const PLATFORM_MEDIA_FRAME_ASPECT_CLASS: Record<
PlatformMediaFrameAspect,
string
> = {
auto: '',
square: 'aspect-square',
standard: 'aspect-[4/3]',
landscape: 'aspect-[16/9]',
wide: 'aspect-[9/5]',
portrait: 'aspect-[9/16]',
video: 'aspect-video',
};
const PLATFORM_MEDIA_FRAME_SURFACE_CLASS: Record<
PlatformMediaFrameSurface,
string
> = {
warm: 'border border-[var(--platform-subpanel-border)] bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.22),transparent_42%),linear-gradient(180deg,rgba(204,117,76,0.9),rgba(223,127,64,0.82))]',
editorDark:
'border border-white/10 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))]',
plain:
'border border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)]',
soft: 'border border-[var(--platform-subpanel-border)] bg-white/68',
bright: 'border border-[var(--platform-subpanel-border)] bg-white/82',
none: '',
bare: 'bg-[var(--platform-subpanel-fill)]',
};
/**
* 平台媒体预览框。
* 统一承接图片预览、固定比例、fallback 文案和可选 overlay。
*/
export const PlatformMediaFrame = forwardRef<
HTMLDivElement,
PlatformMediaFrameProps
>(function PlatformMediaFrame(
{
src,
fallbackSrc,
alt,
fallbackLabel,
aspect = 'square',
surface = 'warm',
loading,
refreshKey,
imageClassName = 'h-full w-full object-cover',
imageProps,
className,
fallbackClassName,
fallbackShellClassName,
fallbackContent,
children,
previewOverlay,
overlayInteractive = false,
...containerProps
},
ref,
) {
const imageSrc = src?.trim() || fallbackSrc?.trim() || '';
const hasOverlay = Boolean(children) || Boolean(previewOverlay);
return (
<div
{...containerProps}
ref={ref}
className={[
'platform-media-frame relative overflow-hidden rounded-2xl',
PLATFORM_MEDIA_FRAME_SURFACE_CLASS[surface],
PLATFORM_MEDIA_FRAME_ASPECT_CLASS[aspect],
className,
]
.filter(Boolean)
.join(' ')}
>
{imageSrc ? (
<ResolvedAssetImage
{...imageProps}
src={src?.trim() || undefined}
fallbackSrc={fallbackSrc?.trim() || undefined}
alt={alt}
loading={loading}
refreshKey={refreshKey}
className={imageClassName}
/>
) : (
<div
className={[
'flex h-full w-full items-center justify-center px-4 text-center text-sm font-semibold tracking-[0.18em] text-zinc-400',
fallbackShellClassName,
fallbackClassName,
]
.filter(Boolean)
.join(' ')}
>
{fallbackContent ?? fallbackLabel}
</div>
)}
{hasOverlay ? (
<div
className={[
overlayInteractive ? 'pointer-events-auto' : 'pointer-events-none',
'absolute inset-0',
].join(' ')}
>
{previewOverlay}
{children}
</div>
) : null}
</div>
);
});

View File

@@ -0,0 +1,96 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import { PlatformMediaTileGrid } from './PlatformMediaTileGrid';
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: ({
src,
alt,
className,
refreshKey,
}: {
src?: string | null;
alt?: string;
className?: string;
refreshKey?: string | number | null;
}) =>
src ? (
<img
src={src}
alt={alt}
className={className}
data-refresh-key={refreshKey ?? undefined}
/>
) : null,
}));
test('renders soft square tile grid with media frames', () => {
const { container } = render(
<PlatformMediaTileGrid
columns="five"
aspect="square"
surface="soft"
tileSurface="slate"
imageClassName="h-full w-full object-contain"
items={[
{
id: 'tile-1',
src: '/tiles/tile-1.png',
alt: '地块 1',
refreshKey: 'asset-tile-1',
testId: 'tile-preview-1',
},
{
id: 'tile-2',
fallbackLabel: '地块',
fallbackContent: <span data-testid="fallback-dot" />,
},
]}
/>,
);
const grid = container.querySelector('.platform-media-tile-grid');
const image = screen.getByRole('img', { name: '地块 1' });
const fallbackDot = screen.getByTestId('fallback-dot');
const itemFrames = container.querySelectorAll(
'.platform-media-tile-grid__item',
);
expect(grid?.className).toContain('grid-cols-5');
expect(grid?.className).toContain('aspect-[1/1]');
expect(grid?.className).toContain('bg-white/78');
expect(image.className).toContain('object-contain');
expect(image.getAttribute('data-refresh-key')).toBe('asset-tile-1');
expect(image.closest('[data-testid="tile-preview-1"]')).toBe(
screen.getByTestId('tile-preview-1'),
);
expect(itemFrames).toHaveLength(2);
expect(itemFrames[0]?.className).toContain('bg-slate-50');
expect(itemFrames[0]?.className).not.toContain(
'bg-[var(--platform-subpanel-fill)]',
);
expect(fallbackDot.closest('.platform-media-tile-grid__item')).toBeTruthy();
});
test('renders six-column white tile grid by default', () => {
const { container } = render(
<PlatformMediaTileGrid
items={[
{ id: 'card-1', src: '/cards/card-1.png', fallbackLabel: '卡片' },
]}
/>,
);
const grid = container.querySelector('.platform-media-tile-grid');
const item = container.querySelector('.platform-media-tile-grid__item');
expect(grid?.className).toContain('grid-cols-6');
expect(grid?.className).toContain('gap-1.5');
expect(item?.className).toContain('bg-white');
expect(item?.className).toContain('shadow-sm');
expect(item?.className).toContain('rounded-[0.45rem]');
expect(item?.className).not.toContain('bg-[var(--platform-subpanel-fill)]');
});

View File

@@ -0,0 +1,126 @@
import type { ReactNode } from 'react';
import { PlatformMediaFrame } from './PlatformMediaFrame';
type PlatformMediaTileGridColumns = 'five' | 'six';
type PlatformMediaTileGridGap = 'xs' | 'sm';
type PlatformMediaTileGridAspect = 'auto' | 'square';
type PlatformMediaTileSurface = 'white' | 'slate' | 'bare';
type PlatformMediaTileGridSurface = 'none' | 'soft';
export type PlatformMediaTileGridItem = {
id: string;
src?: string | null;
alt?: string;
refreshKey?: string | number | null;
fallbackLabel?: string;
fallbackContent?: ReactNode;
testId?: string;
className?: string;
imageClassName?: string;
fallbackClassName?: string;
};
type PlatformMediaTileGridProps = {
items: PlatformMediaTileGridItem[];
columns?: PlatformMediaTileGridColumns;
gap?: PlatformMediaTileGridGap;
aspect?: PlatformMediaTileGridAspect;
surface?: PlatformMediaTileGridSurface;
tileSurface?: PlatformMediaTileSurface;
fallbackLabel?: string;
imageClassName?: string;
fallbackClassName?: string;
className?: string;
tileClassName?: string;
};
const PLATFORM_MEDIA_TILE_GRID_COLUMNS_CLASS: Record<
PlatformMediaTileGridColumns,
string
> = {
five: 'grid-cols-5',
six: 'grid-cols-6',
};
const PLATFORM_MEDIA_TILE_GRID_GAP_CLASS: Record<
PlatformMediaTileGridGap,
string
> = {
sm: 'gap-1.5',
xs: 'gap-1',
};
const PLATFORM_MEDIA_TILE_GRID_SURFACE_CLASS: Record<
PlatformMediaTileGridSurface,
string
> = {
none: '',
soft: 'bg-white/78 p-2',
};
const PLATFORM_MEDIA_TILE_SURFACE_CLASS: Record<
PlatformMediaTileSurface,
string
> = {
bare: 'border border-white/80',
slate: 'border border-white/80 bg-slate-50',
white: 'border border-white/80 bg-white shadow-sm',
};
/**
* 平台媒体缩略格网格。
* 统一承接结果页里同尺寸素材 tile 的网格、圆角、边框和图片/fallback 框。
*/
export function PlatformMediaTileGrid({
items,
columns = 'six',
gap = 'sm',
aspect = 'auto',
surface = 'none',
tileSurface = 'white',
fallbackLabel = '素材',
imageClassName = 'h-full w-full object-cover',
fallbackClassName = 'tracking-normal text-[var(--platform-text-soft)]',
className,
tileClassName,
}: PlatformMediaTileGridProps) {
return (
<div
className={[
'platform-media-tile-grid grid',
PLATFORM_MEDIA_TILE_GRID_COLUMNS_CLASS[columns],
PLATFORM_MEDIA_TILE_GRID_GAP_CLASS[gap],
PLATFORM_MEDIA_TILE_GRID_SURFACE_CLASS[surface],
aspect === 'square' ? 'aspect-[1/1]' : '',
className,
]
.filter(Boolean)
.join(' ')}
>
{items.map((item) => (
<PlatformMediaFrame
key={item.id}
src={item.src}
alt={item.alt ?? ''}
refreshKey={item.refreshKey}
fallbackLabel={item.fallbackLabel ?? fallbackLabel}
fallbackContent={item.fallbackContent}
aspect="square"
surface="none"
data-testid={item.testId}
imageClassName={item.imageClassName ?? imageClassName}
fallbackClassName={item.fallbackClassName ?? fallbackClassName}
className={[
'platform-media-tile-grid__item min-h-0 rounded-[0.45rem]',
PLATFORM_MEDIA_TILE_SURFACE_CLASS[tileSurface],
tileClassName,
item.className,
]
.filter(Boolean)
.join(' ')}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,129 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test, vi } from 'vitest';
import { PlatformModalCloseButton } from './PlatformModalCloseButton';
test('renders profile modal close button with shared chrome', () => {
render(<PlatformModalCloseButton label="关闭账户充值" onClick={() => {}} />);
const button = screen.getByRole('button', { name: '关闭账户充值' });
expect(button.className).toContain('platform-modal-close');
expect(button.className).toContain('h-9');
expect(button.querySelector('svg')).toBeTruthy();
});
test('supports floating close button and custom icon', () => {
render(
<PlatformModalCloseButton
label="关闭泥点账单"
variant="floating"
icon={<span aria-hidden="true">×</span>}
/>,
);
const button = screen.getByRole('button', { name: '关闭泥点账单' });
expect(button.className).toContain('absolute');
expect(button.className).toContain('right-3');
expect(button.textContent).toBe('×');
});
test('supports compact profile icon button', () => {
render(
<PlatformModalCloseButton
label="关闭昵称修改"
variant="profileCompact"
icon="×"
/>,
);
const button = screen.getByRole('button', { name: '关闭昵称修改' });
expect(button.className).toContain('platform-profile-icon-button');
expect(button.className).toContain('h-8');
expect(button.textContent).toBe('×');
});
test('supports plain floating close button', () => {
render(
<PlatformModalCloseButton
label="关闭邀请好友"
variant="floatingPlain"
icon="×"
/>,
);
const button = screen.getByRole('button', { name: '关闭邀请好友' });
expect(button.className).toContain('absolute');
expect(button.className).toContain('top-2');
expect(button.className).not.toContain('shadow-sm');
});
test('supports platform icon close button', () => {
render(
<PlatformModalCloseButton label="关闭素材图片" variant="platformIcon" />,
);
const button = screen.getByRole('button', { name: '关闭素材图片' });
expect(button.className).toContain('platform-icon-button');
expect(button.className).toContain('disabled:opacity-45');
expect(button.querySelector('svg')).toBeTruthy();
});
test('supports pixel close button', () => {
render(<PlatformModalCloseButton label="关闭像素弹窗" variant="pixel" />);
const button = screen.getByRole('button', { name: '关闭像素弹窗' });
expect(button.className).toContain('bg-black/30');
expect(button.className).toContain('text-zinc-400');
expect(button.className).toContain('absolute');
expect(button.className).toContain('disabled:opacity-45');
expect(button.getAttribute('title')).toBe('关闭像素弹窗');
});
test('supports inline pixel placement and intercepted click behavior', async () => {
const user = userEvent.setup();
const onClick = vi.fn();
const onHeaderClick = vi.fn();
render(
<div onClick={onHeaderClick}>
<PlatformModalCloseButton
label="关闭像素标题栏"
variant="pixel"
placement="inline"
stopPropagation
onClick={onClick}
/>
</div>,
);
const button = screen.getByRole('button', { name: '关闭像素标题栏' });
await user.click(button);
expect(button.className).toContain('relative');
expect(button.className).toContain('shrink-0');
expect(onClick).toHaveBeenCalledTimes(1);
expect(onHeaderClick).not.toHaveBeenCalled();
});
test('supports editor dark close button', () => {
render(
<PlatformModalCloseButton label="关闭角色自定义" variant="editorDark" />,
);
const button = screen.getByRole('button', { name: '关闭角色自定义' });
expect(button.className).toContain(
'platform-modal-close-button--editor-dark',
);
expect(button.className).toContain('border-white/10');
expect(button.className).toContain('bg-white/5');
});

View File

@@ -0,0 +1,104 @@
import { X } from 'lucide-react';
import type {
ButtonHTMLAttributes,
MouseEvent as ReactMouseEvent,
ReactNode,
} from 'react';
type PlatformModalCloseButtonVariant =
| 'profile'
| 'profileCompact'
| 'floating'
| 'floatingPlain'
| 'platformIcon'
| 'pixel'
| 'editorDark';
type PlatformModalCloseButtonPlacement = 'absolute' | 'inline';
type PlatformModalCloseButtonProps = Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
'children'
> & {
label: string;
variant?: PlatformModalCloseButtonVariant;
icon?: ReactNode;
placement?: PlatformModalCloseButtonPlacement;
stopPropagation?: boolean;
};
const PLATFORM_MODAL_CLOSE_BUTTON_CLASS_BY_VARIANT: Record<
PlatformModalCloseButtonVariant,
string
> = {
profile:
'platform-modal-close flex h-9 w-9 items-center justify-center rounded-full',
profileCompact:
'platform-profile-icon-button flex h-8 w-8 items-center justify-center rounded-full',
floating:
'absolute right-3 top-3 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/80 text-[#ff4056] shadow-sm',
floatingPlain:
'absolute right-3 top-2 z-10 flex h-8 w-8 items-center justify-center rounded-full text-[#ff4056]',
platformIcon: 'platform-icon-button disabled:cursor-not-allowed disabled:opacity-45',
pixel:
'flex h-9 w-9 items-center justify-center rounded-full border border-white/10 bg-black/30 p-0 text-zinc-400 shadow-[0_8px_18px_rgba(0,0,0,0.28)] transition-colors hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-200/70 disabled:cursor-not-allowed disabled:opacity-45',
editorDark:
'platform-modal-close-button--editor-dark rounded-full border border-white/10 bg-white/5 p-2 text-zinc-300 transition hover:bg-white/10 hover:text-white',
};
const PLATFORM_MODAL_CLOSE_BUTTON_PIXEL_PLACEMENT_CLASS_BY_PLACEMENT: Record<
PlatformModalCloseButtonPlacement,
string
> = {
absolute: 'absolute right-4 top-3 sm:right-5 sm:top-4',
inline: 'relative shrink-0',
};
/**
* 平台弹窗关闭按钮。
* 收口个人中心和平台浮层里重复的关闭 aria、尺寸和视觉样式。
*/
export function PlatformModalCloseButton({
label,
variant = 'profile',
icon = <X className="h-4 w-4" />,
className,
type = 'button',
placement = 'absolute',
stopPropagation = false,
onClick,
title,
...buttonProps
}: PlatformModalCloseButtonProps) {
const handleClick = (event: ReactMouseEvent<HTMLButtonElement>) => {
if (stopPropagation) {
event.preventDefault();
event.stopPropagation();
}
onClick?.(event);
};
return (
<button
{...buttonProps}
type={type}
aria-label={label}
title={title ?? label}
onClick={handleClick}
className={[
PLATFORM_MODAL_CLOSE_BUTTON_CLASS_BY_VARIANT[variant],
variant === 'pixel'
? PLATFORM_MODAL_CLOSE_BUTTON_PIXEL_PLACEMENT_CLASS_BY_PLACEMENT[
placement
]
: null,
className,
]
.filter(Boolean)
.join(' ')}
>
{icon}
</button>
);
}

View File

@@ -0,0 +1,77 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, within } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import { PlatformMudPointConfirmDialog } from './PlatformMudPointConfirmDialog';
test('renders standard mud point confirmation copy and forwards confirm', () => {
const onClose = vi.fn();
const onConfirm = vi.fn();
render(
<PlatformMudPointConfirmDialog
open
points={8}
onClose={onClose}
onConfirm={onConfirm}
portal={false}
/>,
);
const dialog = screen.getByRole('dialog', { name: '确认消耗泥点' });
expect(within(dialog).getByText('消耗 8 泥点')).toBeTruthy();
fireEvent.click(within(dialog).getByRole('button', { name: '确定' }));
expect(onConfirm).toHaveBeenCalledTimes(1);
});
test('supports extra detail copy and close button override', () => {
const onClose = vi.fn();
render(
<PlatformMudPointConfirmDialog
open
points={7}
title="保存正式素材"
description="角色形象"
onClose={onClose}
onConfirm={vi.fn()}
showCloseButton={false}
portal={false}
>
</PlatformMudPointConfirmDialog>,
);
const dialog = screen.getByRole('dialog', { name: '保存正式素材' });
expect(within(dialog).getByText('消耗 7 泥点')).toBeTruthy();
expect(within(dialog).getByText('本次会覆盖当前待确认素材。')).toBeTruthy();
expect(screen.queryByRole('button', { name: '关闭' })).toBeNull();
});
test('applies the stronger default overlay and panel chrome', () => {
render(
<PlatformMudPointConfirmDialog
open
points={10}
onClose={() => {}}
onConfirm={() => {}}
portal={false}
/>,
);
const dialog = screen.getByRole('dialog', { name: '确认消耗泥点' });
const overlay = dialog.parentElement as HTMLElement;
expect(overlay.className).toContain('platform-modal-backdrop');
expect(overlay.className).toContain('platform-theme--light');
expect(overlay.className).toContain('!bg-black/45');
expect(dialog.className).toContain('platform-modal-shell');
expect(dialog.className).toContain('max-w-xs');
expect(dialog.className).toContain(
'shadow-[0_24px_70px_rgba(15,23,42,0.22)]',
);
});

View File

@@ -0,0 +1,79 @@
import type { ReactNode } from 'react';
import { useAuthUi } from '../auth/AuthUiContext';
import { UnifiedConfirmDialog } from './UnifiedConfirmDialog';
type PlatformMudPointConfirmDialogProps = {
open: boolean;
points: number;
onClose: () => void;
onConfirm: () => void;
confirmDisabled?: boolean;
confirmLabel?: string;
title?: string;
description?: ReactNode;
children?: ReactNode;
showCloseButton?: boolean;
portal?: boolean;
size?: 'sm' | 'md';
overlayClassName?: string;
panelClassName?: string;
};
const DEFAULT_OVERLAY_CLASS_NAME = 'platform-modal-backdrop z-[80] !bg-black/45';
const DEFAULT_PANEL_CLASS_NAME =
'platform-modal-shell platform-remap-surface max-w-xs rounded-[1.35rem] shadow-[0_24px_70px_rgba(15,23,42,0.22)]';
function joinClassNames(...classNames: Array<string | undefined>) {
return classNames.filter(Boolean).join(' ');
}
/**
* 平台泥点消耗确认弹窗。
* 统一承接“确认消耗泥点 + 消耗 N 泥点”的标准确认壳层,业务侧只保留点数与确认动作。
*/
export function PlatformMudPointConfirmDialog({
open,
points,
onClose,
onConfirm,
confirmDisabled = false,
confirmLabel = '确定',
title = '确认消耗泥点',
description,
children,
showCloseButton = true,
portal = true,
size = 'sm',
overlayClassName,
panelClassName,
}: PlatformMudPointConfirmDialogProps) {
const resolvedPlatformTheme = useAuthUi()?.platformTheme ?? 'light';
return (
<UnifiedConfirmDialog
open={open}
title={title}
description={description}
onClose={onClose}
onConfirm={onConfirm}
confirmLabel={confirmLabel}
confirmDisabled={confirmDisabled}
showCancel
showCloseButton={showCloseButton}
portal={portal}
size={size}
overlayClassName={joinClassNames(
`platform-theme platform-theme--${resolvedPlatformTheme}`,
DEFAULT_OVERLAY_CLASS_NAME,
overlayClassName,
)}
panelClassName={joinClassNames(DEFAULT_PANEL_CLASS_NAME, panelClassName)}
>
<div className="space-y-2">
<div className="font-semibold"> {points} </div>
{children}
</div>
</UnifiedConfirmDialog>
);
}

View File

@@ -0,0 +1,48 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, test, vi } from 'vitest';
import { PlatformNavigableListItem } from './PlatformNavigableListItem';
describe('PlatformNavigableListItem', () => {
test('renders shared navigable row structure with leading and trailing slots', () => {
render(
<PlatformNavigableListItem
align="start"
leading={<span data-testid="leading">L</span>}
trailing={<span data-testid="trailing">R</span>}
>
<span></span>
</PlatformNavigableListItem>,
);
const button = screen.getByRole('button', { name: /L\s+\s+R/u });
expect(button.className).toContain('platform-navigable-list-item');
expect(button.className).toContain('items-start');
expect(button.className).toContain('gap-4');
expect(screen.getByTestId('leading').parentElement?.className).toContain(
'platform-navigable-list-item__leading',
);
expect(screen.getByTestId('trailing').parentElement?.className).toContain(
'platform-navigable-list-item__trailing',
);
});
test('defaults to type button and forwards click handlers', () => {
const onClick = vi.fn();
render(
<PlatformNavigableListItem onClick={onClick}>
<span></span>
</PlatformNavigableListItem>,
);
const button = screen.getByRole('button', { name: '打开详情' });
expect(button.getAttribute('type')).toBe('button');
fireEvent.click(button);
expect(onClick).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,88 @@
import type { ComponentPropsWithoutRef, ReactNode } from 'react';
type PlatformNavigableListItemAlign = 'center' | 'start';
type PlatformNavigableListItemProps = Omit<
ComponentPropsWithoutRef<'button'>,
'children' | 'type'
> & {
children: ReactNode;
leading?: ReactNode;
trailing?: ReactNode;
align?: PlatformNavigableListItemAlign;
leadingClassName?: string;
trailingClassName?: string;
bodyClassName?: string;
};
const ALIGN_CLASS: Record<PlatformNavigableListItemAlign, string> = {
center: 'items-center gap-3',
start: 'items-start gap-4',
};
/**
* 轻量可导航列表行骨架。
* 只统一 button 语义与 left-content/right-affordance 结构不持有业务文案、badge 或封面规则。
*/
export function PlatformNavigableListItem({
children,
leading = null,
trailing = null,
align = 'center',
className,
leadingClassName,
trailingClassName,
bodyClassName,
...props
}: PlatformNavigableListItemProps) {
return (
<button
type="button"
className={[
'platform-navigable-list-item flex w-full min-w-0 text-left',
ALIGN_CLASS[align],
className ?? null,
]
.filter(Boolean)
.join(' ')}
{...props}
>
{leading ? (
<div
className={[
'platform-navigable-list-item__leading shrink-0',
leadingClassName ?? null,
]
.filter(Boolean)
.join(' ')}
>
{leading}
</div>
) : null}
<div
className={[
'platform-navigable-list-item__body min-w-0 flex-1',
bodyClassName ?? null,
]
.filter(Boolean)
.join(' ')}
>
{children}
</div>
{trailing ? (
<div
className={[
'platform-navigable-list-item__trailing shrink-0',
trailingClassName ?? null,
]
.filter(Boolean)
.join(' ')}
>
{trailing}
</div>
) : null}
</button>
);
}

View File

@@ -0,0 +1,53 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { expect, test } from 'vitest';
import { PlatformOverlayBadge } from './PlatformOverlayBadge';
test('renders a light top-left overlay badge by default', () => {
render(<PlatformOverlayBadge>1</PlatformOverlayBadge>);
const badge = screen.getByText('第1幕');
expect(badge.className).toContain('absolute');
expect(badge.className).toContain('left-3');
expect(badge.className).toContain('top-3');
expect(badge.className).toContain('bg-white/88');
expect(badge.className).toContain('tracking-[0.18em]');
});
test('supports alternate placement and custom class', () => {
render(
<PlatformOverlayBadge placement="bottomRight" className="custom-overlay">
</PlatformOverlayBadge>,
);
const badge = screen.getByText('已选择');
expect(badge.className).toContain('bottom-3');
expect(badge.className).toContain('right-3');
expect(badge.className).toContain('custom-overlay');
});
test('supports compact muted tight overlay badge', () => {
render(
<PlatformOverlayBadge
placement="topRight"
offset="tight"
tone="muted"
size="compact"
>
</PlatformOverlayBadge>,
);
const badge = screen.getByText('占位图');
expect(badge.className).toContain('right-2');
expect(badge.className).toContain('top-2');
expect(badge.className).toContain('bg-[var(--platform-subpanel-fill)]');
expect(badge.className).toContain('px-2');
expect(badge.className).toContain('tracking-normal');
});

View File

@@ -0,0 +1,87 @@
import type { HTMLAttributes, ReactNode } from 'react';
type PlatformOverlayBadgePlacement =
| 'topLeft'
| 'topRight'
| 'bottomLeft'
| 'bottomRight';
type PlatformOverlayBadgeOffset = 'default' | 'tight';
type PlatformOverlayBadgeSize = 'default' | 'compact';
type PlatformOverlayBadgeTone = 'light' | 'muted';
type PlatformOverlayBadgeProps = Omit<
HTMLAttributes<HTMLSpanElement>,
'children'
> & {
children: ReactNode;
placement?: PlatformOverlayBadgePlacement;
offset?: PlatformOverlayBadgeOffset;
size?: PlatformOverlayBadgeSize;
tone?: PlatformOverlayBadgeTone;
};
const PLATFORM_OVERLAY_BADGE_PLACEMENT_CLASS: Record<
PlatformOverlayBadgeOffset,
Record<PlatformOverlayBadgePlacement, string>
> = {
default: {
topLeft: 'left-3 top-3',
topRight: 'right-3 top-3',
bottomLeft: 'bottom-3 left-3',
bottomRight: 'bottom-3 right-3',
},
tight: {
topLeft: 'left-2 top-2',
topRight: 'right-2 top-2',
bottomLeft: 'bottom-2 left-2',
bottomRight: 'bottom-2 right-2',
},
};
const PLATFORM_OVERLAY_BADGE_SIZE_CLASS: Record<
PlatformOverlayBadgeSize,
string
> = {
default: 'px-3 py-1 tracking-[0.18em] shadow-[0_8px_20px_rgba(0,0,0,0.22)]',
compact: 'px-2 py-0.5 tracking-normal shadow-sm',
};
const PLATFORM_OVERLAY_BADGE_TONE_CLASS: Record<
PlatformOverlayBadgeTone,
string
> = {
light: 'border-white/40 bg-white/88 text-zinc-900',
muted:
'border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)] text-[var(--platform-text-soft)]',
};
/**
* 平台媒体悬浮标签。
* 统一承接预览图、素材图和舞台画面上的非交互短标签。
*/
export function PlatformOverlayBadge({
children,
placement = 'topLeft',
offset = 'default',
size = 'default',
tone = 'light',
className,
...spanProps
}: PlatformOverlayBadgeProps) {
return (
<span
{...spanProps}
className={[
'absolute rounded-full border text-[10px] font-bold',
PLATFORM_OVERLAY_BADGE_PLACEMENT_CLASS[offset][placement],
PLATFORM_OVERLAY_BADGE_SIZE_CLASS[size],
PLATFORM_OVERLAY_BADGE_TONE_CLASS[tone],
className,
]
.filter(Boolean)
.join(' ')}
>
{children}
</span>
);
}

View File

@@ -0,0 +1,148 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { Tag } from 'lucide-react';
import { expect, test } from 'vitest';
import { PlatformPillBadge } from './PlatformPillBadge';
import { getPlatformPillBadgeClassName } from './platformPillBadgeModel';
test('renders neutral platform pill badge by default', () => {
render(<PlatformPillBadge>稿</PlatformPillBadge>);
const badge = screen.getByText('草稿');
expect(badge.className).toContain('rounded-full');
expect(badge.className).toContain('border-[var(--platform-subpanel-border)]');
expect(badge.className).toContain('bg-white/72');
});
test('supports muted platform pill badge', () => {
render(<PlatformPillBadge tone="muted"></PlatformPillBadge>);
const badge = screen.getByText('选择');
expect(badge.className).toContain('bg-[var(--platform-subpanel-fill)]');
expect(badge.className).toContain('text-[var(--platform-text-soft)]');
});
test('supports solid neutral status pill badge', () => {
render(<PlatformPillBadge tone="neutralSolid"></PlatformPillBadge>);
const badge = screen.getByText('开启');
expect(badge.className).toContain('bg-[var(--platform-neutral-bg)]');
expect(badge.className).toContain('text-[var(--platform-neutral-text)]');
});
test('supports light overlay pill badge for nested action metadata', () => {
render(<PlatformPillBadge tone="lightOverlay">2</PlatformPillBadge>);
const badge = screen.getByText('2泥点');
expect(badge.className).toContain('border-white/30');
expect(badge.className).toContain('bg-white/24');
expect(badge.className).toContain('text-current');
});
test('supports success tone, compact size, icon and custom class', () => {
render(
<PlatformPillBadge
tone="success"
size="xs"
icon={<Tag aria-hidden="true" className="h-3 w-3" />}
className="ml-2"
>
</PlatformPillBadge>,
);
const badge = screen.getByText('已发布');
expect(badge.className).toContain('border-emerald-200');
expect(badge.className).toContain('text-[11px]');
expect(badge.className).toContain('ml-2');
expect(badge.querySelector('svg')).toBeTruthy();
});
test('supports extra compact size for dense catalog chips', () => {
render(
<PlatformPillBadge tone="warning" size="xxs">
</PlatformPillBadge>,
);
const badge = screen.getByText('新');
expect(badge.className).toContain('px-2.5');
expect(badge.className).toContain('py-1');
expect(badge.className).toContain('text-[10px]');
});
test('shares badge chrome with interactive pill actions', () => {
const className = getPlatformPillBadgeClassName({
tone: 'neutral',
size: 'xs',
className: 'tracking-[0.18em]',
});
expect(className).toContain('inline-flex');
expect(className).toContain('rounded-full');
expect(className).toContain('bg-white/72');
expect(className).toContain('text-[11px]');
expect(className).toContain('tracking-[0.18em]');
});
test('supports warning, danger and cool tones', () => {
const { rerender } = render(
<PlatformPillBadge tone="warning"></PlatformPillBadge>,
);
expect(screen.getByText('待确认').className).toContain(
'border-[var(--platform-warm-border)]',
);
rerender(<PlatformPillBadge tone="danger"></PlatformPillBadge>);
expect(screen.getByText('失败').className).toContain(
'border-[var(--platform-button-danger-border)]',
);
rerender(<PlatformPillBadge tone="cool"></PlatformPillBadge>);
expect(screen.getByText('处理中').className).toContain(
'border-[var(--platform-cool-border)]',
);
});
test('supports dark RPG badge tones', () => {
const { rerender } = render(
<PlatformPillBadge tone="darkSky"></PlatformPillBadge>,
);
expect(screen.getByText('爆发').className).toContain('bg-sky-500/10');
rerender(<PlatformPillBadge tone="darkEmerald"></PlatformPillBadge>);
expect(screen.getByText('状态').className).toContain('text-emerald-100');
rerender(<PlatformPillBadge tone="darkNeutral">Lv.7</PlatformPillBadge>);
expect(screen.getByText('Lv.7').className).toContain('bg-black/20');
rerender(<PlatformPillBadge tone="darkAmber"></PlatformPillBadge>);
expect(screen.getByText('队长').className).toContain('text-amber-100');
rerender(<PlatformPillBadge tone="darkRose"></PlatformPillBadge>);
expect(screen.getByText('敌对').className).toContain('bg-rose-500/10');
});
test('supports profile tones for personal center chips', () => {
render(<PlatformPillBadge tone="profile">80</PlatformPillBadge>);
const badge = screen.getByText('80泥点');
expect(badge.className).toContain('border-rose-100');
expect(badge.className).toContain('bg-rose-50');
expect(badge.className).toContain('text-zinc-600');
render(<PlatformPillBadge tone="profileAccent">RPG</PlatformPillBadge>);
expect(screen.getByText('RPG').className).toContain('text-[#ff4056]');
});

View File

@@ -0,0 +1,40 @@
import type { HTMLAttributes, ReactNode } from 'react';
import {
getPlatformPillBadgeClassName,
type PlatformPillBadgeSize,
type PlatformPillBadgeTone,
} from './platformPillBadgeModel';
type PlatformPillBadgeProps = Omit<
HTMLAttributes<HTMLSpanElement>,
'children'
> & {
tone?: PlatformPillBadgeTone;
size?: PlatformPillBadgeSize;
icon?: ReactNode;
children: ReactNode;
};
/**
* 平台胶囊状态标签。
* 统一承接结果页、作品卡和配置摘要里的小型状态 / 标签 chip。
*/
export function PlatformPillBadge({
tone = 'neutral',
size = 'sm',
icon,
children,
className,
...spanProps
}: PlatformPillBadgeProps) {
return (
<span
{...spanProps}
className={getPlatformPillBadgeClassName({ tone, size, className })}
>
{icon}
{children}
</span>
);
}

View File

@@ -0,0 +1,81 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import { PlatformPillSwitch } from './PlatformPillSwitch';
test('renders checked pill switch with platform chrome', () => {
render(
<PlatformPillSwitch
label="AI重绘"
aria-label="AI重绘"
checked
onChange={() => {}}
/>,
);
const switchInput = screen.getByRole('switch', { name: 'AI重绘' });
expect(switchInput).toHaveProperty('checked', true);
expect(screen.getByText('AI重绘').closest('label')?.className).toContain(
'bg-white/94',
);
expect(screen.getByText('AI重绘').closest('label')?.className).toContain(
'backdrop-blur',
);
});
test('calls onChange when toggled', () => {
const onChange = vi.fn();
render(
<PlatformPillSwitch
label="AI重绘"
aria-label="AI重绘"
checked={false}
onChange={onChange}
/>,
);
fireEvent.click(screen.getByRole('switch', { name: 'AI重绘' }));
expect(onChange).toHaveBeenCalledTimes(1);
});
test('keeps disabled switch inert and styled as disabled', () => {
const onChange = vi.fn();
render(
<PlatformPillSwitch
label="AI重绘"
aria-label="AI重绘"
checked={false}
disabled
onChange={onChange}
/>,
);
const switchInput = screen.getByRole('switch', { name: 'AI重绘' });
expect(switchInput).toHaveProperty('disabled', true);
expect(screen.getByText('AI重绘').closest('label')?.className).toContain(
'cursor-not-allowed',
);
});
test('keeps local placement classes', () => {
render(
<PlatformPillSwitch
label="AI重绘"
aria-label="AI重绘"
checked={false}
className="absolute bottom-3 left-3"
onChange={() => {}}
/>,
);
expect(screen.getByText('AI重绘').closest('label')?.className).toContain(
'bottom-3',
);
});

View File

@@ -0,0 +1,56 @@
import type { InputHTMLAttributes, ReactNode } from 'react';
type PlatformPillSwitchProps = Omit<
InputHTMLAttributes<HTMLInputElement>,
'children' | 'type'
> & {
label: ReactNode;
};
/**
* 平台胶囊开关。
* 统一承载图片面板中类似 AI 重绘的 label + switch 语义和视觉。
*/
export function PlatformPillSwitch({
label,
checked,
disabled,
className,
...inputProps
}: PlatformPillSwitchProps) {
return (
<label
className={[
'inline-flex cursor-pointer items-center gap-2 rounded-full border border-white/80 bg-white/94 px-3 py-2 text-xs font-black text-[var(--platform-text-strong)] shadow-sm backdrop-blur',
disabled ? 'cursor-not-allowed opacity-55' : null,
className,
]
.filter(Boolean)
.join(' ')}
>
<span>{label}</span>
<input
{...inputProps}
role="switch"
type="checkbox"
checked={checked}
disabled={disabled}
className="sr-only"
/>
<span
aria-hidden="true"
className={[
'relative h-5 w-9 rounded-full transition',
checked ? 'bg-[var(--platform-accent)]' : 'bg-zinc-300',
].join(' ')}
>
<span
className={[
'absolute top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition',
checked ? 'left-[1.125rem]' : 'left-0.5',
].join(' ')}
/>
</span>
</label>
);
}

View File

@@ -0,0 +1,63 @@
import type { ReactNode } from 'react';
import { PlatformSubpanel } from './PlatformSubpanel';
type PlatformProfileContentRowProps = {
as?: 'div' | 'button';
children: ReactNode;
className?: string;
surface?: 'platform' | 'flat' | 'soft';
radius?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
padding?: 'none' | 'row' | 'xs' | 'sm' | 'md' | 'lg' | 'tight';
interactive?: boolean;
disabled?: boolean;
type?: 'button' | 'submit' | 'reset';
onClick?: () => void;
};
/**
* 个人中心白底 modal 内容行骨架。
* 只承接常见的 row 外壳与可点击语义,具体字段布局仍留在业务 modal 内部。
*/
export function PlatformProfileContentRow({
as = 'div',
children,
className,
surface = 'flat',
radius = 'xs',
padding = 'md',
interactive = as === 'button',
disabled,
type = 'button',
onClick,
}: PlatformProfileContentRowProps) {
if (as === 'button') {
return (
<PlatformSubpanel
as="button"
type={type}
onClick={onClick}
disabled={disabled}
interactive={interactive}
surface={surface}
radius={radius}
padding={padding}
className={className}
>
{children}
</PlatformSubpanel>
);
}
return (
<PlatformSubpanel
as="div"
surface={surface}
radius={radius}
padding={padding}
className={className}
>
{children}
</PlatformSubpanel>
);
}

View File

@@ -0,0 +1,51 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test, vi } from 'vitest';
import { PlatformProfileContentRow } from './PlatformProfileContentRow';
import { PlatformProfileSkeletonList } from './PlatformProfileSkeletonList';
import { PlatformProfileSummaryHeader } from './PlatformProfileSummaryHeader';
test('platform profile summary header renders kicker, title and badge slot', () => {
render(
<PlatformProfileSummaryHeader
kicker="PLAYED"
title="玩过"
badge={<span>1.5</span>}
/>,
);
expect(screen.getByText('PLAYED')).toBeTruthy();
expect(screen.getByText('玩过')).toBeTruthy();
expect(screen.getByText('1.5小时')).toBeTruthy();
});
test('platform profile skeleton list renders requested skeleton rows', () => {
const { container } = render(
<PlatformProfileSkeletonList
count={3}
containerClassName="space-y-3"
itemClassName="h-16"
/>,
);
expect(container.querySelectorAll('.animate-pulse')).toHaveLength(3);
expect(container.querySelector('.space-y-3')).toBeTruthy();
expect(container.querySelector('.h-16')).toBeTruthy();
});
test('platform profile content row preserves interactive button semantics', async () => {
const user = userEvent.setup();
const onClick = vi.fn();
render(
<PlatformProfileContentRow as="button" onClick={onClick}>
</PlatformProfileContentRow>,
);
await user.click(screen.getByRole('button', { name: '点击行' }));
expect(onClick).toHaveBeenCalledTimes(1);
});

View File

@@ -0,0 +1,31 @@
type PlatformProfileSkeletonListProps = {
count: number;
containerClassName?: string;
itemClassName?: string;
};
/**
* 个人中心白底 modal 列表读取骨架。
* 只负责重复 skeleton 行与容器节奏,行高和栅格继续由调用方微调。
*/
export function PlatformProfileSkeletonList({
count,
containerClassName,
itemClassName,
}: PlatformProfileSkeletonListProps) {
return (
<div className={containerClassName}>
{Array.from({ length: count }).map((_, index) => (
<div
key={index}
className={[
'animate-pulse rounded-xl bg-zinc-100',
itemClassName,
]
.filter(Boolean)
.join(' ')}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,39 @@
import type { ReactNode } from 'react';
type PlatformProfileSummaryHeaderProps = {
kicker: ReactNode;
title: ReactNode;
badge?: ReactNode;
className?: string;
titleClassName?: string;
badgeClassName?: string;
};
/**
* 个人中心白底副弹层标题摘要骨架。
* 只收口 kicker、标题和摘要 badge 的层次,不承接业务数值拼装。
*/
export function PlatformProfileSummaryHeader({
kicker,
title,
badge,
className,
titleClassName,
badgeClassName,
}: PlatformProfileSummaryHeaderProps) {
return (
<div className={['pr-10', className].filter(Boolean).join(' ')}>
<div className="text-[10px] font-black tracking-[0.22em] text-[#ff4056]">
{kicker}
</div>
<div className={['mt-1 text-2xl font-black', titleClassName].filter(Boolean).join(' ')}>
{title}
</div>
{badge ? (
<div className={['mt-3', badgeClassName].filter(Boolean).join(' ')}>
{badge}
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,110 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { expect, test } from 'vitest';
import { PlatformProgressBar } from './PlatformProgressBar';
test('renders shared progressbar chrome and value', () => {
render(
<PlatformProgressBar
value={42}
minVisibleValue={8}
size="sm"
className="mt-3"
fillClassName="bg-emerald-300"
/>,
);
const progressbar = screen.getByRole('progressbar');
const fill = progressbar.firstElementChild as HTMLElement | null;
expect(progressbar.getAttribute('aria-valuenow')).toBe('42');
expect(progressbar.className).toContain('platform-progress-track');
expect(progressbar.className).toContain('h-2.5');
expect(progressbar.className).toContain('mt-3');
expect(fill?.className).toContain('bg-emerald-300');
expect(fill?.style.width).toBe('42%');
});
test('keeps zero progress visually empty and clamps invalid values', () => {
const { rerender } = render(<PlatformProgressBar value={0} />);
let progressbar = screen.getByRole('progressbar');
let fill = progressbar.firstElementChild as HTMLElement | null;
expect(progressbar.getAttribute('aria-valuenow')).toBe('0');
expect(fill?.style.width).toBe('0%');
rerender(<PlatformProgressBar value={Number.NaN} />);
progressbar = screen.getByRole('progressbar');
fill = progressbar.firstElementChild as HTMLElement | null;
expect(progressbar.getAttribute('aria-valuenow')).toBe('0');
expect(fill?.style.width).toBe('0%');
rerender(<PlatformProgressBar value={130} />);
progressbar = screen.getByRole('progressbar');
fill = progressbar.firstElementChild as HTMLElement | null;
expect(progressbar.getAttribute('aria-valuenow')).toBe('100');
expect(fill?.style.width).toBe('100%');
});
test('supports labelled progressbar and local fill style', () => {
render(
<>
<div id="progress-label"></div>
<PlatformProgressBar
value={3}
minVisibleValue={6}
labelledBy="progress-label"
size="md"
fillStyle={{ backgroundColor: 'red' }}
/>
</>,
);
const progressbar = screen.getByRole('progressbar');
const fill = progressbar.firstElementChild as HTMLElement | null;
expect(progressbar.getAttribute('aria-labelledby')).toBe('progress-label');
expect(progressbar.className).toContain('h-3');
expect(fill?.style.width).toBe('6%');
expect(fill?.style.backgroundColor).toBe('red');
});
test('supports aria label and overlay content', () => {
render(
<PlatformProgressBar value={64} ariaLabel="画面生成进度" size="lg">
<span> 270 </span>
</PlatformProgressBar>,
);
const progressbar = screen.getByRole('progressbar', {
name: '画面生成进度',
});
expect(progressbar.getAttribute('aria-valuenow')).toBe('64');
expect(progressbar.className).toContain('relative');
expect(progressbar.className).toContain('h-12');
expect(screen.getByText('预计剩余 270 秒')).toBeTruthy();
});
test('omits current value for indeterminate progress', () => {
render(
<PlatformProgressBar
value={66}
ariaLabel="生成中"
indeterminate
fillClassName="animate-pulse"
/>,
);
const progressbar = screen.getByRole('progressbar', { name: '生成中' });
const fill = progressbar.firstElementChild as HTMLElement | null;
expect(progressbar.getAttribute('aria-valuenow')).toBeNull();
expect(fill?.className).toContain('animate-pulse');
expect(fill?.style.width).toBe('66%');
});

View File

@@ -0,0 +1,90 @@
import type { CSSProperties, ReactNode } from 'react';
type PlatformProgressBarSize = 'xs' | 'sm' | 'md' | 'lg';
type PlatformProgressBarProps = {
value: number;
minVisibleValue?: number;
size?: PlatformProgressBarSize;
ariaLabel?: string;
labelledBy?: string;
indeterminate?: boolean;
className?: string;
fillClassName?: string;
fillStyle?: CSSProperties;
trackStyle?: CSSProperties;
children?: ReactNode;
};
const PLATFORM_PROGRESS_BAR_SIZE_CLASS: Record<
PlatformProgressBarSize,
string
> = {
xs: 'h-2',
sm: 'h-2.5',
md: 'h-3',
lg: 'h-12',
};
function clampProgressValue(value: number) {
if (!Number.isFinite(value)) {
return 0;
}
return Math.min(100, Math.max(0, Math.round(value)));
}
/**
* 平台通用进度条。
* 统一承接 progressbar 语义、platform-progress-track 壳和填充宽度计算。
*/
export function PlatformProgressBar({
value,
minVisibleValue = 0,
size = 'xs',
ariaLabel,
labelledBy,
indeterminate = false,
className,
fillClassName,
fillStyle,
trackStyle,
children,
}: PlatformProgressBarProps) {
const progress = clampProgressValue(value);
const visibleProgress =
progress <= 0 ? 0 : Math.max(minVisibleValue, progress);
return (
<div
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={indeterminate ? undefined : progress}
aria-label={ariaLabel}
aria-labelledby={labelledBy}
className={[
'platform-progress-track relative overflow-hidden rounded-full',
PLATFORM_PROGRESS_BAR_SIZE_CLASS[size],
className,
]
.filter(Boolean)
.join(' ')}
style={trackStyle}
>
<div
className={[
'h-full rounded-full transition-[width] duration-300',
fillClassName,
]
.filter(Boolean)
.join(' ')}
style={{
width: `${visibleProgress}%`,
...fillStyle,
}}
/>
{children}
</div>
);
}

View File

@@ -0,0 +1,31 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { expect, test } from 'vitest';
import { PlatformQuantityBadge } from './PlatformQuantityBadge';
test('renders a dark bottom-right quantity badge by default', () => {
render(<PlatformQuantityBadge>3</PlatformQuantityBadge>);
const badge = screen.getByText('3');
expect(badge.className).toContain('absolute');
expect(badge.className).toContain('bottom-1');
expect(badge.className).toContain('right-1');
expect(badge.className).toContain('bg-black/70');
expect(badge.className).toContain('text-[10px]');
});
test('supports custom class names', () => {
render(
<PlatformQuantityBadge className="custom-quantity">
12
</PlatformQuantityBadge>,
);
const badge = screen.getByText('12');
expect(badge.className).toContain('rounded-full');
expect(badge.className).toContain('custom-quantity');
});

View File

@@ -0,0 +1,55 @@
import type { HTMLAttributes, ReactNode } from 'react';
type PlatformQuantityBadgePlacement = 'bottomRight';
type PlatformQuantityBadgeTone = 'dark';
type PlatformQuantityBadgeProps = Omit<
HTMLAttributes<HTMLSpanElement>,
'children'
> & {
children: ReactNode;
placement?: PlatformQuantityBadgePlacement;
tone?: PlatformQuantityBadgeTone;
};
const PLATFORM_QUANTITY_BADGE_PLACEMENT_CLASS: Record<
PlatformQuantityBadgePlacement,
string
> = {
bottomRight: 'bottom-1 right-1',
};
const PLATFORM_QUANTITY_BADGE_TONE_CLASS: Record<
PlatformQuantityBadgeTone,
string
> = {
dark: 'border-black/35 bg-black/70 text-white',
};
/**
* 平台物品数量角标。
* 统一承接物品格、奖励格等缩略图右下角的数量显示。
*/
export function PlatformQuantityBadge({
children,
placement = 'bottomRight',
tone = 'dark',
className,
...spanProps
}: PlatformQuantityBadgeProps) {
return (
<span
{...spanProps}
className={[
'absolute rounded-full border px-1.5 py-0.5 text-[10px] font-semibold',
PLATFORM_QUANTITY_BADGE_PLACEMENT_CLASS[placement],
PLATFORM_QUANTITY_BADGE_TONE_CLASS[tone],
className,
]
.filter(Boolean)
.join(' ')}
>
{children}
</span>
);
}

View File

@@ -0,0 +1,59 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, expect, test, vi } from 'vitest';
import * as clipboardService from '../../services/clipboard';
import { PlatformReportDialog } from './PlatformReportDialog';
vi.mock('../../services/clipboard', () => ({
copyTextToClipboard: vi.fn(),
}));
afterEach(() => {
vi.clearAllMocks();
});
test('renders report fields and copies the joined report lines', async () => {
vi.mocked(clipboardService.copyTextToClipboard).mockResolvedValue(true);
render(
<PlatformReportDialog
open
title="统一报告"
onClose={() => {}}
copyIdleLabel="复制内容"
fields={[
{ label: '来源', value: '拼图草稿 puzzle-session-1' },
{ label: '状态', value: '已完成', multiline: true },
]}
/>,
);
const dialog = screen.getByRole('dialog', { name: '统一报告' });
expect(within(dialog).getByText('拼图草稿 puzzle-session-1')).toBeTruthy();
expect(within(dialog).getByText('已完成')).toBeTruthy();
fireEvent.click(within(dialog).getByRole('button', { name: '复制内容' }));
expect(clipboardService.copyTextToClipboard).toHaveBeenCalledWith(
['来源:拼图草稿 puzzle-session-1', '状态:已完成'].join('\n'),
);
await waitFor(() => {
expect(within(dialog).getByRole('button', { name: '已复制' })).toBeTruthy();
});
});
test('does not render report fields when closed', () => {
render(
<PlatformReportDialog
open={false}
title="统一报告"
onClose={() => {}}
copyIdleLabel="复制内容"
fields={[]}
/>,
);
expect(screen.queryByRole('dialog', { name: '统一报告' })).toBeNull();
});

View File

@@ -0,0 +1,86 @@
import { useEffect, useMemo } from 'react';
import { CopyFeedbackButton } from './CopyFeedbackButton';
import { PlatformInfoBlock } from './PlatformInfoBlock';
import { PlatformUtilityInfoModal } from './PlatformUtilityInfoModal';
import { useCopyFeedback } from './useCopyFeedback';
export type PlatformReportDialogField = {
label: string;
value: string;
multiline?: boolean;
};
type PlatformReportDialogProps = {
open: boolean;
title: string;
onClose: () => void;
copyIdleLabel: string;
fields: readonly PlatformReportDialogField[];
overlayClassName?: string;
panelClassName?: string;
};
function buildPlatformReportText(fields: readonly PlatformReportDialogField[]) {
return fields.map((field) => `${field.label}${field.value}`).join('\n');
}
export function PlatformReportDialog({
open,
title,
onClose,
copyIdleLabel,
fields,
overlayClassName,
panelClassName = 'rounded-[1.5rem]',
}: PlatformReportDialogProps) {
const { copyState, copyText, resetCopyState } = useCopyFeedback();
const reportText = useMemo(() => buildPlatformReportText(fields), [fields]);
useEffect(() => {
resetCopyState();
}, [open, reportText, resetCopyState]);
const copyReport = () => {
if (!reportText) {
return;
}
void copyText(reportText);
};
return (
<PlatformUtilityInfoModal
open={open}
title={title}
onClose={onClose}
overlayClassName={overlayClassName}
panelClassName={panelClassName}
bodyClassName="space-y-3"
footerClassName="justify-end px-4 py-4 sm:px-5"
footer={
<CopyFeedbackButton
state={copyState}
onClick={copyReport}
disabled={!reportText}
idleLabel={copyIdleLabel}
actionSurface="platform"
actionFullWidth
className="sm:w-auto"
/>
}
>
{open
? fields.map((field) => (
<PlatformInfoBlock
key={`${field.label}-${field.value}`}
label={field.label}
multiline={field.multiline}
>
{field.value}
</PlatformInfoBlock>
))
: null}
</PlatformUtilityInfoModal>
);
}

View File

@@ -0,0 +1,46 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { expect, test } from 'vitest';
import { PlatformRuntimeStatusToast } from './PlatformRuntimeStatusToast';
test('renders error runtime toast with alert semantics', () => {
render(
<PlatformRuntimeStatusToast tone="error" className="mt-2">
</PlatformRuntimeStatusToast>,
);
const toast = screen.getByRole('alert');
expect(toast.className).toContain('platform-runtime-status-toast');
expect(toast.className).toContain('rounded-full');
expect(toast.className).toContain(
'text-[var(--platform-button-danger-text)]',
);
expect(toast.className).toContain('mt-2');
expect(toast.getAttribute('aria-live')).toBe('assertive');
});
test('supports dark and solid runtime surfaces', () => {
const { rerender } = render(
<PlatformRuntimeStatusToast tone="success" surface="dark" shape="rounded">
</PlatformRuntimeStatusToast>,
);
expect(screen.getByRole('status').className).toContain(
'border-emerald-200/35',
);
expect(screen.getByRole('status').className).toContain('rounded-[1.2rem]');
rerender(
<PlatformRuntimeStatusToast tone="error" surface="solid" size="md">
</PlatformRuntimeStatusToast>,
);
expect(screen.getByRole('alert').className).toContain('bg-rose-600');
expect(screen.getByRole('alert').className).toContain('px-4');
});

View File

@@ -0,0 +1,104 @@
import type { HTMLAttributes, ReactNode } from 'react';
type PlatformRuntimeStatusTone =
| 'error'
| 'success'
| 'info'
| 'warning'
| 'neutral';
type PlatformRuntimeStatusSurface = 'light' | 'dark' | 'solid';
type PlatformRuntimeStatusSize = 'xs' | 'sm' | 'md';
type PlatformRuntimeStatusShape = 'pill' | 'rounded';
type PlatformRuntimeStatusToastProps = Omit<
HTMLAttributes<HTMLDivElement>,
'children'
> & {
tone: PlatformRuntimeStatusTone;
surface?: PlatformRuntimeStatusSurface;
size?: PlatformRuntimeStatusSize;
shape?: PlatformRuntimeStatusShape;
children: ReactNode;
};
const PLATFORM_RUNTIME_STATUS_SURFACE_CLASS: Record<
PlatformRuntimeStatusSurface,
Record<PlatformRuntimeStatusTone, string>
> = {
light: {
error:
'border-white/70 bg-white/86 text-[var(--platform-button-danger-text)]',
success:
'border-white/70 bg-white/86 text-[var(--platform-success-text)]',
info: 'border-white/70 bg-white/86 text-[var(--platform-cool-text)]',
warning: 'border-white/70 bg-white/86 text-[var(--platform-warm-text)]',
neutral: 'border-white/70 bg-white/86 text-[var(--platform-text-base)]',
},
dark: {
error: 'border-rose-200/35 bg-rose-400/18 text-rose-50',
success: 'border-emerald-200/35 bg-emerald-400/18 text-emerald-50',
info: 'border-sky-200/30 bg-sky-400/16 text-sky-50',
warning: 'border-amber-200/35 bg-amber-400/18 text-amber-50',
neutral: 'border-white/15 bg-white/12 text-white/82',
},
solid: {
error: 'border-transparent bg-rose-600 text-white',
success: 'border-transparent bg-emerald-600 text-white',
info: 'border-transparent bg-sky-600 text-white',
warning: 'border-transparent bg-amber-500 text-amber-950',
neutral: 'border-transparent bg-slate-800 text-white',
},
};
const PLATFORM_RUNTIME_STATUS_SIZE_CLASS: Record<
PlatformRuntimeStatusSize,
string
> = {
xs: 'px-3 py-1 text-xs leading-5',
sm: 'px-3 py-2 text-sm leading-5',
md: 'px-4 py-3 text-sm leading-6',
};
const PLATFORM_RUNTIME_STATUS_SHAPE_CLASS: Record<
PlatformRuntimeStatusShape,
string
> = {
pill: 'rounded-full',
rounded: 'rounded-[1.2rem]',
};
/**
* 运行态 HUD 状态提示。
* 收口游戏内短错误、成功和反馈 chip位置与玩法强品牌视觉仍由调用方控制。
*/
export function PlatformRuntimeStatusToast({
tone,
surface = 'light',
size = 'sm',
shape = 'pill',
children,
className,
role,
...divProps
}: PlatformRuntimeStatusToastProps) {
const ariaLive = divProps['aria-live'];
return (
<div
{...divProps}
role={role ?? (tone === 'error' ? 'alert' : 'status')}
aria-live={ariaLive ?? (tone === 'error' ? 'assertive' : 'polite')}
className={[
'platform-runtime-status-toast inline-flex max-w-full items-center justify-center gap-1.5 border text-center font-black shadow-sm backdrop-blur',
PLATFORM_RUNTIME_STATUS_SIZE_CLASS[size],
PLATFORM_RUNTIME_STATUS_SHAPE_CLASS[shape],
PLATFORM_RUNTIME_STATUS_SURFACE_CLASS[surface][tone],
className,
]
.filter(Boolean)
.join(' ')}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,135 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import {
PlatformOptionSegment,
PlatformPillTabRail,
PlatformUnderlineTabRail,
} from './PlatformSegmentedTabPresets';
test('underline tab rail keeps channel preset classes and tab semantics', () => {
const onChange = vi.fn();
render(
<PlatformUnderlineTabRail
items={[
{ id: 'recommend', label: '推荐' },
{ id: 'ranking', label: '排行' },
]}
activeId="recommend"
onChange={onChange}
ariaLabel="发现频道"
className="min-w-0"
/>,
);
const tablist = screen.getByRole('tablist', { name: '发现频道' });
const recommendTab = screen.getByRole('tab', { name: '推荐' });
const rankingTab = screen.getByRole('tab', { name: '排行' });
expect(tablist.className).toContain('platform-mobile-home-channelbar');
expect(tablist.className).toContain('min-w-0');
expect(recommendTab.className).toContain('platform-mobile-home-channel');
expect(recommendTab.className).toContain('platform-mobile-home-channel--active');
expect(rankingTab.className).not.toContain(
'platform-mobile-home-channel--active',
);
fireEvent.click(rankingTab);
expect(onChange).toHaveBeenCalledWith('ranking');
});
test('underline tab rail supports ranking preset', () => {
render(
<PlatformUnderlineTabRail
items={[
{ id: 'hot', label: '热门' },
{ id: 'new', label: '最新' },
]}
activeId="hot"
onChange={vi.fn()}
ariaLabel="作品排行"
variant="ranking"
/>,
);
const hotTab = screen.getByRole('tab', { name: '热门' });
expect(hotTab.closest('div')?.className).toContain('platform-ranking-tabs');
expect(hotTab.className).toContain('platform-ranking-tab');
expect(hotTab.className).toContain('platform-ranking-tab--active');
});
test('option segment keeps category filter preset classes', () => {
render(
<PlatformOptionSegment
items={[
{ id: 'all', label: '全部' },
{ id: 'rpg', label: '文字冒险' },
]}
activeId="all"
onChange={vi.fn()}
variant="categoryFilter"
/>,
);
const allButton = screen.getByRole('button', { name: '全部' });
expect(allButton.closest('div')?.className).toContain(
'platform-category-filter-dialog__options',
);
expect(allButton.className).toContain('platform-category-filter-dialog__option');
expect(allButton.className).toContain(
'platform-category-filter-dialog__option--active',
);
});
test('option segment supports profile tab semantics', () => {
const onChange = vi.fn();
render(
<PlatformOptionSegment
items={[
{ id: 'points', label: '泥点充值' },
{ id: 'membership', label: '会员卡' },
]}
activeId="points"
onChange={onChange}
variant="profile"
ariaLabel="充值类型"
/>,
);
const tablist = screen.getByRole('tablist', { name: '充值类型' });
const membershipTab = screen.getByRole('tab', { name: '会员卡' });
expect(tablist.className).toContain('grid-cols-2');
expect(membershipTab.className).toContain('w-full');
fireEvent.click(membershipTab);
expect(onChange).toHaveBeenCalledWith('membership');
});
test('pill tab rail keeps creation entry preset classes', () => {
render(
<PlatformPillTabRail
items={[
{ id: 'recent', label: '最近创作' },
{ id: 'recommend', label: '热门推荐' },
]}
activeId="recent"
onChange={vi.fn()}
ariaLabel="创作入口页签"
/>,
);
const recentTab = screen.getByRole('tab', { name: '最近创作' });
expect(recentTab.closest('div')?.className).toContain('snap-x');
expect(recentTab.className).toContain('snap-start');
expect(recentTab.className).toContain('after:bg-[#d9793f]');
});

View File

@@ -0,0 +1,182 @@
import {
PlatformSegmentedTabs,
type PlatformSegmentedTabItem,
} from './PlatformSegmentedTabs';
type PlatformSegmentedTabPresetProps<TId extends string> = {
items: readonly PlatformSegmentedTabItem<TId>[];
activeId: TId;
onChange: (id: TId) => void;
ariaLabel?: string;
className?: string;
};
export type PlatformUnderlineTabRailVariant = 'channel' | 'ranking';
const PLATFORM_UNDERLINE_TAB_RAIL_CLASS: Record<
PlatformUnderlineTabRailVariant,
string
> = {
channel: 'platform-mobile-home-channelbar pb-1',
ranking: 'platform-ranking-tabs pb-1',
};
const PLATFORM_UNDERLINE_TAB_ITEM_CLASS: Record<
PlatformUnderlineTabRailVariant,
{ base: string; active: string }
> = {
channel: {
base: 'platform-mobile-home-channel shrink-0 !min-h-8 !rounded-none !border-0 !bg-transparent !px-0 !shadow-none hover:!bg-transparent',
active: 'platform-mobile-home-channel--active',
},
ranking: {
base: 'platform-ranking-tab shrink-0 !min-h-[2.35rem] !rounded-none !border-0 !bg-transparent !px-[0.15rem] !shadow-none hover:!bg-transparent',
active: 'platform-ranking-tab--active',
},
};
export type PlatformOptionSegmentVariant = 'categoryFilter' | 'profile';
const PLATFORM_OPTION_SEGMENT_CLASS: Record<
PlatformOptionSegmentVariant,
{
rail: string;
active: string;
idle: string;
}
> = {
categoryFilter: {
rail: 'platform-category-filter-dialog__options',
active:
'platform-category-filter-dialog__option--active !border-[var(--platform-cool-border)] !bg-[var(--platform-cool-bg)] !text-[var(--platform-cool-text)]',
idle:
'platform-category-filter-dialog__option !min-h-[2.35rem] !rounded-[0.78rem] !border !border-[var(--platform-subpanel-border)] !bg-[var(--platform-subpanel-fill)] !px-3 !text-[0.88rem] !font-black !text-[var(--platform-text-base)] !shadow-none hover:!bg-[var(--platform-subpanel-fill)]',
},
profile: {
rail: '',
active:
'!border-[var(--platform-cool-border)] !bg-[var(--platform-cool-bg)] !text-[var(--platform-cool-text)]',
idle:
'w-full !min-h-[2.25rem] !rounded-[0.78rem] !border !border-[var(--platform-subpanel-border)] !bg-[rgba(255,255,255,0.04)] !px-3 !text-sm !font-extrabold !text-[var(--platform-text-base)] !shadow-none hover:!bg-[rgba(255,255,255,0.08)]',
},
};
/**
* 统一首页、作品架这类横向文字 rail只沉淀稳定的滚动与下划线皮肤。
*/
export function PlatformUnderlineTabRail<TId extends string>({
items,
activeId,
onChange,
ariaLabel,
className,
variant = 'channel',
}: PlatformSegmentedTabPresetProps<TId> & {
variant?: PlatformUnderlineTabRailVariant;
}) {
return (
<PlatformSegmentedTabs
items={items}
activeId={activeId}
onChange={onChange}
layout="scroll"
gap="md"
frame="bare"
surface="transparent"
size="sm"
tone="neutral"
semantics="tabs"
ariaLabel={ariaLabel}
className={[PLATFORM_UNDERLINE_TAB_RAIL_CLASS[variant], className]
.filter(Boolean)
.join(' ')}
itemClassName={(_, active) =>
[
PLATFORM_UNDERLINE_TAB_ITEM_CLASS[variant].base,
active ? PLATFORM_UNDERLINE_TAB_ITEM_CLASS[variant].active : null,
]
.filter(Boolean)
.join(' ')
}
/>
);
}
/**
* 统一二列按钮式切换,只负责稳定的视觉 preset不承接业务语义。
*/
export function PlatformOptionSegment<TId extends string>({
items,
activeId,
onChange,
ariaLabel,
className,
variant,
}: PlatformSegmentedTabPresetProps<TId> & {
variant: PlatformOptionSegmentVariant;
}) {
return (
<PlatformSegmentedTabs
items={items}
activeId={activeId}
onChange={onChange}
columns="two"
layout="grid"
gap={variant === 'profile' ? 'sm' : 'md'}
frame="bare"
surface="transparent"
size="sm"
semantics={variant === 'profile' ? 'tabs' : 'segment'}
ariaLabel={ariaLabel}
className={[PLATFORM_OPTION_SEGMENT_CLASS[variant].rail, className]
.filter(Boolean)
.join(' ')}
itemClassName={(_, active) =>
[
PLATFORM_OPTION_SEGMENT_CLASS[variant].idle,
active ? PLATFORM_OPTION_SEGMENT_CLASS[variant].active : null,
]
.filter(Boolean)
.join(' ')
}
/>
);
}
/**
* 创作入口使用的轻量 pill rail保留 snap 与下划线的组合语义。
*/
export function PlatformPillTabRail<TId extends string>({
items,
activeId,
onChange,
ariaLabel,
className,
}: PlatformSegmentedTabPresetProps<TId>) {
return (
<PlatformSegmentedTabs
items={items}
activeId={activeId}
onChange={onChange}
layout="scroll"
gap="md"
frame="bare"
surface="transparent"
size="sm"
tone="neutral"
semantics="tabs"
ariaLabel={ariaLabel}
className={['-mx-0.5 snap-x px-0.5 pb-1 scroll-px-2 sm:!gap-3', className]
.filter(Boolean)
.join(' ')}
itemClassName={(_, active) =>
[
"relative shrink-0 snap-start !min-h-8 !rounded-full !border-0 !bg-transparent !px-2.5 !text-xs !font-black !shadow-none sm:!min-h-9 sm:!px-3.5 sm:!text-sm",
active
? "!text-[#6f2f21] after:absolute after:bottom-0 after:left-3 after:right-3 after:h-1 after:rounded-full after:bg-[#d9793f] after:content-['']"
: '!text-[#7a6558] hover:!bg-transparent hover:!text-[#6f2f21]',
].join(' ')
}
/>
);
}

View File

@@ -0,0 +1,192 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import { PlatformSegmentedTabs } from './PlatformSegmentedTabs';
const ITEMS = [
{ id: 'work', label: '作品信息' },
{ id: 'levels', label: '拼图关卡' },
] as const;
test('renders platform segmented tabs with pressed state', () => {
const onChange = vi.fn();
render(
<PlatformSegmentedTabs
items={ITEMS}
activeId="work"
onChange={onChange}
className="mb-3"
/>,
);
const workTab = screen.getByRole('button', { name: '作品信息' });
const levelsTab = screen.getByRole('button', { name: '拼图关卡' });
expect(workTab.getAttribute('aria-pressed')).toBe('true');
expect(levelsTab.getAttribute('aria-pressed')).toBe('false');
expect(workTab.className).toContain('bg-white');
expect(levelsTab.className).toContain('hover:bg-white/60');
expect(workTab.closest('div')?.className).toContain('grid-cols-2');
expect(workTab.closest('div')?.className).toContain('mb-3');
fireEvent.click(levelsTab);
expect(onChange).toHaveBeenCalledWith('levels');
});
test('supports compact responsive columns and truncated labels', () => {
render(
<PlatformSegmentedTabs
items={[
{ id: 'profile', label: '作品' },
{ id: 'world', label: '世界' },
{ id: 'opening', label: '开场' },
]}
activeId="profile"
onChange={vi.fn()}
columns="threeToSix"
gap="sm"
radius="md"
size="compact"
truncateLabels
/>,
);
const profileTab = screen.getByRole('button', { name: '作品' });
const label = profileTab.querySelector('span');
expect(profileTab.closest('div')?.className).toContain('grid-cols-3');
expect(profileTab.closest('div')?.className).toContain('sm:grid-cols-6');
expect(profileTab.closest('div')?.className).toContain('gap-1');
expect(profileTab.className).toContain('rounded-[0.8rem]');
expect(profileTab.className).toContain('text-xs');
expect(label?.className).toContain('truncate');
});
test('respects disabled items and custom item classes', () => {
const onChange = vi.fn();
render(
<PlatformSegmentedTabs
items={[
{ id: 'single', label: '单关卡' },
{ id: 'multi', label: '多关卡', disabled: true },
]}
activeId="single"
onChange={onChange}
itemClassName={(item, active) =>
item.id === 'single' && active ? 'data-active-token' : null
}
/>,
);
const singleTab = screen.getByRole('button', { name: '单关卡' });
const multiTab = screen.getByRole('button', { name: '多关卡' });
expect(singleTab.className).toContain('data-active-token');
expect(multiTab).toHaveProperty('disabled', true);
fireEvent.click(multiTab);
expect(onChange).not.toHaveBeenCalled();
});
test('supports bare four-column choice tabs with tone variants', () => {
render(
<PlatformSegmentedTabs
items={[
{ id: 'easy', label: '轻松' },
{ id: 'standard', label: '标准' },
{ id: 'advanced', label: '进阶' },
{ id: 'hardcore', label: '硬核' },
]}
activeId="advanced"
onChange={vi.fn()}
columns="four"
size="choice"
surface="transparent"
tone="rose"
frame="bare"
/>,
);
const advancedTab = screen.getByRole('button', { name: '进阶' });
const standardTab = screen.getByRole('button', { name: '标准' });
const tabGrid = advancedTab.closest('div');
expect(tabGrid?.className).toContain('grid-cols-4');
expect(tabGrid?.className).toContain('border-0');
expect(tabGrid?.className).toContain('p-0');
expect(tabGrid?.className).toContain('bg-transparent');
expect(advancedTab.className).toContain('rounded-[0.9rem]');
expect(advancedTab.className).toContain('bg-[linear-gradient');
expect(standardTab.className).toContain('bg-white/76');
});
test('supports auth-style tab semantics with underline tone', () => {
const onChange = vi.fn();
render(
<PlatformSegmentedTabs
items={[
{ id: 'phone', label: '短信登录' },
{ id: 'password', label: '密码登录' },
]}
activeId="phone"
onChange={onChange}
columns="one"
frame="bare"
surface="transparent"
tone="underline"
size="tab"
semantics="tabs"
ariaLabel="登录方式"
/>,
);
const tablist = screen.getByRole('tablist', { name: '登录方式' });
const phoneTab = screen.getByRole('tab', { name: '短信登录' });
const passwordTab = screen.getByRole('tab', { name: '密码登录' });
expect(tablist.className).toContain('grid-cols-1');
expect(phoneTab.getAttribute('aria-selected')).toBe('true');
expect(phoneTab.getAttribute('aria-pressed')).toBeNull();
expect(phoneTab.className).toContain('h-12');
expect(phoneTab.querySelector('span.absolute')).toBeTruthy();
expect(passwordTab.getAttribute('aria-selected')).toBe('false');
fireEvent.click(passwordTab);
expect(onChange).toHaveBeenCalledWith('password');
});
test('supports scroll layout for horizontal tabs', () => {
render(
<PlatformSegmentedTabs
items={[
{ id: 'recent', label: '最近创作' },
{ id: 'rpg', label: '文字冒险' },
{ id: 'jump-hop', label: '跳一跳' },
]}
activeId="recent"
onChange={vi.fn()}
layout="scroll"
semantics="tabs"
ariaLabel="创作入口页签"
frame="bare"
surface="transparent"
className="pb-1"
/>,
);
const tablist = screen.getByRole('tablist', { name: '创作入口页签' });
expect(tablist.className).toContain('flex');
expect(tablist.className).toContain('overflow-x-auto');
expect(tablist.className).toContain('scrollbar-hide');
expect(tablist.className).not.toContain('grid-cols-2');
expect(tablist.className).toContain('pb-1');
});

View File

@@ -0,0 +1,243 @@
import type { ReactNode } from 'react';
type PlatformSegmentedTabsColumns =
| 'one'
| 'two'
| 'three'
| 'four'
| 'threeToSix';
type PlatformSegmentedTabsGap = 'sm' | 'md';
type PlatformSegmentedTabsRadius = 'md' | 'lg' | 'xl';
type PlatformSegmentedTabsSize = 'sm' | 'md' | 'compact' | 'choice' | 'tab';
type PlatformSegmentedTabsSurface = 'default' | 'soft' | 'transparent';
type PlatformSegmentedTabsTone = 'neutral' | 'warm' | 'rose' | 'underline';
type PlatformSegmentedTabsFrame = 'panel' | 'bare';
type PlatformSegmentedTabsSemantics = 'segment' | 'tabs';
type PlatformSegmentedTabsLayout = 'grid' | 'scroll';
export type PlatformSegmentedTabItem<TId extends string> = {
id: TId;
label: ReactNode;
ariaLabel?: string;
disabled?: boolean;
};
type PlatformSegmentedTabsProps<TId extends string> = {
items: readonly PlatformSegmentedTabItem<TId>[];
activeId: TId;
onChange: (id: TId) => void;
columns?: PlatformSegmentedTabsColumns;
gap?: PlatformSegmentedTabsGap;
radius?: PlatformSegmentedTabsRadius;
size?: PlatformSegmentedTabsSize;
surface?: PlatformSegmentedTabsSurface;
tone?: PlatformSegmentedTabsTone;
frame?: PlatformSegmentedTabsFrame;
semantics?: PlatformSegmentedTabsSemantics;
layout?: PlatformSegmentedTabsLayout;
ariaLabel?: string;
truncateLabels?: boolean;
disabled?: boolean;
className?: string;
itemClassName?:
| string
| ((item: PlatformSegmentedTabItem<TId>, active: boolean) => string | null);
};
const PLATFORM_SEGMENTED_TABS_COLUMNS_CLASS: Record<
PlatformSegmentedTabsColumns,
string
> = {
one: 'grid-cols-1',
two: 'grid-cols-2',
three: 'grid-cols-3',
four: 'grid-cols-4',
threeToSix: 'grid-cols-3 sm:grid-cols-6',
};
const PLATFORM_SEGMENTED_TABS_GAP_CLASS: Record<
PlatformSegmentedTabsGap,
string
> = {
sm: 'gap-1',
md: 'gap-2',
};
const PLATFORM_SEGMENTED_TABS_RADIUS_CLASS: Record<
PlatformSegmentedTabsRadius,
string
> = {
md: 'rounded-[1rem]',
lg: 'rounded-[1.1rem]',
xl: 'rounded-[1.25rem]',
};
const PLATFORM_SEGMENTED_TABS_SURFACE_CLASS: Record<
PlatformSegmentedTabsSurface,
string
> = {
default: 'bg-white/62',
soft: 'bg-white/58',
transparent: 'bg-transparent',
};
const PLATFORM_SEGMENTED_TABS_FRAME_CLASS: Record<
PlatformSegmentedTabsFrame,
string
> = {
panel: 'border border-[var(--platform-subpanel-border)] p-1',
bare: 'border-0 p-0',
};
const PLATFORM_SEGMENTED_TABS_ITEM_SIZE_CLASS: Record<
PlatformSegmentedTabsSize,
string
> = {
sm: 'min-h-10 rounded-[0.9rem] px-3 text-sm font-bold',
md: 'min-h-10 rounded-[1rem] px-3 text-sm font-bold',
compact: 'min-h-10 rounded-[0.8rem] px-2 text-xs font-black sm:text-sm',
choice: 'min-h-10 rounded-[0.9rem] px-1.5 py-2 text-center',
tab: 'relative h-12 rounded-none px-2 text-base font-semibold sm:text-lg',
};
const PLATFORM_SEGMENTED_TABS_TONE_CLASS: Record<
PlatformSegmentedTabsTone,
{ active: string; idle: string }
> = {
neutral: {
active: 'bg-white text-[var(--platform-text-strong)] shadow-sm',
idle: 'text-[var(--platform-text-base)] hover:bg-white/60',
},
warm: {
active:
'bg-[var(--platform-warm-bg)] text-[var(--platform-text-strong)] shadow-[inset_0_0_0_1px_rgba(204,117,76,0.18)]',
idle: 'text-[var(--platform-text-base)] hover:bg-white/58',
},
rose: {
active:
'border border-[#ff7890] bg-[linear-gradient(180deg,#ff7890_0%,#ff4f6a_100%)] text-white shadow-[0_8px_18px_rgba(244,63,94,0.16)]',
idle: 'border border-[var(--platform-subpanel-border)] bg-white/76 text-[var(--platform-text-strong)] hover:border-[var(--platform-surface-hover-border)] hover:bg-white',
},
underline: {
active: 'text-[var(--platform-text-strong)]',
idle: 'text-[var(--platform-text-muted)] hover:text-[var(--platform-text-base)]',
},
};
function resolveItemClassName<TId extends string>({
active,
disabled,
item,
itemClassName,
size,
tone,
}: {
active: boolean;
disabled: boolean;
item: PlatformSegmentedTabItem<TId>;
itemClassName?: PlatformSegmentedTabsProps<TId>['itemClassName'];
size: PlatformSegmentedTabsSize;
tone: PlatformSegmentedTabsTone;
}) {
const extraClassName =
typeof itemClassName === 'function'
? itemClassName(item, active)
: itemClassName;
return [
'min-w-0 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--platform-warm-border)]',
PLATFORM_SEGMENTED_TABS_ITEM_SIZE_CLASS[size],
active
? PLATFORM_SEGMENTED_TABS_TONE_CLASS[tone].active
: PLATFORM_SEGMENTED_TABS_TONE_CLASS[tone].idle,
disabled ? 'cursor-not-allowed opacity-55 hover:bg-transparent' : null,
extraClassName,
]
.filter(Boolean)
.join(' ');
}
/**
* 平台白底分段选择控件。
* 统一承接结果页和轻量弹窗内重复的 tab / segment button chrome。
*/
export function PlatformSegmentedTabs<TId extends string>({
items,
activeId,
onChange,
columns = 'two',
gap = 'md',
radius = 'xl',
size = 'md',
surface = 'default',
tone = 'neutral',
frame = 'panel',
semantics = 'segment',
layout = 'grid',
ariaLabel,
truncateLabels = false,
disabled = false,
className,
itemClassName,
}: PlatformSegmentedTabsProps<TId>) {
return (
<div
role={semantics === 'tabs' ? 'tablist' : undefined}
aria-label={semantics === 'tabs' ? ariaLabel : undefined}
className={[
layout === 'scroll'
? 'flex min-w-0 items-center overflow-x-auto scrollbar-hide'
: 'grid',
PLATFORM_SEGMENTED_TABS_FRAME_CLASS[frame],
PLATFORM_SEGMENTED_TABS_GAP_CLASS[gap],
layout === 'grid' ? PLATFORM_SEGMENTED_TABS_COLUMNS_CLASS[columns] : null,
PLATFORM_SEGMENTED_TABS_RADIUS_CLASS[radius],
PLATFORM_SEGMENTED_TABS_SURFACE_CLASS[surface],
className,
]
.filter(Boolean)
.join(' ')}
>
{items.map((item) => {
const active = activeId === item.id;
const itemDisabled = disabled || Boolean(item.disabled);
return (
<button
key={item.id}
type="button"
role={semantics === 'tabs' ? 'tab' : undefined}
aria-label={item.ariaLabel}
aria-selected={semantics === 'tabs' ? active : undefined}
aria-pressed={semantics === 'segment' ? active : undefined}
disabled={itemDisabled}
onClick={() => {
if (itemDisabled) {
return;
}
onChange(item.id);
}}
className={resolveItemClassName({
active,
disabled: itemDisabled,
item,
itemClassName,
size,
tone,
})}
>
<>
{truncateLabels ? (
<span className="block truncate">{item.label}</span>
) : (
item.label
)}
{tone === 'underline' && active ? (
<span className="absolute bottom-1 left-1/2 h-1 w-12 -translate-x-1/2 rounded-full bg-[var(--platform-accent)]" />
) : null}
</>
</button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,46 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { expect, test } from 'vitest';
import { PlatformSlotBadge } from './PlatformSlotBadge';
test('renders an inactive compact slot badge by default', () => {
render(<PlatformSlotBadge>2</PlatformSlotBadge>);
const badge = screen.getByText('2');
expect(badge.className).toContain('h-5');
expect(badge.className).toContain('min-w-5');
expect(badge.className).toContain('rounded-full');
expect(badge.className).toContain('bg-zinc-900');
});
test('supports active slot badge and custom class', () => {
render(
<PlatformSlotBadge tone="active" className="custom-slot">
</PlatformSlotBadge>,
);
const badge = screen.getByText('主');
expect(badge.className).toContain('bg-sky-300');
expect(badge.className).toContain('text-slate-950');
expect(badge.className).toContain('custom-slot');
});
test('supports soft medium step badge', () => {
render(
<PlatformSlotBadge tone="soft" size="md">
1
</PlatformSlotBadge>,
);
const badge = screen.getByText('1');
expect(badge.className).toContain('h-6');
expect(badge.className).toContain('min-w-6');
expect(badge.className).toContain('bg-white/82');
expect(badge.className).toContain('shadow-sm');
});

View File

@@ -0,0 +1,52 @@
import type { HTMLAttributes, ReactNode } from 'react';
type PlatformSlotBadgeTone = 'active' | 'inactive' | 'soft';
type PlatformSlotBadgeSize = 'sm' | 'md';
type PlatformSlotBadgeProps = Omit<
HTMLAttributes<HTMLSpanElement>,
'children'
> & {
children: ReactNode;
size?: PlatformSlotBadgeSize;
tone?: PlatformSlotBadgeTone;
};
const PLATFORM_SLOT_BADGE_TONE_CLASS: Record<PlatformSlotBadgeTone, string> = {
active: 'border-sky-100/70 bg-sky-300 text-slate-950',
inactive: 'border-zinc-400/70 bg-zinc-900 text-white',
soft: 'border-transparent bg-white/82 text-[var(--platform-text-strong)] shadow-sm',
};
const PLATFORM_SLOT_BADGE_SIZE_CLASS: Record<PlatformSlotBadgeSize, string> = {
sm: 'h-5 min-w-5 px-1 text-[10px]',
md: 'h-6 min-w-6 px-1.5 text-xs',
};
/**
* 平台紧凑槽位编号徽标。
* 统一承接角色槽、步骤槽等复合按钮内部的序号 / 主位标记。
*/
export function PlatformSlotBadge({
children,
size = 'sm',
tone = 'inactive',
className,
...spanProps
}: PlatformSlotBadgeProps) {
return (
<span
{...spanProps}
className={[
'flex items-center justify-center rounded-full border font-black leading-none',
PLATFORM_SLOT_BADGE_SIZE_CLASS[size],
PLATFORM_SLOT_BADGE_TONE_CLASS[tone],
className,
]
.filter(Boolean)
.join(' ')}
>
{children}
</span>
);
}

View File

@@ -0,0 +1,74 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { expect, test } from 'vitest';
import { PlatformStatGrid } from './PlatformStatGrid';
test('renders platform stat cards with value-first layout', () => {
render(
<PlatformStatGrid
items={[
{ label: '图案组', value: 35 },
{ label: '卡片', value: 95 },
{ label: '状态', value: 'ready' },
]}
/>,
);
const value = screen.getByText('35');
const label = screen.getByText('图案组');
const grid = value.closest('div')?.parentElement?.parentElement;
const card = value.parentElement;
expect(grid?.className).toContain('grid-cols-3');
expect(grid?.className).toContain('text-center');
expect(card?.className).toContain('bg-white/76');
expect(value.className).toContain('text-lg');
expect(label.className).toContain('tracking-[0.14em]');
});
test('supports label-first plain cards and responsive four-column grid', () => {
render(
<PlatformStatGrid
items={[
{ label: '需要消除', value: '12 次' },
{ label: '总物品数', value: '36 件' },
]}
columns="twoToFour"
order="labelFirst"
surface="plain"
itemClassName={(item) =>
item.label === '总物品数' ? 'total-item-card' : null
}
/>,
);
const label = screen.getByText('需要消除');
const value = screen.getByText('12 次');
const grid = label.closest('div')?.parentElement?.parentElement;
const totalCard = screen.getByText('总物品数').parentElement;
expect(grid?.className).toContain('grid-cols-2');
expect(grid?.className).toContain('sm:grid-cols-4');
expect(label.nextElementSibling).toBe(value);
expect(totalCard?.className).toContain('border');
expect(totalCard?.className).toContain('total-item-card');
});
test('supports compact stat chips without labels', () => {
render(
<PlatformStatGrid
items={[{ value: '6 个' }, { value: '可发布' }]}
columns="two"
density="compact"
/>,
);
const chip = screen.getByText('6 个');
const card = chip.parentElement;
expect(card?.className).toContain('py-2');
expect(chip.className).toContain('text-sm');
expect(screen.queryByText('状态')).toBeNull();
});

View File

@@ -0,0 +1,158 @@
import type { ReactNode } from 'react';
type PlatformStatGridColumns = 'two' | 'three' | 'four' | 'twoToFour';
type PlatformStatGridDensity = 'compact' | 'default';
type PlatformStatGridOrder = 'valueFirst' | 'labelFirst';
type PlatformStatGridSurface = 'soft' | 'plain';
type PlatformStatGridTextAlign = 'left' | 'center';
export type PlatformStatGridItem = {
label?: ReactNode;
value: ReactNode;
key?: string;
};
type PlatformStatGridProps = {
items: readonly PlatformStatGridItem[];
columns?: PlatformStatGridColumns;
density?: PlatformStatGridDensity;
order?: PlatformStatGridOrder;
surface?: PlatformStatGridSurface;
textAlign?: PlatformStatGridTextAlign;
className?: string;
itemClassName?:
| string
| ((item: PlatformStatGridItem, index: number) => string | null);
};
const PLATFORM_STAT_GRID_COLUMNS_CLASS: Record<
PlatformStatGridColumns,
string
> = {
two: 'grid-cols-2',
three: 'grid-cols-3',
four: 'grid-cols-4',
twoToFour: 'grid-cols-2 sm:grid-cols-4',
};
const PLATFORM_STAT_GRID_DENSITY_CLASS: Record<
PlatformStatGridDensity,
{ item: string; value: string; label: string }
> = {
compact: {
item: 'rounded-[1rem] px-2 py-2',
value: 'text-sm font-black',
label: 'mt-1 text-[0.68rem] font-bold tracking-[0.14em]',
},
default: {
item: 'rounded-[1rem] px-3 py-3',
value: 'text-lg font-black',
label: 'mt-1 text-[11px] font-bold tracking-[0.14em]',
},
};
const PLATFORM_STAT_GRID_SURFACE_CLASS: Record<
PlatformStatGridSurface,
string
> = {
soft: 'bg-white/76',
plain: 'border border-[var(--platform-subpanel-border)] bg-white/68',
};
const PLATFORM_STAT_GRID_TEXT_ALIGN_CLASS: Record<
PlatformStatGridTextAlign,
string
> = {
left: 'text-left',
center: 'text-center',
};
/**
* 平台统计小卡网格。
* 统一承接结果页里的“数值 / 标签”或轻量状态 chip 布局。
*/
export function PlatformStatGrid({
items,
columns = 'three',
density = 'default',
order = 'valueFirst',
surface = 'soft',
textAlign = 'center',
className,
itemClassName,
}: PlatformStatGridProps) {
const densityClass = PLATFORM_STAT_GRID_DENSITY_CLASS[density];
return (
<div
className={[
'grid gap-2',
PLATFORM_STAT_GRID_COLUMNS_CLASS[columns],
PLATFORM_STAT_GRID_TEXT_ALIGN_CLASS[textAlign],
className,
]
.filter(Boolean)
.join(' ')}
>
{items.map((item, index) => {
const extraClassName =
typeof itemClassName === 'function'
? itemClassName(item, index)
: itemClassName;
const key =
item.key ??
(typeof item.label === 'string'
? item.label
: typeof item.value === 'string'
? item.value
: index);
const valueNode = (
<div
className={[
densityClass.value,
'text-[var(--platform-text-strong)]',
].join(' ')}
>
{item.value}
</div>
);
const labelNode = item.label ? (
<div
className={[
densityClass.label,
'text-[var(--platform-text-soft)]',
].join(' ')}
>
{item.label}
</div>
) : null;
return (
<div
key={key}
className={[
densityClass.item,
PLATFORM_STAT_GRID_SURFACE_CLASS[surface],
extraClassName,
]
.filter(Boolean)
.join(' ')}
>
{order === 'labelFirst' ? (
<>
{labelNode}
{valueNode}
</>
) : (
<>
{valueNode}
{labelNode}
</>
)}
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,156 @@
/* @vitest-environment jsdom */
import { Waves } from 'lucide-react';
import { fireEvent, render, screen } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import { PlatformStatusDialog } from './PlatformStatusDialog';
test('renders result state with description and primary action', () => {
const onClose = vi.fn();
const onAction = vi.fn();
render(
<PlatformStatusDialog
status="success"
title="支付成功"
description="账户状态已刷新"
onClose={onClose}
action={{ label: '知道了', onClick: onAction }}
/>,
);
const dialog = screen.getByRole('dialog', { name: '支付成功' });
const overlay = dialog.parentElement as HTMLElement;
const badge = dialog.querySelector('.platform-icon-badge');
const action = screen.getByRole('button', { name: '知道了' });
const visibleDescription = dialog.querySelector(
'.mt-3.text-sm.font-semibold.leading-6.text-\\[var\\(--platform-text-soft\\)\\]',
);
expect(dialog).toBeTruthy();
expect(overlay.className).toContain('platform-theme--light');
expect(overlay.className).toContain('platform-profile-modal-overlay');
expect(visibleDescription?.textContent).toBe('账户状态已刷新');
expect(badge?.className).toContain('text-[var(--platform-success-text)]');
expect(action.className).toContain('platform-primary-button');
fireEvent.click(action);
expect(onAction).toHaveBeenCalledTimes(1);
});
test('supports blocking confirming state without close action', () => {
const onClose = vi.fn();
render(
<PlatformStatusDialog
status="confirming"
title="正在确认支付"
description="订单 A100 正在同步到账状态,请先停留在当前页面。"
onClose={onClose}
closeDisabled
zIndexClassName="z-[95]"
/>,
);
const dialog = screen.getByRole('dialog', { name: '正在确认支付' });
const overlay = dialog.parentElement as HTMLElement;
const spinner = dialog.querySelector('.platform-icon-badge svg');
expect(overlay.className).toContain('z-[95]');
expect(spinner?.getAttribute('class')).toContain('animate-spin');
expect(
screen.queryByRole('button', { name: '关闭' }) ||
screen.queryByRole('button', { name: '知道了' }),
).toBeNull();
fireEvent.click(overlay);
fireEvent.keyDown(window, { key: 'Escape' });
expect(onClose).not.toHaveBeenCalled();
});
test('supports custom badge icon label and action button styling', () => {
render(
<PlatformStatusDialog
status="error"
title="发布失败"
description="还缺少 16 个基础动作"
onClose={vi.fn()}
icon={<Waves className="h-4 w-4" />}
iconLabel="发布失败提示"
iconClassName="bg-[var(--platform-button-danger-fill)] text-[var(--platform-button-danger-text)]"
action={{
label: '知道了',
onClick: vi.fn(),
surface: 'platform',
className: 'border-slate-950 bg-slate-950 text-white',
}}
/>,
);
const badge = screen.getByLabelText('发布失败提示');
const action = screen.getByRole('button', { name: '知道了' });
expect(badge.className).toContain('bg-[var(--platform-button-danger-fill)]');
expect(badge.className).toContain(
'text-[var(--platform-button-danger-text)]',
);
expect(action.className).toContain('border-slate-950');
expect(action.className).toContain('bg-slate-950');
});
test('keeps default theme classes when callers customize the overlay', () => {
render(
<PlatformStatusDialog
status="error"
title="发布失败"
description="图片生成失败"
onClose={vi.fn()}
overlayClassName="bg-slate-950/58 custom-overlay"
action={{ label: '知道了', onClick: vi.fn(), surface: 'platform' }}
/>,
);
const dialog = screen.getByRole('dialog', { name: '发布失败' });
const overlay = dialog.parentElement as HTMLElement;
expect(overlay.className).toContain('platform-theme--light');
expect(overlay.className).toContain('platform-profile-modal-overlay');
expect(overlay.className).toContain('bg-slate-950/58');
expect(overlay.className).toContain('custom-overlay');
});
test('supports header notice layout with body content and close button', () => {
const onClose = vi.fn();
render(
<PlatformStatusDialog
status="error"
title="泥点不足"
description="当前表单不会丢失,关闭后可继续编辑或补足泥点再继续。"
onClose={onClose}
showHeader
showCloseButton
closeOnBackdrop
action={{ label: '知道了', onClick: onClose, surface: 'platform' }}
>
6 5
</PlatformStatusDialog>,
);
const dialog = screen.getByRole('dialog', { name: '泥点不足' });
expect(
dialog.querySelector('.mt-4.text-xl.font-black.text-\\[var\\(--platform-text-strong\\)\\]'),
).toBeNull();
expect(screen.getByText('本次需要 6 泥点,当前 5 泥点。')).toBeTruthy();
expect(
screen.getByText(
'当前表单不会丢失,关闭后可继续编辑或补足泥点再继续。',
),
).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '关闭' }));
expect(onClose).toHaveBeenCalledTimes(1);
});

View File

@@ -0,0 +1,201 @@
import type { ReactNode } from 'react';
import {
AlertCircle,
CheckCircle2,
Loader2,
XCircle,
} from 'lucide-react';
import { PlatformActionButton } from './PlatformActionButton';
import { PlatformIconBadge } from './PlatformIconBadge';
import { UnifiedModal } from './UnifiedModal';
import type {
PlatformActionButtonSize,
PlatformActionButtonSurface,
PlatformActionButtonTone,
} from './platformActionButtonModel';
export type PlatformStatusDialogStatus =
| 'success'
| 'cancel'
| 'error'
| 'loading'
| 'confirming';
type PlatformStatusDialogAction = {
label: string;
onClick: () => void;
disabled?: boolean;
tone?: PlatformActionButtonTone;
surface?: PlatformActionButtonSurface;
size?: PlatformActionButtonSize;
fullWidth?: boolean;
className?: string;
};
type PlatformStatusDialogProps = {
open?: boolean;
status: PlatformStatusDialogStatus;
title: string;
description?: ReactNode;
children?: ReactNode;
onClose: () => void;
action?: PlatformStatusDialogAction;
showHeader?: boolean;
showBodyTitle?: boolean;
showCloseButton?: boolean;
closeOnBackdrop?: boolean;
closeOnEscape?: boolean;
closeLabel?: string;
closeDisabled?: boolean;
zIndexClassName?: string;
overlayClassName?: string;
panelClassName?: string;
bodyClassName?: string;
iconClassName?: string;
icon?: ReactNode;
iconLabel?: string;
};
type PlatformStatusVisualConfig = {
icon: ReactNode;
iconClassName: string;
};
const DEFAULT_OVERLAY_CLASS =
'platform-theme platform-theme--light platform-profile-modal-overlay bg-slate-950/72 backdrop-blur-xl';
const DEFAULT_PANEL_CLASS =
'platform-remap-surface !max-w-sm rounded-[1.4rem]';
const DEFAULT_BODY_CLASS = 'px-5 pb-5 pt-6 text-center';
function joinClassNames(...classNames: Array<string | undefined>) {
return classNames.filter(Boolean).join(' ');
}
function getStatusVisualConfig(
status: PlatformStatusDialogStatus,
): PlatformStatusVisualConfig {
switch (status) {
case 'success':
return {
icon: <CheckCircle2 className="h-8 w-8" aria-hidden="true" />,
iconClassName: 'text-[var(--platform-success-text)]',
};
case 'cancel':
return {
icon: <XCircle className="h-8 w-8" aria-hidden="true" />,
iconClassName: 'text-[var(--platform-text-soft)]',
};
case 'error':
return {
icon: <AlertCircle className="h-8 w-8" aria-hidden="true" />,
iconClassName: 'text-[var(--platform-button-danger-text)]',
};
case 'loading':
case 'confirming':
return {
icon: (
<Loader2 className="h-8 w-8 animate-spin" aria-hidden="true" />
),
iconClassName: 'text-[var(--platform-accent)]',
};
}
}
/**
* 平台状态弹窗。
* 收口个人中心这类“状态图标 + 标题正文 + 可选主动作”的无头弹窗模式。
*/
export function PlatformStatusDialog({
open = true,
status,
title,
description,
children,
onClose,
action,
showHeader = false,
showBodyTitle,
showCloseButton = false,
closeOnBackdrop = false,
closeOnEscape = false,
closeLabel,
closeDisabled = false,
zIndexClassName = 'z-[90]',
overlayClassName,
panelClassName = DEFAULT_PANEL_CLASS,
bodyClassName = DEFAULT_BODY_CLASS,
iconClassName,
icon,
iconLabel,
}: PlatformStatusDialogProps) {
const visualConfig = getStatusVisualConfig(status);
const badgeIcon = icon ?? visualConfig.icon;
const shouldRenderBodyTitle = showBodyTitle ?? !showHeader;
return (
<UnifiedModal
open={open}
title={title}
onClose={onClose}
showHeader={showHeader}
showCloseButton={showCloseButton}
closeLabel={closeLabel}
closeDisabled={closeDisabled}
closeOnBackdrop={closeOnBackdrop}
closeOnEscape={closeOnEscape}
portal={false}
size="sm"
zIndexClassName={zIndexClassName}
overlayClassName={joinClassNames(
DEFAULT_OVERLAY_CLASS,
overlayClassName,
)}
panelClassName={panelClassName}
bodyClassName={bodyClassName}
>
<PlatformIconBadge
icon={badgeIcon}
label={iconLabel}
size="xl"
tone="neutral"
className={[
'mx-auto bg-white/10',
visualConfig.iconClassName,
iconClassName,
]
.filter(Boolean)
.join(' ')}
/>
{shouldRenderBodyTitle ? (
<div className="mt-4 text-xl font-black text-[var(--platform-text-strong)]">
{title}
</div>
) : null}
{children ? (
<div className="mt-3 text-sm font-semibold leading-6 text-[var(--platform-text-base)]">
{children}
</div>
) : null}
{description ? (
<div className="mt-3 text-sm font-semibold leading-6 text-[var(--platform-text-soft)]">
{description}
</div>
) : null}
{action ? (
<PlatformActionButton
fullWidth={action.fullWidth ?? true}
size={action.size ?? 'md'}
tone={action.tone}
surface={action.surface ?? 'profile'}
className={['mt-5', action.className].filter(Boolean).join(' ')}
onClick={action.onClick}
disabled={action.disabled}
>
{action.label}
</PlatformActionButton>
) : null}
</UnifiedModal>
);
}

View File

@@ -0,0 +1,150 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { expect, test } from 'vitest';
import { PlatformStatusMessage } from './PlatformStatusMessage';
test('renders platform error status with shared tone classes', () => {
render(
<PlatformStatusMessage tone="error" className="mt-4">
</PlatformStatusMessage>,
);
const message = screen.getByText('保存失败');
expect(message.className).toContain('platform-status-message');
expect(message.className).toContain('border-rose-200');
expect(message.className).toContain('bg-rose-50');
expect(message.className).toContain('mt-4');
});
test('renders success, info, warning, and neutral status tones', () => {
const { rerender } = render(
<PlatformStatusMessage tone="success"></PlatformStatusMessage>,
);
expect(screen.getByText('已保存').className).toContain('border-emerald-200');
rerender(<PlatformStatusMessage tone="info"></PlatformStatusMessage>);
expect(screen.getByText('处理中').className).toContain(
'border-[var(--platform-cool-border)]',
);
rerender(
<PlatformStatusMessage tone="warning"></PlatformStatusMessage>,
);
expect(screen.getByText('请确认').className).toContain('border-amber-200');
rerender(
<PlatformStatusMessage tone="neutral"></PlatformStatusMessage>,
);
expect(screen.getByText('暂无内容').className).toContain(
'border-[var(--platform-subpanel-border)]',
);
});
test('supports tinted surface and medium spacing for dark platform areas', () => {
render(
<PlatformStatusMessage tone="error" surface="tinted" size="md">
</PlatformStatusMessage>,
);
const message = screen.getByText('暗色区域错误');
expect(message.className).toContain('border-rose-400/20');
expect(message.className).toContain('bg-rose-500/10');
expect(message.className).toContain('px-4');
expect(message.className).toContain('leading-6');
});
test('supports platform surface tokens for result pages', () => {
const { rerender } = render(
<PlatformStatusMessage tone="warning" surface="platform">
</PlatformStatusMessage>,
);
expect(screen.getByText('发布阻断').className).toContain(
'border-[var(--platform-warm-border)]',
);
expect(screen.getByText('发布阻断').className).toContain(
'bg-[var(--platform-warm-bg)]',
);
rerender(
<PlatformStatusMessage tone="success" surface="platform">
</PlatformStatusMessage>,
);
expect(screen.getByText('已满足发布条件').className).toContain(
'border-[var(--platform-success-border)]',
);
});
test('supports profile surface tokens for account modals', () => {
const { rerender } = render(
<PlatformStatusMessage tone="error" surface="profile" size="xs">
</PlatformStatusMessage>,
);
expect(screen.getByText('充值失败').className).toContain(
'border-[var(--platform-button-danger-border)]',
);
expect(screen.getByText('充值失败').className).toContain(
'bg-[var(--platform-button-danger-fill)]',
);
expect(screen.getByText('充值失败').className).toContain('text-xs');
rerender(
<PlatformStatusMessage tone="success" surface="profile" size="xs">
</PlatformStatusMessage>,
);
expect(screen.getByText('已到账').className).toContain(
'border-[var(--platform-success-border)]',
);
expect(screen.getByText('已到账').className).toContain(
'text-[var(--platform-success-text)]',
);
});
test('supports editor dark surface for RPG panels', () => {
const { rerender } = render(
<PlatformStatusMessage tone="warning" surface="editorDark" size="xs">
QA
</PlatformStatusMessage>,
);
expect(screen.getByText('QA 提示').className).toContain(
'border-amber-300/15',
);
expect(screen.getByText('QA 提示').className).toContain('bg-amber-500/10');
expect(screen.getByText('QA 提示').className).toContain('text-amber-50/90');
rerender(
<PlatformStatusMessage tone="neutral" surface="editorDark" size="xs">
</PlatformStatusMessage>,
);
expect(screen.getByText('暗色中性').className).toContain('border-white/10');
expect(screen.getByText('暗色中性').className).toContain('bg-black/20');
});
test('supports platform remap surface wrapper class', () => {
render(
<PlatformStatusMessage tone="info" surface="platform" remapSurface>
</PlatformStatusMessage>,
);
expect(screen.getByText('正在处理').className).toContain(
'platform-remap-surface',
);
});

View File

@@ -0,0 +1,108 @@
import type { HTMLAttributes, ReactNode } from 'react';
type PlatformStatusTone = 'error' | 'success' | 'info' | 'warning' | 'neutral';
type PlatformStatusSurface =
| 'light'
| 'tinted'
| 'platform'
| 'profile'
| 'editorDark';
type PlatformStatusSize = 'xs' | 'sm' | 'md';
type PlatformStatusMessageProps = Omit<
HTMLAttributes<HTMLDivElement>,
'children'
> & {
tone: PlatformStatusTone;
surface?: PlatformStatusSurface;
size?: PlatformStatusSize;
remapSurface?: boolean;
children: ReactNode;
};
const PLATFORM_STATUS_MESSAGE_CLASS_BY_SURFACE_AND_TONE: Record<
PlatformStatusSurface,
Record<PlatformStatusTone, string>
> = {
light: {
error: 'border-rose-200 bg-rose-50 text-rose-700',
success: 'border-emerald-200 bg-emerald-50 text-emerald-700',
info: 'border-[var(--platform-cool-border)] bg-[var(--platform-cool-bg)] text-[var(--platform-cool-text)]',
warning: 'border-amber-200 bg-amber-50 text-amber-900',
neutral:
'border-[var(--platform-subpanel-border)] bg-white/68 text-[var(--platform-text-base)]',
},
tinted: {
error: 'border-rose-400/20 bg-rose-500/10 text-rose-700',
success: 'border-emerald-300/25 bg-emerald-500/12 text-emerald-700',
info: 'border-[var(--platform-cool-border)] bg-[var(--platform-cool-bg)] text-[var(--platform-cool-text)]',
warning: 'border-amber-300/30 bg-amber-500/12 text-amber-900',
neutral:
'border-[var(--platform-subpanel-border)] bg-white/20 text-[var(--platform-text-base)]',
},
platform: {
error:
'border-[var(--platform-button-danger-border)] bg-[var(--platform-button-danger-fill)] text-[var(--platform-button-danger-text)]',
success:
'border-[var(--platform-success-border)] bg-[var(--platform-success-bg)] text-[var(--platform-success-text)]',
info: 'border-[var(--platform-cool-border)] bg-[var(--platform-cool-bg)] text-[var(--platform-cool-text)]',
warning:
'border-[var(--platform-warm-border)] bg-[var(--platform-warm-bg)] text-[var(--platform-warm-text)]',
neutral:
'border-[var(--platform-subpanel-border)] bg-white/68 text-[var(--platform-text-base)]',
},
profile: {
error:
'border-[var(--platform-button-danger-border)] bg-[var(--platform-button-danger-fill)] text-[var(--platform-button-danger-text)]',
success:
'border-[var(--platform-success-border)] bg-[var(--platform-success-bg)] text-[var(--platform-success-text)]',
info: 'border-[var(--platform-cool-border)] bg-[var(--platform-cool-bg)] text-[var(--platform-cool-text)]',
warning: 'border-amber-300 bg-amber-500/12 text-amber-900',
neutral:
'border-[var(--platform-subpanel-border)] bg-white/68 text-[var(--platform-text-base)]',
},
editorDark: {
error: 'border-rose-300/15 bg-rose-500/10 text-rose-50/90',
success: 'border-emerald-300/15 bg-emerald-500/10 text-emerald-50/90',
info: 'border-sky-300/15 bg-sky-500/10 text-sky-50/90',
warning: 'border-amber-300/15 bg-amber-500/10 text-amber-50/90',
neutral: 'border-white/10 bg-black/20 text-zinc-300',
},
};
const PLATFORM_STATUS_MESSAGE_SIZE_CLASS: Record<PlatformStatusSize, string> = {
xs: 'px-3 py-2 text-xs',
sm: 'px-3 py-2 text-sm',
md: 'px-4 py-3 text-sm leading-6',
};
/**
* 平台通用状态提示条。
* 收口错误、成功和提示类状态的基础边框、底色和文字颜色。
*/
export function PlatformStatusMessage({
tone,
surface = 'light',
size = 'sm',
remapSurface = false,
children,
className,
...divProps
}: PlatformStatusMessageProps) {
return (
<div
{...divProps}
className={[
remapSurface ? 'platform-remap-surface' : null,
'platform-status-message rounded-xl border',
PLATFORM_STATUS_MESSAGE_SIZE_CLASS[size],
PLATFORM_STATUS_MESSAGE_CLASS_BY_SURFACE_AND_TONE[surface][tone],
className,
]
.filter(Boolean)
.join(' ')}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,231 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test, vi } from 'vitest';
import { PlatformSubpanel } from './PlatformSubpanel';
test('renders platform subpanel shell with section title', () => {
render(
<PlatformSubpanel title="作品信息">
<p></p>
</PlatformSubpanel>,
);
const panel = screen.getByText('作品信息').closest('section');
expect(panel?.className).toContain('platform-subpanel');
expect(panel?.className).toContain('rounded-[1.25rem]');
expect(panel?.className).toContain('p-4');
expect(screen.getByText('作品信息').className).toContain('tracking-[0.18em]');
});
test('supports actions, strong title and body class', () => {
render(
<PlatformSubpanel
as="div"
title="音频"
titleVariant="strong"
actions={<button type="button"></button>}
radius="lg"
padding="lg"
bodyClassName="mt-3 grid gap-2"
className="custom-panel"
>
<span></span>
</PlatformSubpanel>,
);
const panel = screen.getByText('音频').closest('div.custom-panel');
const body = screen.getByText('上传').parentElement;
expect(panel?.className).toContain('rounded-[1.35rem]');
expect(panel?.className).toContain('sm:p-5');
expect(screen.getByText('音频').className).toContain('font-black');
expect(screen.getByRole('button', { name: '重置' })).toBeTruthy();
expect(body?.className).toContain('mt-3 grid gap-2');
});
test('supports extra large platform subpanel radius', () => {
render(
<PlatformSubpanel radius="xl" padding="lg">
</PlatformSubpanel>,
);
const panel = screen.getByText('方洞面板').closest('section');
expect(panel?.className).toContain('rounded-[1.5rem]');
expect(panel?.className).toContain('sm:p-5');
});
test('passes static element attributes through to the panel shell', () => {
render(
<PlatformSubpanel as="aside" aria-label="预览面板" data-testid="preview">
</PlatformSubpanel>,
);
const panel = screen.getByTestId('preview');
expect(panel.tagName).toBe('ASIDE');
expect(panel.getAttribute('aria-label')).toBe('预览面板');
expect(panel.className).toContain('platform-subpanel');
});
test('supports flat compact subpanel cards', () => {
render(
<PlatformSubpanel
as="div"
surface="flat"
radius="sm"
padding="sm"
title="封面"
actions={<button type="button"></button>}
>
<span></span>
</PlatformSubpanel>,
);
const panel = screen.getByText('已绑定').closest('div');
expect(panel?.className.split(/\s+/u)).not.toContain('platform-subpanel');
expect(panel?.className).toContain('bg-white/72');
expect(panel?.className).toContain('rounded-[1rem]');
expect(panel?.className).toContain('p-3');
expect(screen.getByRole('button', { name: '选择' })).toBeTruthy();
});
test('supports dark compact subpanel cards', () => {
render(
<PlatformSubpanel as="div" surface="dark" radius="xs" padding="xs">
</PlatformSubpanel>,
);
const panel = screen.getByText('任务目标').closest('div');
expect(panel?.className).toContain('border-white/10');
expect(panel?.className).toContain('bg-black/25');
expect(panel?.className).toContain('rounded-xl');
expect(panel?.className).toContain('px-3');
expect(panel?.className).toContain('py-2.5');
});
test('supports tinted dark information panels', () => {
const { rerender } = render(
<PlatformSubpanel as="div" surface="darkSky" radius="lg" padding="md">
</PlatformSubpanel>,
);
let panel = screen.getByText('私聊').closest('div');
expect(panel?.className).toContain('border-sky-400/18');
expect(panel?.className).toContain('bg-sky-500/8');
expect(panel?.className).toContain('text-sky-50');
expect(panel?.className).toContain('rounded-[1.35rem]');
rerender(
<PlatformSubpanel as="div" surface="darkEmerald" radius="xs" padding="row">
</PlatformSubpanel>,
);
panel = screen.getByText('队友收束').closest('div');
expect(panel?.className).toContain('border-emerald-400/18');
expect(panel?.className).toContain('bg-emerald-500/8');
expect(panel?.className).toContain('text-emerald-100/85');
rerender(
<PlatformSubpanel as="div" surface="darkAmber" radius="xs" padding="sm">
</PlatformSubpanel>,
);
panel = screen.getByText('等级').closest('div');
expect(panel?.className).toContain('border-amber-300/18');
expect(panel?.className).toContain('bg-amber-500/8');
expect(panel?.className).toContain('text-amber-50');
rerender(
<PlatformSubpanel as="div" surface="darkRose" radius="xs" padding="xs">
</PlatformSubpanel>,
);
panel = screen.getByText('好感度').closest('div');
expect(panel?.className).toContain('border-rose-300/18');
expect(panel?.className).toContain('bg-rose-500/8');
expect(panel?.className).toContain('text-rose-50');
});
test('supports soft tight subpanel rows', () => {
const { rerender } = render(
<PlatformSubpanel as="div" surface="soft" radius="sm" padding="tight">
</PlatformSubpanel>,
);
let panel = screen.getByText('新增标签').closest('div');
expect(panel?.className).toContain('bg-white/68');
expect(panel?.className).toContain('rounded-[1rem]');
expect(panel?.className).toContain('p-2');
rerender(
<PlatformSubpanel as="div" surface="soft" radius="sm" padding="row">
</PlatformSubpanel>,
);
panel = screen.getByText('已选素材').closest('div');
expect(panel?.className).toContain('px-3');
expect(panel?.className).toContain('py-2');
});
test('supports danger subpanel surface', () => {
render(
<PlatformSubpanel surface="danger" padding="none">
</PlatformSubpanel>,
);
const panel = screen.getByText('已选卡片').closest('section');
expect(panel?.className.split(/\s+/u)).not.toContain('platform-subpanel');
expect(panel?.className).toContain(
'border-[var(--platform-button-danger-border)]',
);
expect(panel?.className).toContain('bg-[var(--platform-button-danger-fill)]');
expect(panel?.className).toContain('p-0');
});
test('supports interactive button subpanel cards', async () => {
const user = userEvent.setup();
const onClick = vi.fn();
render(
<PlatformSubpanel
as="button"
surface="flat"
radius="sm"
padding="sm"
interactive
onClick={onClick}
>
</PlatformSubpanel>,
);
const button = screen.getByRole('button', { name: '继续' });
expect(button.getAttribute('type')).toBe('button');
expect(button.className).toContain('hover:bg-white');
expect(button.className).toContain('disabled:cursor-not-allowed');
await user.click(button);
expect(onClick).toHaveBeenCalledTimes(1);
});

View File

@@ -0,0 +1,222 @@
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>
);
}

View File

@@ -0,0 +1,121 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import { PlatformTagEditor } from './PlatformTagEditor';
function parseTags(value: string) {
return value
.split(/[,\s]+/u)
.map((tag) => tag.trim())
.filter(Boolean);
}
test('renders tags and removes a tag', () => {
const onChange = vi.fn();
render(
<PlatformTagEditor
title="作品标签"
tags={['山海', '机关']}
parseInput={parseTags}
onChange={onChange}
/>,
);
const removeButton = screen.getByRole('button', { name: '删除标签 山海' });
expect(removeButton.className).toContain('platform-icon-button');
expect(removeButton.className).toContain('h-3.5');
expect(removeButton.className).toContain('w-3.5');
expect(removeButton.className).toContain('border-0');
expect(removeButton.className).toContain('bg-transparent');
expect(removeButton.className).toContain('p-0');
expect(removeButton.className).toContain('opacity-70');
expect(removeButton.getAttribute('title')).toBe('删除标签');
fireEvent.click(removeButton);
expect(onChange).toHaveBeenCalledWith(['机关']);
});
test('adds parsed tags with enter and trims duplicates', () => {
const onChange = vi.fn();
render(
<PlatformTagEditor
title="作品标签"
tags={['山海']}
parseInput={parseTags}
onChange={onChange}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '新增标签' }));
const tagInput = screen.getByLabelText('新题材标签');
const inputShell = tagInput.closest('div');
expect(inputShell?.className).toContain('bg-white/68');
expect(inputShell?.className).toContain('p-2');
expect(tagInput.className).toContain('bg-white/90');
expect(tagInput.className).toContain(
'focus:ring-[var(--platform-warm-border)]',
);
fireEvent.change(tagInput, {
target: { value: ' 山海,机关 ' },
});
fireEvent.keyDown(tagInput, { key: 'Enter' });
expect(onChange).toHaveBeenCalledWith(['山海', '机关']);
});
test('cancels adding with escape and hides add action at max tags', () => {
const onChange = vi.fn();
render(
<PlatformTagEditor
title="主题标签"
tags={['山海']}
maxTags={1}
parseInput={parseTags}
onChange={onChange}
/>,
);
expect(screen.queryByRole('button', { name: '新增标签' })).toBeNull();
expect(screen.queryByLabelText('新题材标签')).toBeNull();
render(
<PlatformTagEditor
title="主题标签"
tags={[]}
parseInput={parseTags}
onChange={onChange}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '新增标签' }));
fireEvent.keyDown(screen.getByLabelText('新题材标签'), { key: 'Escape' });
expect(screen.queryByLabelText('新题材标签')).toBeNull();
});
test('renders generate action, warm tone and error message', () => {
const onGenerate = vi.fn();
render(
<PlatformTagEditor
title="作品标签"
tags={['街机']}
parseInput={parseTags}
onChange={vi.fn()}
onGenerate={onGenerate}
generateLabel="AI生成作品标签"
tone="warm"
error="标签生成失败"
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'AI生成作品标签' }));
expect(onGenerate).toHaveBeenCalledTimes(1);
expect(screen.getByText('街机').className).toContain(
'bg-[var(--platform-warm-bg)]',
);
expect(screen.getByText('标签生成失败')).toBeTruthy();
});

View File

@@ -0,0 +1,217 @@
import { Loader2, Plus, Sparkles, X } from 'lucide-react';
import type { ReactNode } from 'react';
import { useState } from 'react';
import { PlatformActionButton } from './PlatformActionButton';
import { PlatformIconButton } from './PlatformIconButton';
import { PlatformStatusMessage } from './PlatformStatusMessage';
import { PlatformSubpanel } from './PlatformSubpanel';
import { PlatformTextField } from './PlatformTextField';
type PlatformTagEditorRadius = 'md' | 'lg';
type PlatformTagEditorPadding = 'md' | 'lg' | 'none';
type PlatformTagEditorTone = 'amber' | 'warm';
type PlatformTagEditorProps = {
title: ReactNode;
tags: readonly string[];
disabled?: boolean;
maxTags?: number;
error?: ReactNode;
addLabel?: string;
generateLabel?: string;
inputLabel?: string;
inputPlaceholder?: string;
emptyLabel?: ReactNode;
parseInput: (value: string) => string[];
onChange: (tags: string[]) => void;
onGenerate?: () => void;
generateIcon?: ReactNode;
radius?: PlatformTagEditorRadius;
padding?: PlatformTagEditorPadding;
tone?: PlatformTagEditorTone;
};
const PLATFORM_TAG_EDITOR_TAG_CLASS: Record<PlatformTagEditorTone, string> = {
amber:
'border-amber-300/35 bg-amber-100/68 text-amber-700 [&_button]:text-amber-800/70 [&_button:hover]:text-amber-950',
warm: 'border-[var(--platform-warm-border)] bg-[var(--platform-warm-bg)] text-[var(--platform-warm-text)] [&_button]:text-[var(--platform-warm-text)] [&_button:hover]:text-[var(--platform-text-strong)]',
};
function dedupeTags(tags: readonly string[]) {
return [...new Set(tags.map((tag) => tag.trim()).filter(Boolean))];
}
/**
* 平台标签编辑器。
* 统一承接结果页里的标签 chip、删除、新增输入和可选 AI 生成动作。
*/
export function PlatformTagEditor({
title,
tags,
disabled = false,
maxTags,
error,
addLabel = '新增标签',
generateLabel,
inputLabel = '新题材标签',
inputPlaceholder = '输入新标签',
emptyLabel = '暂无标签',
parseInput,
onChange,
onGenerate,
generateIcon,
radius = 'md',
padding = 'md',
tone = 'amber',
}: PlatformTagEditorProps) {
const [newTagText, setNewTagText] = useState('');
const [isAddingTag, setIsAddingTag] = useState(false);
const normalizedTags = dedupeTags(tags);
const canAddMoreTags =
typeof maxTags === 'number' ? normalizedTags.length < maxTags : true;
const addTags = () => {
const nextTags = dedupeTags([...normalizedTags, ...parseInput(newTagText)]);
onChange(
typeof maxTags === 'number' ? nextTags.slice(0, maxTags) : nextTags,
);
setNewTagText('');
setIsAddingTag(false);
};
const cancelAddingTag = () => {
setNewTagText('');
setIsAddingTag(false);
};
return (
<PlatformSubpanel
title={title}
radius={radius}
padding={padding}
actions={
<>
{onGenerate && generateLabel ? (
<PlatformIconButton
disabled={disabled}
onClick={onGenerate}
label={generateLabel}
title={generateLabel}
icon={
disabled ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
(generateIcon ?? <Sparkles className="h-4 w-4" />)
)
}
className="h-9 w-9"
/>
) : null}
{!isAddingTag && canAddMoreTags ? (
<PlatformIconButton
disabled={disabled}
onClick={() => setIsAddingTag(true)}
label={addLabel}
title={addLabel}
icon={<Plus className="h-4 w-4" />}
className="h-9 w-9"
/>
) : null}
</>
}
>
<div className="mt-3 flex flex-wrap gap-2">
{normalizedTags.map((tag) => (
<span
key={tag}
className={[
'inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-semibold',
PLATFORM_TAG_EDITOR_TAG_CLASS[tone],
].join(' ')}
>
{tag}
<PlatformIconButton
disabled={disabled}
label={`删除标签 ${tag}`}
title="删除标签"
onClick={() =>
onChange(
normalizedTags.filter((currentTag) => currentTag !== tag),
)
}
className="h-3.5 w-3.5 border-0 bg-transparent p-0 opacity-70 shadow-none transition hover:translate-y-0 hover:bg-transparent disabled:opacity-45"
icon={<X className="h-3.5 w-3.5" />}
/>
</span>
))}
{normalizedTags.length <= 0 ? (
<span className="text-sm text-[var(--platform-text-soft)]">
{emptyLabel}
</span>
) : null}
</div>
{isAddingTag ? (
<PlatformSubpanel
as="div"
surface="soft"
radius="sm"
padding="tight"
className="mt-3 flex flex-col gap-2 sm:flex-row"
>
<PlatformTextField
autoFocus
value={newTagText}
disabled={disabled}
density="compact"
size="xs"
onChange={(event) => setNewTagText(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
addTags();
}
if (event.key === 'Escape') {
cancelAddingTag();
}
}}
className="min-h-10 flex-1"
placeholder={inputPlaceholder}
aria-label={inputLabel}
/>
<div className="flex gap-2">
<PlatformActionButton
disabled={disabled}
onClick={addTags}
size="xs"
className="min-h-10 flex-1 sm:flex-none"
>
</PlatformActionButton>
<PlatformActionButton
disabled={disabled}
onClick={cancelAddingTag}
tone="ghost"
size="xs"
className="min-h-10 flex-1 sm:flex-none"
>
</PlatformActionButton>
</div>
</PlatformSubpanel>
) : null}
{error ? (
<PlatformStatusMessage
tone="error"
surface="platform"
size="md"
className="mt-3"
>
{error}
</PlatformStatusMessage>
) : null}
</PlatformSubpanel>
);
}

View File

@@ -0,0 +1,198 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import { PlatformSelectField, PlatformTextField } from './PlatformTextField';
test('renders shared platform input chrome', () => {
const onChange = vi.fn();
render(
<PlatformTextField
aria-label="作品名称"
value="水果抓大鹅"
onChange={onChange}
size="lg"
/>,
);
const input = screen.getByLabelText('作品名称');
fireEvent.change(input, { target: { value: '水果乐园' } });
expect(input.tagName).toBe('INPUT');
expect(input.className).toContain('bg-white/86');
expect(input.className).toContain('text-base');
expect(input.className).toContain(
'focus:border-[var(--platform-surface-hover-border)]',
);
expect(input.className).toContain('focus:ring-2');
expect(input.className).toContain('focus:ring-[var(--platform-warm-border)]');
expect(onChange).toHaveBeenCalledTimes(1);
});
test('renders shared platform textarea chrome', () => {
render(
<PlatformTextField
variant="textarea"
aria-label="作品描述"
value="简介"
rows={5}
size="md"
onChange={vi.fn()}
/>,
);
const textarea = screen.getByLabelText('作品描述');
expect(textarea.tagName).toBe('TEXTAREA');
expect(textarea.getAttribute('rows')).toBe('5');
expect(textarea.className).toContain('resize-none');
expect(textarea.className).toContain('leading-6');
});
test('keeps local classes and disabled state', () => {
render(
<PlatformTextField
aria-label="物品名称"
value=""
disabled
className="mt-2"
onChange={vi.fn()}
/>,
);
const input = screen.getByLabelText('物品名称');
expect(input).toHaveProperty('disabled', true);
expect(input.className).toContain('mt-2');
expect(input.className).toContain('disabled:cursor-not-allowed');
});
test('renders compact field chrome', () => {
render(
<PlatformTextField
aria-label="形状名称"
value="方块"
density="compact"
onChange={vi.fn()}
/>,
);
const input = screen.getByLabelText('形状名称');
expect(input.className).toContain('rounded-[0.85rem]');
expect(input.className).toContain('bg-white/90');
expect(input.className).toContain('py-2');
});
test('renders roomy textarea chrome', () => {
render(
<PlatformTextField
variant="textarea"
aria-label="作品简介"
value="简介"
rows={4}
size="md"
density="roomy"
onChange={vi.fn()}
/>,
);
const textarea = screen.getByLabelText('作品简介');
expect(textarea.className).toContain('bg-white/90');
expect(textarea.className).toContain('px-4');
expect(textarea.className).toContain('py-3');
expect(textarea.className).toContain('leading-6');
});
test('renders focused field tone variants', () => {
render(
<>
<PlatformTextField
aria-label="主题"
value=""
tone="rose"
onChange={vi.fn()}
/>
<PlatformTextField
aria-label="物品"
value=""
tone="emerald"
onChange={vi.fn()}
/>
</>,
);
expect(screen.getByLabelText('主题').className).toContain(
'focus:border-rose-200 focus:ring-rose-100',
);
expect(screen.getByLabelText('物品').className).toContain(
'focus:border-emerald-200 focus:ring-emerald-100',
);
});
test('renders shared platform select chrome', () => {
render(
<PlatformSelectField
aria-label="目标洞口"
value="hole-1"
density="compact"
onChange={vi.fn()}
>
<option value="hole-1"> 1</option>
</PlatformSelectField>,
);
const select = screen.getByLabelText('目标洞口');
expect(select.tagName).toBe('SELECT');
expect(select.className).toContain('rounded-[0.85rem]');
expect(select.className).toContain('text-sm');
});
test('renders editor dark input textarea and select chrome', () => {
render(
<>
<PlatformTextField
aria-label="角色名字"
value="沈行"
surface="editorDark"
tone="emerald"
density="roomy"
onChange={vi.fn()}
/>
<PlatformTextField
variant="textarea"
aria-label="聊天草稿"
value="问问线索"
surface="editorDark"
tone="sky"
rows={4}
onChange={vi.fn()}
/>
<PlatformSelectField
aria-label="生成模式"
value="fast"
surface="editorDark"
tone="sky"
onChange={vi.fn()}
>
<option value="fast"></option>
</PlatformSelectField>
</>,
);
const input = screen.getByLabelText('角色名字');
const textarea = screen.getByLabelText('聊天草稿');
const select = screen.getByLabelText('生成模式');
expect(input.className).toContain('platform-text-field--editor-dark');
expect(input.className).toContain('bg-black/30');
expect(input.className).toContain('focus:border-emerald-400/40');
expect(textarea.className).toContain('resize-none');
expect(textarea.className).toContain('focus:border-sky-400/40');
expect(select.className).toContain('platform-text-field--editor-dark');
expect(select.className).toContain('focus:border-sky-400/40');
});

View File

@@ -0,0 +1,184 @@
import type {
InputHTMLAttributes,
SelectHTMLAttributes,
TextareaHTMLAttributes,
} from 'react';
type PlatformTextFieldSize = 'xs' | 'sm' | 'md' | 'lg';
type PlatformTextFieldDensity = 'default' | 'compact' | 'roomy';
type PlatformTextFieldTone = 'warm' | 'rose' | 'emerald' | 'sky';
type PlatformTextFieldSurface = 'platform' | 'editorDark';
type PlatformTextFieldBaseProps = {
size?: PlatformTextFieldSize;
density?: PlatformTextFieldDensity;
tone?: PlatformTextFieldTone;
surface?: PlatformTextFieldSurface;
};
type PlatformTextFieldInputProps = Omit<
InputHTMLAttributes<HTMLInputElement>,
'size'
> &
PlatformTextFieldBaseProps & {
variant?: 'input';
};
type PlatformTextFieldTextareaProps =
TextareaHTMLAttributes<HTMLTextAreaElement> &
PlatformTextFieldBaseProps & {
variant: 'textarea';
};
type PlatformTextFieldProps =
| PlatformTextFieldInputProps
| PlatformTextFieldTextareaProps;
type PlatformSelectFieldProps = Omit<
SelectHTMLAttributes<HTMLSelectElement>,
'size'
> &
PlatformTextFieldBaseProps;
const PLATFORM_TEXT_FIELD_SIZE_CLASS: Record<PlatformTextFieldSize, string> = {
xs: 'text-sm leading-5',
sm: 'text-sm font-semibold',
md: 'text-sm leading-6',
lg: 'text-base font-semibold',
};
const PLATFORM_TEXT_FIELD_PLATFORM_DENSITY_CLASS: Record<
PlatformTextFieldDensity,
string
> = {
default: 'rounded-[1rem] bg-white/86 px-3 py-3',
compact: 'rounded-[0.85rem] bg-white/90 px-3 py-2',
roomy: 'rounded-[1rem] bg-white/90 px-4 py-3',
};
const PLATFORM_TEXT_FIELD_EDITOR_DARK_DENSITY_CLASS: Record<
PlatformTextFieldDensity,
string
> = {
default: 'rounded-[1rem] bg-black/30 px-3 py-3',
compact: 'rounded-[0.85rem] bg-black/30 px-3 py-2',
roomy: 'rounded-[1rem] bg-black/30 px-4 py-3',
};
const PLATFORM_TEXT_FIELD_PLATFORM_TONE_CLASS: Record<
PlatformTextFieldTone,
string
> = {
warm: 'focus:border-[var(--platform-surface-hover-border)] focus:ring-[var(--platform-warm-border)]',
rose: 'focus:border-rose-200 focus:ring-rose-100',
emerald: 'focus:border-emerald-200 focus:ring-emerald-100',
sky: 'focus:border-sky-200 focus:ring-sky-100',
};
const PLATFORM_TEXT_FIELD_EDITOR_DARK_TONE_CLASS: Record<
PlatformTextFieldTone,
string
> = {
warm: 'focus:border-amber-300/40',
rose: 'focus:border-rose-300/40',
emerald: 'focus:border-emerald-400/40',
sky: 'focus:border-sky-400/40',
};
function buildPlatformTextFieldClassName({
surface,
size,
density,
tone,
multiline,
className,
}: {
surface: PlatformTextFieldSurface;
size: PlatformTextFieldSize;
density: PlatformTextFieldDensity;
tone: PlatformTextFieldTone;
multiline?: boolean;
className?: string;
}) {
const isEditorDark = surface === 'editorDark';
return [
'w-full platform-text-field outline-none transition disabled:cursor-not-allowed disabled:opacity-60',
isEditorDark
? 'platform-text-field--editor-dark border border-white/10 text-white'
: 'border border-[var(--platform-subpanel-border)] text-[var(--platform-text-strong)] focus:bg-white focus:ring-2',
isEditorDark
? PLATFORM_TEXT_FIELD_EDITOR_DARK_DENSITY_CLASS[density]
: PLATFORM_TEXT_FIELD_PLATFORM_DENSITY_CLASS[density],
PLATFORM_TEXT_FIELD_SIZE_CLASS[size],
isEditorDark
? PLATFORM_TEXT_FIELD_EDITOR_DARK_TONE_CLASS[tone]
: PLATFORM_TEXT_FIELD_PLATFORM_TONE_CLASS[tone],
multiline ? 'resize-none' : null,
className,
]
.filter(Boolean)
.join(' ');
}
/**
* 平台白底表单输入框。
* 统一承接结果页 / 工作台白底圆角输入框和文本域的基础 chrome。
*/
export function PlatformTextField({
variant = 'input',
size = 'sm',
density = 'default',
tone = 'warm',
surface = 'platform',
className,
...fieldProps
}: PlatformTextFieldProps) {
const fieldClassName = buildPlatformTextFieldClassName({
surface,
size,
density,
tone,
multiline: variant === 'textarea',
className,
});
if (variant === 'textarea') {
return (
<textarea
{...(fieldProps as TextareaHTMLAttributes<HTMLTextAreaElement>)}
className={fieldClassName}
/>
);
}
return (
<input
{...(fieldProps as InputHTMLAttributes<HTMLInputElement>)}
className={fieldClassName}
/>
);
}
/**
* 平台白底下拉框。
* 与 PlatformTextField 共享输入 chrome避免业务页为同一组表单控件重复拼样式。
*/
export function PlatformSelectField({
size = 'sm',
density = 'default',
tone = 'warm',
surface = 'platform',
className,
...selectProps
}: PlatformSelectFieldProps) {
const fieldClassName = buildPlatformTextFieldClassName({
surface,
size,
density,
tone,
className,
});
return <select {...selectProps} className={fieldClassName} />;
}

View File

@@ -0,0 +1,88 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import { PlatformToggleRow } from './PlatformToggleRow';
test('renders checkbox toggle row and reports checked value', () => {
const onChange = vi.fn();
render(
<PlatformToggleRow label="自由输入" checked={false} onChange={onChange} />,
);
const checkbox = screen.getByRole('checkbox', { name: '自由输入' });
fireEvent.click(checkbox);
expect(onChange).toHaveBeenCalledWith(true);
expect(screen.getByText('自由输入').closest('label')?.className).toContain(
'bg-white/74',
);
});
test('renders disabled checkbox row', () => {
render(<PlatformToggleRow label="玩家可见" checked disabled />);
const checkbox = screen.getByRole('checkbox', { name: '玩家可见' });
expect(checkbox).toHaveProperty('disabled', true);
expect(screen.getByText('玩家可见').closest('label')?.className).toContain(
'opacity-60',
);
});
test('renders status row without checkbox', () => {
render(
<PlatformToggleRow
label="文本模式"
checked
mode="status"
surface="plain"
/>,
);
expect(screen.queryByRole('checkbox')).toBeNull();
expect(screen.getByText('开启').className).toContain(
'bg-[var(--platform-neutral-bg)]',
);
expect(screen.getByText('文本模式').closest('div')?.className).toContain(
'bg-white/78',
);
});
test('renders clickable status row as button', () => {
const onClick = vi.fn();
render(
<PlatformToggleRow
label="文本模式"
checked={false}
mode="status"
onClick={onClick}
/>,
);
fireEvent.click(screen.getByRole('button', { name: //u }));
expect(onClick).toHaveBeenCalledTimes(1);
expect(screen.getByText('关闭')).toBeTruthy();
});
test('keeps icon and local classes', () => {
render(
<PlatformToggleRow
label="文本模式"
checked
icon={<span data-testid="toggle-icon" />}
className="custom-row"
labelClassName="custom-label"
/>,
);
expect(screen.getByTestId('toggle-icon')).toBeTruthy();
expect(screen.getByText('文本模式').closest('label')?.className).toContain(
'custom-row',
);
expect(screen.getByText('文本模式').parentElement?.className).toContain(
'custom-label',
);
});

View File

@@ -0,0 +1,134 @@
import type { ChangeEvent, ReactNode } from 'react';
import { getPlatformPillBadgeClassName } from './platformPillBadgeModel';
type PlatformToggleRowSurface = 'soft' | 'plain';
type PlatformToggleRowMode = 'checkbox' | 'status';
type PlatformToggleRowProps = {
label: ReactNode;
checked: boolean;
onChange?: (checked: boolean) => void;
disabled?: boolean;
mode?: PlatformToggleRowMode;
icon?: ReactNode;
onLabel?: ReactNode;
offLabel?: ReactNode;
onClick?: () => void;
className?: string;
labelClassName?: string;
surface?: PlatformToggleRowSurface;
};
const PLATFORM_TOGGLE_ROW_SURFACE_CLASS: Record<
PlatformToggleRowSurface,
string
> = {
soft: 'bg-white/74',
plain: 'bg-white/78',
};
function renderToggleStatus({
checked,
offLabel,
onLabel,
}: {
checked: boolean;
offLabel: ReactNode;
onLabel: ReactNode;
}) {
return (
<span
className={getPlatformPillBadgeClassName({
tone: 'neutralSolid',
size: 'sm',
})}
>
{checked ? onLabel : offLabel}
</span>
);
}
/**
* 平台整行开关。
* 统一承接设置面板和结果页配置里的白底 label + checkbox / 状态行。
*/
export function PlatformToggleRow({
label,
checked,
onChange,
disabled = false,
mode = 'checkbox',
icon,
onLabel = '开启',
offLabel = '关闭',
onClick,
className,
labelClassName,
surface = 'soft',
}: PlatformToggleRowProps) {
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
onChange?.(event.target.checked);
};
const rowClassName = [
'flex min-h-12 items-center justify-between gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] px-3',
PLATFORM_TOGGLE_ROW_SURFACE_CLASS[surface],
disabled ? 'opacity-60' : null,
className,
]
.filter(Boolean)
.join(' ');
const labelNode = (
<span
className={[
'flex min-w-0 items-center gap-2 text-sm font-semibold text-[var(--platform-text-strong)]',
labelClassName,
]
.filter(Boolean)
.join(' ')}
>
{icon ? <span className="shrink-0">{icon}</span> : null}
<span>{label}</span>
</span>
);
if (mode === 'status') {
if (onClick) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={[
rowClassName,
'w-full text-left transition hover:bg-white disabled:cursor-not-allowed',
].join(' ')}
>
{labelNode}
{renderToggleStatus({ checked, onLabel, offLabel })}
</button>
);
}
return (
<div className={rowClassName}>
{labelNode}
{renderToggleStatus({ checked, onLabel, offLabel })}
</div>
);
}
return (
<label className={rowClassName}>
{labelNode}
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={handleChange}
className="h-4 w-4 rounded border-[var(--platform-subpanel-border)]"
/>
</label>
);
}

View File

@@ -0,0 +1,54 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, within } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import { PlatformToolModalShell } from './PlatformToolModalShell';
vi.mock('../auth/AuthUiContext', () => ({
useAuthUi: () => ({ platformTheme: 'light' }),
}));
test('renders shared platform tool modal shell with remapped panel chrome', () => {
render(
<PlatformToolModalShell
open
title="发布拼图作品"
onClose={() => {}}
footer={<button type="button"></button>}
panelClassName="!max-h-[min(90vh,42rem)]"
>
<div></div>
</PlatformToolModalShell>,
);
const dialog = screen.getByRole('dialog', { name: '发布拼图作品' });
expect(dialog.parentElement?.className).toContain('platform-theme--light');
expect(dialog.className).toContain('platform-remap-surface');
expect(dialog.className).toContain('shadow-[0_24px_80px_rgba(0,0,0,0.55)]');
expect(dialog.className).toContain('!max-h-[min(90vh,42rem)]');
expect(within(dialog).getByText('这里是正文')).toBeTruthy();
expect(within(dialog).getByRole('button', { name: '取消' })).toBeTruthy();
});
test('supports fixed aria label while keeping visible title text', () => {
const onClose = vi.fn();
render(
<PlatformToolModalShell
open
title="雨夜猫街"
ariaLabel="关卡详情"
onClose={onClose}
>
<div></div>
</PlatformToolModalShell>,
);
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
expect(within(dialog).getByText('雨夜猫街')).toBeTruthy();
expect(screen.getByRole('button', { name: '关闭关卡详情' })).toBeTruthy();
fireEvent.click(dialog.parentElement as HTMLElement);
expect(onClose).toHaveBeenCalledTimes(1);
});

View File

@@ -0,0 +1,90 @@
import type { ReactNode } from 'react';
import { useAuthUi } from '../auth/AuthUiContext';
import { UnifiedModal } from './UnifiedModal';
type PlatformToolModalShellProps = {
open: boolean;
title: string;
ariaLabel?: string;
description?: ReactNode;
children: ReactNode;
footer?: ReactNode;
onClose: () => void;
size?: 'sm' | 'md' | 'lg' | 'xl' | 'fullscreen';
closeLabel?: string;
closeDisabled?: boolean;
closeOnBackdrop?: boolean;
closeOnEscape?: boolean;
zIndexClassName?: string;
panelClassName?: string;
titleClassName?: string;
bodyClassName?: string;
footerClassName?: string;
};
function joinClassNames(...classNames: Array<string | false | null | undefined>) {
return classNames.filter(Boolean).join(' ');
}
/**
* 结果页 / 工具页里的白底 portal 弹窗壳。
* 这里只收口平台主题 overlay、白底 panel 和标准 header/body/footer 节奏,不吸收各玩法正文与动作语义。
*/
export function PlatformToolModalShell({
open,
title,
ariaLabel,
description,
children,
footer,
onClose,
size = 'xl',
closeLabel,
closeDisabled = false,
closeOnBackdrop = true,
closeOnEscape = true,
zIndexClassName = 'z-[140]',
panelClassName,
titleClassName,
bodyClassName,
footerClassName,
}: PlatformToolModalShellProps) {
const resolvedPlatformTheme =
useAuthUi()?.platformTheme ?? 'light';
return (
<UnifiedModal
open={open}
title={title}
// 某些工具弹窗标题会直接显示当前关卡/物品名,但读屏和测试更适合使用稳定的弹窗语义名。
ariaLabel={ariaLabel}
description={description}
onClose={onClose}
footer={footer}
size={size}
closeLabel={closeLabel ?? `关闭${ariaLabel ?? title}`}
closeDisabled={closeDisabled}
closeOnBackdrop={closeOnBackdrop}
closeOnEscape={closeOnEscape}
zIndexClassName={zIndexClassName}
overlayClassName={`platform-theme platform-theme--${resolvedPlatformTheme}`}
panelClassName={joinClassNames(
'platform-remap-surface shadow-[0_24px_80px_rgba(0,0,0,0.55)]',
panelClassName,
)}
headerClassName="!items-center !px-5 !py-4"
titleClassName={titleClassName}
bodyClassName={joinClassNames(
'!px-5 !py-4 sm:!px-5 sm:!py-4',
bodyClassName,
)}
footerClassName={joinClassNames(
'!px-5 !py-4 sm:!px-5 sm:!py-4',
footerClassName,
)}
>
{children}
</UnifiedModal>
);
}

View File

@@ -0,0 +1,53 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, within } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import { PlatformUnsavedLeaveConfirmDialog } from './PlatformUnsavedLeaveConfirmDialog';
test('renders the platform variant with leave/continue actions', () => {
const onClose = vi.fn();
const onConfirm = vi.fn();
render(
<PlatformUnsavedLeaveConfirmDialog
open
title="确认关闭"
onClose={onClose}
onConfirm={onConfirm}
>
</PlatformUnsavedLeaveConfirmDialog>,
);
const dialog = screen.getByRole('dialog', { name: '确认关闭' });
expect(within(dialog).getByText('当前修改尚未保存,确认关闭吗?')).toBeTruthy();
fireEvent.click(within(dialog).getByRole('button', { name: '继续编辑' }));
expect(onClose).toHaveBeenCalledTimes(1);
fireEvent.click(within(dialog).getByRole('button', { name: '确认关闭' }));
expect(onConfirm).toHaveBeenCalledTimes(1);
});
test('supports pixel variant with custom confirm label', () => {
render(
<PlatformUnsavedLeaveConfirmDialog
open
title="确认退出"
confirmLabel="仍然退出"
variant="pixel"
onClose={() => {}}
onConfirm={() => {}}
>
</PlatformUnsavedLeaveConfirmDialog>,
);
const dialog = screen.getByRole('dialog', { name: '确认退出' });
expect(dialog.className).toContain('pixel-modal-shell');
expect(within(dialog).getByRole('button', { name: '仍然退出' })).toBeTruthy();
expect(within(dialog).getByRole('button', { name: '继续编辑' })).toBeTruthy();
});

View File

@@ -0,0 +1,92 @@
import type { ReactNode } from 'react';
import { UnifiedConfirmDialog } from './UnifiedConfirmDialog';
type PlatformUnsavedLeaveConfirmDialogProps = {
open: boolean;
title?: string;
children?: ReactNode;
onClose: () => void;
onConfirm: () => void;
confirmLabel?: string;
cancelLabel?: string;
showCloseButton?: boolean;
closeOnBackdrop?: boolean;
portal?: boolean;
size?: 'sm' | 'md';
variant?: 'platform' | 'pixel';
overlayClassName?: string;
panelClassName?: string;
};
function resolveUnsavedLeaveOverlayClassName(
variant: 'platform' | 'pixel',
overlayClassName?: string,
) {
if (overlayClassName) {
return overlayClassName;
}
return variant === 'pixel' ? 'z-[140]' : 'z-[140] !items-center';
}
function resolveUnsavedLeavePanelClassName(
variant: 'platform' | 'pixel',
panelClassName?: string,
) {
if (panelClassName) {
return panelClassName;
}
return variant === 'platform'
? 'platform-remap-surface rounded-[1.5rem]'
: undefined;
}
/**
* 平台未保存离开确认弹窗。
* 统一承接关闭 / 退出前的“继续编辑 + 确认离开”语义和默认平台壳层。
*/
export function PlatformUnsavedLeaveConfirmDialog({
open,
title = '确认退出',
children,
onClose,
onConfirm,
confirmLabel,
cancelLabel = '继续编辑',
showCloseButton = true,
closeOnBackdrop = true,
portal = true,
size = 'sm',
variant = 'platform',
overlayClassName,
panelClassName,
}: PlatformUnsavedLeaveConfirmDialogProps) {
return (
<UnifiedConfirmDialog
open={open}
title={title}
onClose={onClose}
onConfirm={onConfirm}
confirmLabel={confirmLabel ?? title}
cancelLabel={cancelLabel}
showCloseButton={showCloseButton}
closeOnBackdrop={closeOnBackdrop}
showCancel
portal={portal}
size={size}
variant={variant}
overlayClassName={resolveUnsavedLeaveOverlayClassName(
variant,
overlayClassName,
)}
panelClassName={resolveUnsavedLeavePanelClassName(
variant,
panelClassName,
)}
>
{children}
</UnifiedConfirmDialog>
);
}

View File

@@ -0,0 +1,225 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import { PlatformUploadPreviewCard } from './PlatformUploadPreviewCard';
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: ({
src,
alt,
className,
}: {
src?: string | null;
alt?: string;
className?: string;
}) => <img src={src ?? ''} alt={alt} className={className} />,
}));
test('renders uploaded image preview with shared chrome', () => {
render(
<PlatformUploadPreviewCard
imageSrc="data:image/png;base64,abc"
imageAlt="反馈凭证预览"
removeLabel="移除上传凭证"
onRemove={vi.fn()}
/>,
);
const image = screen.getByRole('img', { name: '反馈凭证预览' });
const removeButton = screen.getByRole('button', { name: '移除上传凭证' });
expect(image.getAttribute('src')).toBe('data:image/png;base64,abc');
expect(image.className).toContain('object-cover');
expect(removeButton.getAttribute('type')).toBe('button');
expect(removeButton.className).toContain('bg-black/55');
});
test('calls remove handler from preview action', () => {
const onRemove = vi.fn();
render(
<PlatformUploadPreviewCard
imageSrc="/preview.png"
imageAlt="上传图片预览"
removeLabel="移除图片"
onRemove={onRemove}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '移除图片' }));
expect(onRemove).toHaveBeenCalledTimes(1);
});
test('supports preview action with resolved asset image', () => {
const onPreview = vi.fn();
render(
<PlatformUploadPreviewCard
imageSrc="/generated/reference.png"
imageAlt="参考图"
previewLabel="预览参考图 1"
removeLabel="移除参考图"
onPreview={onPreview}
resolveAsset
/>,
);
const previewButton = screen.getByRole('button', { name: '预览参考图 1' });
fireEvent.click(previewButton);
expect(screen.getByRole('img', { name: '参考图' }).getAttribute('src')).toBe(
'/generated/reference.png',
);
expect(onPreview).toHaveBeenCalledTimes(1);
});
test('can render preview-only cards without remove action', () => {
render(
<PlatformUploadPreviewCard
imageSrc="/preview.png"
imageAlt="上传图片预览"
removeLabel="移除图片"
/>,
);
expect(screen.getByRole('img', { name: '上传图片预览' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '移除图片' })).toBeNull();
});
test('supports captioned preview cards for reference images', () => {
const onPreview = vi.fn();
render(
<PlatformUploadPreviewCard
imageSrc="/reference.png"
imageAlt=""
caption="草莓"
previewLabel="预览参考图 草莓"
removeLabel="移除参考图 草莓"
onPreview={onPreview}
onRemove={vi.fn()}
className="w-full rounded-[1rem]"
imageShellClassName="ring-1"
captionClassName="pr-9"
/>,
);
const previewButton = screen.getByRole('button', {
name: '预览参考图 草莓',
});
fireEvent.click(previewButton);
const previewImage = previewButton.querySelector('img');
expect(screen.getByText('草莓').className).toContain('pr-9');
expect(previewImage?.parentElement?.className).toContain('ring-1');
expect(onPreview).toHaveBeenCalledTimes(1);
});
test('supports inline selected upload preview rows', () => {
const onRemove = vi.fn();
render(
<PlatformUploadPreviewCard
layout="inline"
imageSrc="/reference.png"
imageAlt="创作参考图"
caption="森林参考图.png"
removeLabel="移除参考图"
onRemove={onRemove}
className="mt-3"
removeButtonProps={{ title: '移除参考图' }}
/>,
);
const image = screen.getByRole('img', { name: '创作参考图' });
const row = image.closest('div')?.parentElement;
const removeButton = screen.getByRole('button', { name: '移除参考图' });
expect(row?.className).toContain('flex items-center gap-3');
expect(row?.className).toContain('bg-white/68');
expect(row?.className).toContain('px-3');
expect(row?.className).toContain('py-2');
expect(row?.className).toContain('mt-3');
expect(screen.getByText('森林参考图.png').className).toContain('truncate');
expect(removeButton.className).toContain('platform-icon-button');
expect(removeButton.getAttribute('title')).toBe('移除参考图');
fireEvent.click(removeButton);
expect(onRemove).toHaveBeenCalledTimes(1);
});
test('supports editor dark inline selected upload preview rows', () => {
render(
<PlatformUploadPreviewCard
layout="inline"
surface="editorDark"
imageSrc="/reference.png"
imageAlt="封面参考图"
caption="已载入封面参考图"
removeLabel="移除封面参考图"
onRemove={vi.fn()}
imageShellClassName="!h-16 !w-24 rounded-xl"
/>,
);
const image = screen.getByRole('img', { name: '封面参考图' });
const imageShell = image.parentElement;
const row = imageShell?.parentElement;
const caption = screen.getByText('已载入封面参考图');
const removeButton = screen.getByRole('button', { name: '移除封面参考图' });
expect(row?.className).toContain('border-white/10');
expect(row?.className).toContain('bg-black/25');
expect(imageShell?.className).toContain('bg-black/30');
expect(imageShell?.className).toContain('!w-24');
expect(caption.className).toContain('text-zinc-300');
expect(removeButton.className).toContain('bg-black/55');
});
test('keeps disabled remove action inert', () => {
const onRemove = vi.fn();
render(
<PlatformUploadPreviewCard
imageSrc="/preview.png"
imageAlt="上传图片预览"
removeLabel="移除图片"
onRemove={onRemove}
disabled
/>,
);
const removeButton = screen.getByRole('button', { name: '移除图片' });
fireEvent.click(removeButton);
expect(removeButton).toHaveProperty('disabled', true);
expect(removeButton.className).toContain('disabled:cursor-not-allowed');
expect(onRemove).not.toHaveBeenCalled();
});
test('supports local size, image and remove button classes', () => {
render(
<PlatformUploadPreviewCard
imageSrc="/preview.png"
imageAlt="上传图片预览"
removeLabel="移除图片"
onRemove={vi.fn()}
className="h-20 w-20"
imageClassName="object-top"
removeButtonProps={{ className: 'right-2 top-2' }}
/>,
);
const image = screen.getByRole('img', { name: '上传图片预览' });
const removeButton = screen.getByRole('button', { name: '移除图片' });
expect(image.parentElement?.className).toContain('h-20');
expect(image.className).toContain('object-top');
expect(removeButton.className).toContain('right-2');
});

View File

@@ -0,0 +1,195 @@
import { X } from 'lucide-react';
import type { ButtonHTMLAttributes, ReactNode } from 'react';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import { PlatformIconButton } from './PlatformIconButton';
import { PlatformSubpanel } from './PlatformSubpanel';
type PlatformUploadPreviewCardProps = {
imageSrc: string;
imageAlt: string;
imageRefreshKey?: string | number | null;
removeLabel: string;
layout?: 'square' | 'inline';
surface?: 'platform' | 'editorDark';
caption?: ReactNode;
previewLabel?: string;
onPreview?: () => void;
onRemove?: () => void;
disabled?: boolean;
resolveAsset?: boolean;
className?: string;
imageClassName?: string;
imageShellClassName?: string;
captionClassName?: string;
removeIcon?: ReactNode;
previewButtonProps?: Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
'aria-label' | 'children' | 'disabled' | 'onClick' | 'type'
>;
removeButtonProps?: Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
'aria-label' | 'children' | 'disabled' | 'onClick' | 'type'
>;
};
/**
* 平台上传预览卡片。
* 统一承载上传后缩略图、预览壳和右上角移除按钮。
*/
export function PlatformUploadPreviewCard({
imageSrc,
imageAlt,
imageRefreshKey = null,
removeLabel,
layout = 'square',
surface = 'platform',
caption,
previewLabel,
onPreview,
onRemove,
disabled = false,
resolveAsset = false,
className,
imageClassName,
imageShellClassName,
captionClassName,
removeIcon = <X className="h-3 w-3" />,
previewButtonProps,
removeButtonProps,
}: PlatformUploadPreviewCardProps) {
const { className: previewButtonClassName, ...restPreviewButtonProps } =
previewButtonProps ?? {};
const { className: removeButtonClassName, ...restRemoveButtonProps } =
removeButtonProps ?? {};
const inline = layout === 'inline';
const editorDark = surface === 'editorDark';
const imageClassNames = ['h-full w-full object-cover', imageClassName]
.filter(Boolean)
.join(' ');
const imageShellClassNames = [
inline
? [
'h-12 w-12 shrink-0 overflow-hidden rounded-[0.8rem]',
editorDark
? 'border border-white/10 bg-black/30'
: 'bg-[var(--platform-track-fill)]',
].join(' ')
: caption
? 'aspect-square overflow-hidden'
: 'h-full w-full',
imageShellClassName,
]
.filter(Boolean)
.join(' ');
const captionClassNames = [
'truncate px-2 py-2 pr-8 text-[11px] font-semibold',
editorDark ? 'text-zinc-300' : 'text-[var(--platform-text-base)]',
captionClassName,
]
.filter(Boolean)
.join(' ');
const previewActionLabel = previewLabel ?? imageAlt;
const imageElement = resolveAsset ? (
<ResolvedAssetImage
src={imageSrc}
refreshKey={imageRefreshKey}
alt={imageAlt}
className={imageClassNames}
/>
) : (
<img src={imageSrc} alt={imageAlt} className={imageClassNames} />
);
const imageContent =
caption || imageShellClassName || inline ? (
<div className={imageShellClassNames}>{imageElement}</div>
) : (
imageElement
);
const squareRootClassName =
surface === 'editorDark'
? 'relative h-[5.75rem] w-[5.75rem] overflow-hidden rounded-xl border border-white/10 bg-black/25'
: 'relative h-[5.75rem] w-[5.75rem] overflow-hidden rounded-xl border border-[var(--platform-subpanel-border)] bg-[var(--platform-input-fill)]';
const cardContent = (
<>
{onPreview ? (
<button
{...restPreviewButtonProps}
type="button"
aria-label={previewActionLabel}
title={restPreviewButtonProps.title ?? previewActionLabel}
disabled={disabled}
onClick={onPreview}
className={[
caption ? 'block w-full' : 'block h-full w-full',
'disabled:cursor-not-allowed disabled:opacity-55',
previewButtonClassName,
]
.filter(Boolean)
.join(' ')}
>
{imageContent}
</button>
) : (
imageContent
)}
{caption ? (
<div
className={
inline
? [
'min-w-0 flex-1 truncate text-sm font-semibold',
editorDark
? 'text-zinc-300'
: 'text-[var(--platform-text-strong)]',
captionClassName,
]
.filter(Boolean)
.join(' ')
: captionClassNames
}
>
{caption}
</div>
) : null}
{onRemove ? (
<PlatformIconButton
{...restRemoveButtonProps}
label={removeLabel}
icon={removeIcon}
variant={inline && !editorDark ? 'platformIcon' : 'darkMini'}
disabled={disabled}
onClick={onRemove}
className={[
inline ? 'h-9 w-9 shrink-0' : 'absolute right-1 top-1 h-5 w-5',
removeButtonClassName,
]
.filter(Boolean)
.join(' ')}
/>
) : null}
</>
);
if (inline) {
return (
<PlatformSubpanel
as="div"
surface={editorDark ? 'dark' : 'soft'}
radius="sm"
padding="row"
className={['flex items-center gap-3', className]
.filter(Boolean)
.join(' ')}
>
{cardContent}
</PlatformSubpanel>
);
}
return (
<div className={[squareRootClassName, className].filter(Boolean).join(' ')}>
{cardContent}
</div>
);
}

View File

@@ -0,0 +1,141 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import { PlatformUploadTile } from './PlatformUploadTile';
test('renders platform upload tile with default button semantics', () => {
render(<PlatformUploadTile label="上传凭证" hint="(最多四张)" />);
const button = screen.getByRole('button', {
name: '上传凭证 (最多四张)',
});
expect(button.getAttribute('type')).toBe('button');
expect(button.className).toContain('border-dashed');
expect(button.className).toContain('bg-[var(--platform-input-fill)]');
expect(screen.getByText('上传凭证')).toBeTruthy();
expect(screen.getByText('(最多四张)')).toBeTruthy();
});
test('keeps disabled upload tile inert', () => {
const onClick = vi.fn();
render(<PlatformUploadTile label="上传凭证" disabled onClick={onClick} />);
const button = screen.getByRole('button', { name: '上传凭证' });
fireEvent.click(button);
expect(button).toHaveProperty('disabled', true);
expect(button.className).toContain('cursor-not-allowed');
expect(onClick).not.toHaveBeenCalled();
});
test('supports label semantics for file inputs', () => {
render(
<>
<PlatformUploadTile
asChild="label"
htmlFor="reference-upload"
label="上传参考图"
className="h-20 w-20"
/>
<input id="reference-upload" type="file" />
</>,
);
const input = screen.getByLabelText('上传参考图');
const label = screen.getByText('上传参考图').closest('label');
expect(input.getAttribute('type')).toBe('file');
expect(label?.getAttribute('for')).toBe('reference-upload');
expect(label?.className).toContain('h-20');
});
test('supports editor dark panel upload labels', () => {
render(
<>
<PlatformUploadTile
asChild="label"
htmlFor="cover-upload"
label="上传封面"
hint="支持 png、jpg、webp。"
icon={null}
size="panel"
surface="editorDark"
/>
<input id="cover-upload" type="file" />
</>,
);
const input = screen.getByLabelText(//u);
const label = screen.getByText('上传封面').closest('label');
expect(input.getAttribute('type')).toBe('file');
expect(label?.className).toContain('min-h-[7rem]');
expect(label?.className).toContain('items-start');
expect(label?.className).toContain('border-white/12');
expect(label?.className).toContain('bg-black/20');
expect(screen.getByText('支持 png、jpg、webp。').className).toContain(
'leading-5',
);
});
test('prevents disabled label uploads from opening the nested input', () => {
const onClick = vi.fn();
render(
<PlatformUploadTile
asChild="label"
label="上传参考图"
disabled
onClick={onClick}
/>,
);
const label = screen.getByText('上传参考图').closest('label');
const clickEvent = new MouseEvent('click', {
bubbles: true,
cancelable: true,
});
label?.dispatchEvent(clickEvent);
expect(clickEvent.defaultPrevented).toBe(true);
expect(label?.getAttribute('aria-disabled')).toBe('true');
expect(onClick).not.toHaveBeenCalled();
});
test('supports custom icon content', () => {
render(
<PlatformUploadTile
label="上传音频"
icon={<span aria-hidden="true">+</span>}
/>,
);
expect(
screen.getByRole('button', { name: '上传音频' }).textContent,
).toContain('+');
});
test('supports compact icon-only dashed actions', () => {
render(
<PlatformUploadTile
label="新增功德词条"
showLabel={false}
size="compact"
icon={<span aria-hidden="true">+</span>}
className="rounded-[0.95rem]"
/>,
);
const button = screen.getByRole('button', { name: '新增功德词条' });
const hiddenLabel = button.querySelector('.sr-only');
expect(button.className).toContain('h-12');
expect(button.className).toContain('w-full');
expect(button.className).toContain('rounded-[0.95rem]');
expect(hiddenLabel?.textContent).toBe('新增功德词条');
});

View File

@@ -0,0 +1,146 @@
import { ImagePlus } from 'lucide-react';
import type {
ButtonHTMLAttributes,
LabelHTMLAttributes,
ReactNode,
} from 'react';
type PlatformUploadTileBaseProps = {
label: ReactNode;
hint?: ReactNode;
icon?: ReactNode;
disabled?: boolean;
showLabel?: boolean;
size?: 'square' | 'compact' | 'panel';
surface?: 'platform' | 'editorDark';
};
type PlatformUploadTileButtonProps = Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
'children' | 'disabled'
> &
PlatformUploadTileBaseProps & {
asChild?: false;
};
type PlatformUploadTileLabelProps = Omit<
LabelHTMLAttributes<HTMLLabelElement>,
'children'
> &
PlatformUploadTileBaseProps & {
asChild: 'label';
};
type PlatformUploadTileProps =
| PlatformUploadTileButtonProps
| PlatformUploadTileLabelProps;
function getPlatformUploadTileClassName(
size: 'square' | 'compact' | 'panel',
surface: 'platform' | 'editorDark',
className?: string,
disabled?: boolean,
) {
const surfaceClassName = {
platform:
'border-[var(--platform-subpanel-border)] bg-[var(--platform-input-fill)] text-[var(--platform-text-soft)] hover:border-[var(--platform-surface-hover-border)] hover:text-[var(--platform-text-strong)]',
editorDark:
'border-white/12 bg-black/20 text-zinc-300 hover:border-sky-300/45 hover:bg-white/8 hover:text-white',
}[surface];
const sizeClassName = {
square: 'h-[5.75rem] w-[5.75rem] items-center text-center',
compact: 'h-12 w-full items-center text-center',
panel: 'min-h-[7rem] w-full items-start px-4 py-4 text-left',
}[size];
return [
'flex flex-col justify-center rounded-xl border border-dashed transition',
surfaceClassName,
sizeClassName,
disabled ? 'cursor-not-allowed opacity-55' : 'cursor-pointer',
className,
]
.filter(Boolean)
.join(' ');
}
/**
* 平台通用上传方块。
* 统一承载图片 / 附件上传入口的虚线方块、图标、主副文案和可访问语义。
*/
export function PlatformUploadTile({
label,
hint,
icon = <ImagePlus className="h-6 w-6" />,
disabled = false,
showLabel = true,
size = 'square',
surface = 'platform',
className,
asChild,
...actionProps
}: PlatformUploadTileProps) {
const tileClassName = getPlatformUploadTileClassName(
size,
surface,
className,
disabled,
);
const hasIcon = Boolean(icon);
const labelClassName =
size === 'panel'
? [hasIcon ? 'mt-2' : null, 'text-[11px] font-bold tracking-[0.14em]']
.filter(Boolean)
.join(' ')
: [hasIcon ? 'mt-2' : null, 'text-xs font-medium']
.filter(Boolean)
.join(' ');
const hintClassName =
size === 'panel'
? 'mt-2 text-xs leading-5 opacity-80'
: 'mt-0.5 text-[11px] opacity-80';
const content = (
<>
{hasIcon ? icon : null}
<span className={showLabel ? labelClassName : 'sr-only'}>{label}</span>
{hint ? <span className={hintClassName}>{hint}</span> : null}
</>
);
if (asChild === 'label') {
const { onClick, ...labelProps } =
actionProps as LabelHTMLAttributes<HTMLLabelElement>;
return (
<label
{...labelProps}
onClick={(event) => {
if (disabled) {
event.preventDefault();
return;
}
onClick?.(event);
}}
aria-disabled={disabled || undefined}
className={tileClassName}
>
{content}
</label>
);
}
const { type = 'button', ...buttonProps } =
actionProps as ButtonHTMLAttributes<HTMLButtonElement>;
return (
<button
{...buttonProps}
type={type}
disabled={disabled}
className={tileClassName}
>
{content}
</button>
);
}

View File

@@ -0,0 +1,51 @@
/* @vitest-environment jsdom */
import { render, screen, within } from '@testing-library/react';
import { expect, test } from 'vitest';
import { PlatformUtilityInfoModal } from './PlatformUtilityInfoModal';
test('renders platform utility info modal shell with default platform styling', () => {
render(
<PlatformUtilityInfoModal
open
title="工具信息"
onClose={() => {}}
footer={<button type="button"></button>}
panelClassName="rounded-[1.5rem]"
>
<div></div>
</PlatformUtilityInfoModal>,
);
const dialog = screen.getByRole('dialog', { name: '工具信息' });
expect(dialog.parentElement?.className).toContain('platform-theme--light');
expect(dialog.parentElement?.className).toContain('!items-center');
expect(dialog.className).toContain('platform-remap-surface');
expect(dialog.className).toContain('rounded-[1.5rem]');
expect(within(dialog).getByText('这里是正文')).toBeTruthy();
expect(within(dialog).getByRole('button', { name: '知道了' })).toBeTruthy();
});
test('supports custom theme and spacing overrides', () => {
render(
<PlatformUtilityInfoModal
open
title="工具信息"
onClose={() => {}}
platformTheme="dark"
bodyClassName="space-y-3"
footerClassName="justify-center pt-0"
footer={<button type="button"></button>}
>
<div></div>
</PlatformUtilityInfoModal>,
);
const dialog = screen.getByRole('dialog', { name: '工具信息' });
expect(dialog.parentElement?.className).toContain('platform-theme--dark');
expect(dialog.querySelector('.space-y-3')).toBeTruthy();
expect(dialog.querySelector('.justify-center')).toBeTruthy();
expect(dialog.querySelector('.pt-0')).toBeTruthy();
expect(within(dialog).getByRole('button', { name: '继续' })).toBeTruthy();
});

Some files were not shown because too many files have changed in this diff Show More