收口前端平台组件库能力
新增 PlatformUiKit 通用弹窗、按钮、状态、空态、媒体、表单和标签等公共组件 迁移结果页、创作工作台、认证入口、RPG 暗色面板和运行态弹窗的重复 UI chrome 补充组件测试、页面回归测试、技术文档和 Hermes 共享决策记录
This commit is contained in:
73
src/components/common/CopyCodeButton.test.tsx
Normal file
73
src/components/common/CopyCodeButton.test.tsx
Normal 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');
|
||||
});
|
||||
133
src/components/common/CopyCodeButton.tsx
Normal file
133
src/components/common/CopyCodeButton.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
135
src/components/common/CopyFeedbackButton.test.tsx
Normal file
135
src/components/common/CopyFeedbackButton.test.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
/* @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"
|
||||
actionFullWidth
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { name: '复制报错' });
|
||||
|
||||
expect(button.className).toContain('platform-button--primary');
|
||||
expect(button.className).toContain('w-full');
|
||||
expect(button.className).toContain('disabled:cursor-not-allowed');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
138
src/components/common/CopyFeedbackButton.tsx
Normal file
138
src/components/common/CopyFeedbackButton.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
import {
|
||||
getPlatformActionButtonClassName,
|
||||
type PlatformActionButtonSize,
|
||||
type PlatformActionButtonSurface,
|
||||
type PlatformActionButtonTone,
|
||||
} from './platformActionButtonModel';
|
||||
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;
|
||||
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',
|
||||
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);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
actionSurface
|
||||
? getPlatformActionButtonClassName({
|
||||
surface: actionSurface,
|
||||
tone: actionTone,
|
||||
size: actionSize,
|
||||
fullWidth: actionFullWidth,
|
||||
})
|
||||
: actionAppearance === 'pill'
|
||||
? getPlatformPillBadgeClassName({
|
||||
tone: actionPillTone,
|
||||
size: actionPillSize,
|
||||
})
|
||||
: null,
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
{...buttonProps}
|
||||
aria-label={ariaLabel ?? accessibleLabel}
|
||||
title={
|
||||
title ??
|
||||
(typeof accessibleLabel === 'string' ? accessibleLabel : undefined)
|
||||
}
|
||||
>
|
||||
{showIcon ? icon : null}
|
||||
{showLabel ? <span className={labelClassName}>{label}</span> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
39
src/components/common/CopyFeedbackMessage.test.tsx
Normal file
39
src/components/common/CopyFeedbackMessage.test.tsx
Normal 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();
|
||||
});
|
||||
29
src/components/common/CopyFeedbackMessage.tsx
Normal file
29
src/components/common/CopyFeedbackMessage.tsx
Normal 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>;
|
||||
}
|
||||
@@ -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(() =>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,18 @@ test('creative image input panel handles reference uploads and preview', () => {
|
||||
const promptReferenceInput = screen.getByLabelText('上传参考图', {
|
||||
selector: 'input',
|
||||
});
|
||||
const promptTextarea = screen.getByLabelText('画面描述');
|
||||
const emptyMainImageIconBadge = document.querySelector(
|
||||
'[aria-hidden="true"]',
|
||||
);
|
||||
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,18 +90,11 @@ 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'),
|
||||
@@ -97,7 +103,11 @@ test('creative image input panel handles reference uploads and preview', () => {
|
||||
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 +148,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 +195,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');
|
||||
@@ -332,19 +346,24 @@ 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: '关闭关卡图片预览' }));
|
||||
|
||||
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();
|
||||
@@ -399,6 +418,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 +600,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();
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
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 { PlatformIconBadge } from './PlatformIconBadge';
|
||||
import { PlatformIconButton } from './PlatformIconButton';
|
||||
import { PlatformModalCloseButton } from './PlatformModalCloseButton';
|
||||
import { PlatformPillBadge } from './PlatformPillBadge';
|
||||
import { PlatformPillSwitch } from './PlatformPillSwitch';
|
||||
import { PlatformStatusMessage } from './PlatformStatusMessage';
|
||||
import { PlatformTextField } from './PlatformTextField';
|
||||
import { PlatformUploadPreviewCard } from './PlatformUploadPreviewCard';
|
||||
|
||||
export type CreativeImageInputReferenceImage = {
|
||||
id: string;
|
||||
@@ -131,7 +133,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 =
|
||||
@@ -258,76 +261,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 +332,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}
|
||||
@@ -359,81 +351,83 @@ export function CreativeImageInputPanel({
|
||||
{promptLabel}
|
||||
</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 +437,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 +462,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,12 +476,16 @@ 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}
|
||||
|
||||
@@ -501,14 +508,12 @@ export function CreativeImageInputPanel({
|
||||
>
|
||||
{previewReferenceImage.label}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={labels.closePromptReferencePreview}
|
||||
<PlatformModalCloseButton
|
||||
label={labels.closePromptReferencePreview}
|
||||
variant="profileCompact"
|
||||
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>
|
||||
className="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-[72vh] overflow-hidden rounded-[1rem] bg-black/5">
|
||||
<ResolvedAssetImage
|
||||
@@ -540,16 +545,15 @@ export function CreativeImageInputPanel({
|
||||
>
|
||||
{labels.previewMainImage ?? uploadedImageAlt}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={
|
||||
labels.closeMainImagePreview ?? labels.closePromptReferencePreview
|
||||
<PlatformModalCloseButton
|
||||
label={
|
||||
labels.closeMainImagePreview ??
|
||||
labels.closePromptReferencePreview
|
||||
}
|
||||
variant="profileCompact"
|
||||
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>
|
||||
className="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-[82vh] overflow-hidden rounded-[1rem] bg-black/5">
|
||||
<ResolvedAssetImage
|
||||
@@ -581,23 +585,20 @@ export function CreativeImageInputPanel({
|
||||
{labels.removeImageConfirmBody}
|
||||
</div>
|
||||
<div className="mt-5 grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
<PlatformActionButton
|
||||
tone="secondary"
|
||||
onClick={() => setIsRemoveImageConfirmOpen(false)}
|
||||
className="platform-button platform-button--secondary justify-center"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
</PlatformActionButton>
|
||||
<PlatformActionButton
|
||||
onClick={() => {
|
||||
onMainImageRemove();
|
||||
setIsRemoveImageConfirmOpen(false);
|
||||
}}
|
||||
className="platform-button platform-button--primary justify-center"
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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">
|
||||
|
||||
76
src/components/common/PlatformActionButton.test.tsx
Normal file
76
src/components/common/PlatformActionButton.test.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
/* @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]');
|
||||
});
|
||||
95
src/components/common/PlatformActionButton.tsx
Normal file
95
src/components/common/PlatformActionButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
244
src/components/common/PlatformAssetPickerCard.test.tsx
Normal file
244
src/components/common/PlatformAssetPickerCard.test.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
/* @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('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');
|
||||
});
|
||||
322
src/components/common/PlatformAssetPickerCard.tsx
Normal file
322
src/components/common/PlatformAssetPickerCard.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
import type { ButtonHTMLAttributes, Key, ReactNode } from 'react';
|
||||
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
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>) {
|
||||
return (
|
||||
<>
|
||||
{error ? (
|
||||
<PlatformStatusMessage
|
||||
tone="error"
|
||||
surface={PLATFORM_ASSET_PICKER_GRID_STATUS_SURFACE[surface]}
|
||||
size="md"
|
||||
className={statusClassName}
|
||||
>
|
||||
{error}
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
<PlatformEmptyState
|
||||
surface="dashed"
|
||||
size="panel"
|
||||
className={[
|
||||
PLATFORM_ASSET_PICKER_GRID_EMPTY_CLASS[surface],
|
||||
emptyClassName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
{loadingLabel}
|
||||
</PlatformEmptyState>
|
||||
) : null}
|
||||
|
||||
{!isLoading && !error && items.length <= 0 ? (
|
||||
<PlatformEmptyState
|
||||
surface="dashed"
|
||||
size="panel"
|
||||
className={[
|
||||
PLATFORM_ASSET_PICKER_GRID_EMPTY_CLASS[surface],
|
||||
emptyClassName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
{emptyLabel}
|
||||
</PlatformEmptyState>
|
||||
) : null}
|
||||
|
||||
{!isLoading && 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
95
src/components/common/PlatformDarkOptionCard.test.tsx
Normal file
95
src/components/common/PlatformDarkOptionCard.test.tsx
Normal 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');
|
||||
});
|
||||
82
src/components/common/PlatformDarkOptionCard.tsx
Normal file
82
src/components/common/PlatformDarkOptionCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
src/components/common/PlatformEmptyState.test.tsx
Normal file
73
src/components/common/PlatformEmptyState.test.tsx
Normal 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)]');
|
||||
});
|
||||
75
src/components/common/PlatformEmptyState.tsx
Normal file
75
src/components/common/PlatformEmptyState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
src/components/common/PlatformFieldLabel.test.tsx
Normal file
52
src/components/common/PlatformFieldLabel.test.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
/* @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 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');
|
||||
});
|
||||
44
src/components/common/PlatformFieldLabel.tsx
Normal file
44
src/components/common/PlatformFieldLabel.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type PlatformFieldLabelVariant =
|
||||
| 'field'
|
||||
| 'section'
|
||||
| 'form'
|
||||
| '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)]',
|
||||
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>
|
||||
);
|
||||
}
|
||||
125
src/components/common/PlatformIconBadge.test.tsx
Normal file
125
src/components/common/PlatformIconBadge.test.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
/* @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('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');
|
||||
});
|
||||
84
src/components/common/PlatformIconBadge.tsx
Normal file
84
src/components/common/PlatformIconBadge.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
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={[
|
||||
'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>
|
||||
);
|
||||
}
|
||||
167
src/components/common/PlatformIconButton.test.tsx
Normal file
167
src/components/common/PlatformIconButton.test.tsx
Normal 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');
|
||||
});
|
||||
89
src/components/common/PlatformIconButton.tsx
Normal file
89
src/components/common/PlatformIconButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
src/components/common/PlatformInfoBlock.test.tsx
Normal file
78
src/components/common/PlatformInfoBlock.test.tsx
Normal 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');
|
||||
});
|
||||
100
src/components/common/PlatformInfoBlock.tsx
Normal file
100
src/components/common/PlatformInfoBlock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
295
src/components/common/PlatformMediaFrame.test.tsx
Normal file
295
src/components/common/PlatformMediaFrame.test.tsx
Normal 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');
|
||||
});
|
||||
163
src/components/common/PlatformMediaFrame.tsx
Normal file
163
src/components/common/PlatformMediaFrame.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
96
src/components/common/PlatformMediaTileGrid.test.tsx
Normal file
96
src/components/common/PlatformMediaTileGrid.test.tsx
Normal 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)]');
|
||||
});
|
||||
126
src/components/common/PlatformMediaTileGrid.tsx
Normal file
126
src/components/common/PlatformMediaTileGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
src/components/common/PlatformModalCloseButton.test.tsx
Normal file
89
src/components/common/PlatformModalCloseButton.test.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { expect, test } 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.querySelector('svg')).toBeTruthy();
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
65
src/components/common/PlatformModalCloseButton.tsx
Normal file
65
src/components/common/PlatformModalCloseButton.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { X } from 'lucide-react';
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
type PlatformModalCloseButtonVariant =
|
||||
| 'profile'
|
||||
| 'profileCompact'
|
||||
| 'floating'
|
||||
| 'floatingPlain'
|
||||
| 'platformIcon'
|
||||
| 'editorDark';
|
||||
|
||||
type PlatformModalCloseButtonProps = Omit<
|
||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
'children'
|
||||
> & {
|
||||
label: string;
|
||||
variant?: PlatformModalCloseButtonVariant;
|
||||
icon?: ReactNode;
|
||||
};
|
||||
|
||||
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',
|
||||
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',
|
||||
};
|
||||
|
||||
/**
|
||||
* 平台弹窗关闭按钮。
|
||||
* 收口个人中心和平台浮层里重复的关闭 aria、尺寸和视觉样式。
|
||||
*/
|
||||
export function PlatformModalCloseButton({
|
||||
label,
|
||||
variant = 'profile',
|
||||
icon = <X className="h-4 w-4" />,
|
||||
className,
|
||||
type = 'button',
|
||||
...buttonProps
|
||||
}: PlatformModalCloseButtonProps) {
|
||||
return (
|
||||
<button
|
||||
{...buttonProps}
|
||||
type={type}
|
||||
aria-label={label}
|
||||
className={[
|
||||
PLATFORM_MODAL_CLOSE_BUTTON_CLASS_BY_VARIANT[variant],
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
53
src/components/common/PlatformOverlayBadge.test.tsx
Normal file
53
src/components/common/PlatformOverlayBadge.test.tsx
Normal 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');
|
||||
});
|
||||
87
src/components/common/PlatformOverlayBadge.tsx
Normal file
87
src/components/common/PlatformOverlayBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
148
src/components/common/PlatformPillBadge.test.tsx
Normal file
148
src/components/common/PlatformPillBadge.test.tsx
Normal 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]');
|
||||
});
|
||||
40
src/components/common/PlatformPillBadge.tsx
Normal file
40
src/components/common/PlatformPillBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
81
src/components/common/PlatformPillSwitch.test.tsx
Normal file
81
src/components/common/PlatformPillSwitch.test.tsx
Normal 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',
|
||||
);
|
||||
});
|
||||
56
src/components/common/PlatformPillSwitch.tsx
Normal file
56
src/components/common/PlatformPillSwitch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
src/components/common/PlatformProgressBar.test.tsx
Normal file
110
src/components/common/PlatformProgressBar.test.tsx
Normal 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%');
|
||||
});
|
||||
90
src/components/common/PlatformProgressBar.tsx
Normal file
90
src/components/common/PlatformProgressBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
src/components/common/PlatformQuantityBadge.test.tsx
Normal file
31
src/components/common/PlatformQuantityBadge.test.tsx
Normal 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');
|
||||
});
|
||||
55
src/components/common/PlatformQuantityBadge.tsx
Normal file
55
src/components/common/PlatformQuantityBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
164
src/components/common/PlatformSegmentedTabs.test.tsx
Normal file
164
src/components/common/PlatformSegmentedTabs.test.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
/* @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');
|
||||
});
|
||||
238
src/components/common/PlatformSegmentedTabs.tsx
Normal file
238
src/components/common/PlatformSegmentedTabs.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
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';
|
||||
|
||||
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;
|
||||
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',
|
||||
ariaLabel,
|
||||
truncateLabels = false,
|
||||
disabled = false,
|
||||
className,
|
||||
itemClassName,
|
||||
}: PlatformSegmentedTabsProps<TId>) {
|
||||
return (
|
||||
<div
|
||||
role={semantics === 'tabs' ? 'tablist' : undefined}
|
||||
aria-label={semantics === 'tabs' ? ariaLabel : undefined}
|
||||
className={[
|
||||
'grid',
|
||||
PLATFORM_SEGMENTED_TABS_FRAME_CLASS[frame],
|
||||
PLATFORM_SEGMENTED_TABS_COLUMNS_CLASS[columns],
|
||||
PLATFORM_SEGMENTED_TABS_GAP_CLASS[gap],
|
||||
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>
|
||||
);
|
||||
}
|
||||
46
src/components/common/PlatformSlotBadge.test.tsx
Normal file
46
src/components/common/PlatformSlotBadge.test.tsx
Normal 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');
|
||||
});
|
||||
52
src/components/common/PlatformSlotBadge.tsx
Normal file
52
src/components/common/PlatformSlotBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
src/components/common/PlatformStatGrid.test.tsx
Normal file
74
src/components/common/PlatformStatGrid.test.tsx
Normal 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();
|
||||
});
|
||||
158
src/components/common/PlatformStatGrid.tsx
Normal file
158
src/components/common/PlatformStatGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
150
src/components/common/PlatformStatusMessage.test.tsx
Normal file
150
src/components/common/PlatformStatusMessage.test.tsx
Normal 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',
|
||||
);
|
||||
});
|
||||
108
src/components/common/PlatformStatusMessage.tsx
Normal file
108
src/components/common/PlatformStatusMessage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
231
src/components/common/PlatformSubpanel.test.tsx
Normal file
231
src/components/common/PlatformSubpanel.test.tsx
Normal 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);
|
||||
});
|
||||
222
src/components/common/PlatformSubpanel.tsx
Normal file
222
src/components/common/PlatformSubpanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
src/components/common/PlatformTagEditor.test.tsx
Normal file
110
src/components/common/PlatformTagEditor.test.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
/* @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}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '删除标签 山海' }));
|
||||
|
||||
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();
|
||||
});
|
||||
219
src/components/common/PlatformTagEditor.tsx
Normal file
219
src/components/common/PlatformTagEditor.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
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}
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() =>
|
||||
onChange(
|
||||
normalizedTags.filter((currentTag) => currentTag !== tag),
|
||||
)
|
||||
}
|
||||
className="rounded-full opacity-70 transition hover:opacity-100 disabled:opacity-45"
|
||||
aria-label={`删除标签 ${tag}`}
|
||||
title="删除标签"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
198
src/components/common/PlatformTextField.test.tsx
Normal file
198
src/components/common/PlatformTextField.test.tsx
Normal 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');
|
||||
});
|
||||
184
src/components/common/PlatformTextField.tsx
Normal file
184
src/components/common/PlatformTextField.tsx
Normal 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} />;
|
||||
}
|
||||
88
src/components/common/PlatformToggleRow.test.tsx
Normal file
88
src/components/common/PlatformToggleRow.test.tsx
Normal 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',
|
||||
);
|
||||
});
|
||||
134
src/components/common/PlatformToggleRow.tsx
Normal file
134
src/components/common/PlatformToggleRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
225
src/components/common/PlatformUploadPreviewCard.test.tsx
Normal file
225
src/components/common/PlatformUploadPreviewCard.test.tsx
Normal 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');
|
||||
});
|
||||
195
src/components/common/PlatformUploadPreviewCard.tsx
Normal file
195
src/components/common/PlatformUploadPreviewCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
141
src/components/common/PlatformUploadTile.test.tsx
Normal file
141
src/components/common/PlatformUploadTile.test.tsx
Normal 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('新增功德词条');
|
||||
});
|
||||
146
src/components/common/PlatformUploadTile.tsx
Normal file
146
src/components/common/PlatformUploadTile.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -42,9 +42,7 @@ describe('PublishShareModal', () => {
|
||||
test('renders share text and channel icons, then copies from main button', async () => {
|
||||
vi.mocked(clipboardService.copyTextToClipboard).mockResolvedValue(true);
|
||||
|
||||
render(
|
||||
<PublishShareModal open payload={payload} onClose={() => {}} />,
|
||||
);
|
||||
render(<PublishShareModal open payload={payload} onClose={() => {}} />);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '分享给朋友' });
|
||||
expect(dialog.parentElement?.className).toContain('!items-center');
|
||||
@@ -52,11 +50,22 @@ describe('PublishShareModal', () => {
|
||||
expect(dialog.className).toContain('platform-modal-shell');
|
||||
expect(dialog.className).toContain('rounded-[1.75rem]');
|
||||
expect(dialog.getAttribute('style')).toBeNull();
|
||||
expect(within(dialog).getByText(/邀请你来玩《暖灯猫街》/u)).toBeTruthy();
|
||||
const shareTextBlock = within(dialog).getByText(/邀请你来玩《暖灯猫街》/u);
|
||||
const shareTextShell = shareTextBlock.closest('div')?.parentElement;
|
||||
expect(shareTextBlock).toBeTruthy();
|
||||
expect(shareTextShell?.className).toContain('bg-white/72');
|
||||
expect(shareTextShell?.className).toContain('rounded-[1.25rem]');
|
||||
expect(shareTextBlock.className).toContain('whitespace-pre-wrap');
|
||||
expect(within(dialog).getByRole('button', { name: '分享' })).toBeTruthy();
|
||||
expect(within(dialog).getByRole('button', { name: '分享到微信' })).toBeTruthy();
|
||||
expect(within(dialog).getByRole('button', { name: '分享到QQ' })).toBeTruthy();
|
||||
expect(within(dialog).getByRole('button', { name: '分享到抖音' })).toBeTruthy();
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: '分享到微信' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: '分享到QQ' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: '分享到抖音' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(dialog).getByTestId('share-channel-logo-wechat'),
|
||||
).toBeTruthy();
|
||||
@@ -71,7 +80,9 @@ describe('PublishShareModal', () => {
|
||||
expect.stringContaining('作品号:PZ-00000001'),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(within(dialog).getByRole('button', { name: '已复制' })).toBeTruthy();
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: '已复制' }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import { copyTextToClipboard } from '../../services/clipboard';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { CopyFeedbackButton } from './CopyFeedbackButton';
|
||||
import { PlatformInfoBlock } from './PlatformInfoBlock';
|
||||
import {
|
||||
buildPublishShareText,
|
||||
type PublishShareModalPayload,
|
||||
} from './publishShareModalModel';
|
||||
import { UnifiedModal } from './UnifiedModal';
|
||||
import { useCopyFeedback } from './useCopyFeedback';
|
||||
|
||||
type PublishShareModalProps = {
|
||||
open: boolean;
|
||||
@@ -94,43 +95,22 @@ export function PublishShareModal({
|
||||
onClose,
|
||||
}: PublishShareModalProps) {
|
||||
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
|
||||
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
|
||||
'idle',
|
||||
);
|
||||
const resetTimerRef = useRef<number | null>(null);
|
||||
const { copyState, copyText, resetCopyState } = useCopyFeedback();
|
||||
const shareText = useMemo(
|
||||
() => (payload ? buildPublishShareText(payload) : ''),
|
||||
[payload],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (resetTimerRef.current !== null) {
|
||||
window.clearTimeout(resetTimerRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setCopyState('idle');
|
||||
}, [payload?.publicWorkCode]);
|
||||
resetCopyState();
|
||||
}, [payload?.publicWorkCode, resetCopyState]);
|
||||
|
||||
const copyShareText = () => {
|
||||
if (!shareText) {
|
||||
return;
|
||||
}
|
||||
|
||||
void copyTextToClipboard(shareText).then((copied) => {
|
||||
setCopyState(copied ? 'copied' : 'failed');
|
||||
if (resetTimerRef.current !== null) {
|
||||
window.clearTimeout(resetTimerRef.current);
|
||||
}
|
||||
resetTimerRef.current = window.setTimeout(() => {
|
||||
resetTimerRef.current = null;
|
||||
setCopyState('idle');
|
||||
}, 1400);
|
||||
});
|
||||
void copyText(shareText);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -167,28 +147,22 @@ export function PublishShareModal({
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/72 p-4">
|
||||
<div className="whitespace-pre-wrap break-words text-sm leading-6 text-[var(--platform-text-strong)]">
|
||||
{shareText}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
<PlatformInfoBlock
|
||||
multiline
|
||||
className="rounded-[1.25rem] p-4"
|
||||
valueClassName="mt-0"
|
||||
>
|
||||
{shareText}
|
||||
</PlatformInfoBlock>
|
||||
<CopyFeedbackButton
|
||||
state={copyState}
|
||||
onClick={copyShareText}
|
||||
disabled={!shareText}
|
||||
className="platform-button platform-button--primary w-full justify-center gap-2 disabled:cursor-not-allowed disabled:opacity-55"
|
||||
>
|
||||
{copyState === 'copied' ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
{copyState === 'copied'
|
||||
? '已复制'
|
||||
: copyState === 'failed'
|
||||
? '复制失败'
|
||||
: '分享'}
|
||||
</button>
|
||||
idleLabel="分享"
|
||||
actionSurface="platform"
|
||||
actionFullWidth
|
||||
className="disabled:opacity-55"
|
||||
/>
|
||||
</UnifiedModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,11 +6,15 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
export type SquareImageCropRect = {
|
||||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
};
|
||||
import { PlatformActionButton } from './PlatformActionButton';
|
||||
import { PlatformModalCloseButton } from './PlatformModalCloseButton';
|
||||
import { PlatformStatusMessage } from './PlatformStatusMessage';
|
||||
import {
|
||||
clampNumber,
|
||||
clampSquareImageCropRect,
|
||||
getSquareCropSizeBounds,
|
||||
type SquareImageCropRect,
|
||||
} from './squareImageCropModel';
|
||||
|
||||
export type SquareImageCropModalLabels = {
|
||||
title: string;
|
||||
@@ -98,44 +102,6 @@ const SQUARE_CROP_RESIZE_HANDLES: Array<{
|
||||
},
|
||||
];
|
||||
|
||||
function clampNumber(value: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function getSquareCropSizeBounds(imageSize: { width: number; height: number }) {
|
||||
const maxSize = Math.max(1, Math.min(imageSize.width, imageSize.height));
|
||||
const minSize = Math.min(maxSize, Math.max(48, maxSize * 0.18));
|
||||
|
||||
return { minSize, maxSize };
|
||||
}
|
||||
|
||||
export function buildCenteredSquareImageCropRect(imageSize: {
|
||||
width: number;
|
||||
height: number;
|
||||
}): SquareImageCropRect {
|
||||
const size = Math.max(1, Math.min(imageSize.width, imageSize.height));
|
||||
|
||||
return {
|
||||
x: Math.max(0, (imageSize.width - size) / 2),
|
||||
y: Math.max(0, (imageSize.height - size) / 2),
|
||||
size,
|
||||
};
|
||||
}
|
||||
|
||||
export function clampSquareImageCropRect(
|
||||
imageSize: { width: number; height: number },
|
||||
crop: SquareImageCropRect,
|
||||
): SquareImageCropRect {
|
||||
const { minSize, maxSize } = getSquareCropSizeBounds(imageSize);
|
||||
const size = clampNumber(crop.size, minSize, maxSize);
|
||||
|
||||
return {
|
||||
x: clampNumber(crop.x, 0, Math.max(0, imageSize.width - size)),
|
||||
y: clampNumber(crop.y, 0, Math.max(0, imageSize.height - size)),
|
||||
size,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSquareCropPreviewStyle(
|
||||
crop: SquareImageCropRect,
|
||||
imageSize: { width: number; height: number },
|
||||
@@ -354,14 +320,12 @@ export function SquareImageCropModal({
|
||||
<div id={titleId} className="text-base font-black">
|
||||
{labels.title}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={labels.close}
|
||||
<PlatformModalCloseButton
|
||||
label={labels.close}
|
||||
variant="profileCompact"
|
||||
onClick={onClose}
|
||||
className="platform-profile-icon-button flex h-8 w-8 items-center justify-center rounded-full"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
icon="×"
|
||||
/>
|
||||
</div>
|
||||
<div className="px-5 py-5">
|
||||
<div
|
||||
@@ -416,26 +380,24 @@ export function SquareImageCropModal({
|
||||
</div>
|
||||
</div>
|
||||
{error ? (
|
||||
<div className="mt-4 rounded-2xl border border-[var(--platform-button-danger-border)] bg-[var(--platform-button-danger-fill)] px-3 py-2 text-sm text-[var(--platform-button-danger-text)]">
|
||||
<PlatformStatusMessage
|
||||
tone="error"
|
||||
surface="profile"
|
||||
className="mt-4 rounded-2xl"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
<div className="mt-5 grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="platform-button platform-button--secondary justify-center"
|
||||
>
|
||||
<PlatformActionButton tone="secondary" onClick={onClose}>
|
||||
{labels.cancel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
</PlatformActionButton>
|
||||
<PlatformActionButton
|
||||
onClick={onSubmit}
|
||||
disabled={isSaving}
|
||||
className="platform-button platform-button--primary justify-center disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{isSaving ? labels.saving : labels.submit}
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
187
src/components/common/UnifiedConfirmDialog.test.tsx
Normal file
187
src/components/common/UnifiedConfirmDialog.test.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import { UnifiedConfirmDialog } from './UnifiedConfirmDialog';
|
||||
|
||||
test('renders a simple confirm dialog and closes through the primary action', () => {
|
||||
const onClose = vi.fn();
|
||||
|
||||
render(
|
||||
<UnifiedConfirmDialog
|
||||
open
|
||||
title="泥点提示"
|
||||
description="当前表单不会丢失。"
|
||||
onClose={onClose}
|
||||
confirmLabel="知道了"
|
||||
>
|
||||
泥点不足,请补足后继续。
|
||||
</UnifiedConfirmDialog>,
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
test('supports cancel and danger confirm actions', () => {
|
||||
const onClose = vi.fn();
|
||||
const onConfirm = vi.fn();
|
||||
|
||||
render(
|
||||
<UnifiedConfirmDialog
|
||||
open
|
||||
title="删除作品"
|
||||
description="确认删除《潮雾列岛》吗?"
|
||||
onClose={onClose}
|
||||
onConfirm={onConfirm}
|
||||
showCancel
|
||||
confirmLabel="确认删除"
|
||||
confirmTone="danger"
|
||||
>
|
||||
删除后不可恢复。
|
||||
</UnifiedConfirmDialog>,
|
||||
);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '删除作品' });
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '取消' }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '确认删除' }));
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('busy state disables close paths and shows the busy confirm label', () => {
|
||||
const onClose = vi.fn();
|
||||
const onConfirm = vi.fn();
|
||||
|
||||
render(
|
||||
<UnifiedConfirmDialog
|
||||
open
|
||||
title="删除作品"
|
||||
onClose={onClose}
|
||||
onConfirm={onConfirm}
|
||||
showCancel
|
||||
confirmLabel="确认删除"
|
||||
busyConfirmLabel="删除中"
|
||||
busy
|
||||
>
|
||||
正在删除。
|
||||
</UnifiedConfirmDialog>,
|
||||
);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '删除作品' });
|
||||
const overlay = dialog.parentElement as HTMLElement;
|
||||
|
||||
expect(
|
||||
(
|
||||
within(dialog).getByRole('button', {
|
||||
name: '删除中',
|
||||
}) as HTMLButtonElement
|
||||
).disabled,
|
||||
).toBe(true);
|
||||
expect(
|
||||
(within(dialog).getByRole('button', { name: '取消' }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
|
||||
fireEvent.click(overlay);
|
||||
fireEvent.keyDown(window, { key: 'Escape' });
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '删除中' }));
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('can render inline inside an existing modal stack', () => {
|
||||
const { container } = render(
|
||||
<div data-testid="modal-stack">
|
||||
<UnifiedConfirmDialog
|
||||
open
|
||||
title="确认消耗泥点"
|
||||
onClose={() => {}}
|
||||
portal={false}
|
||||
showCancel
|
||||
>
|
||||
消耗 2 泥点
|
||||
</UnifiedConfirmDialog>
|
||||
</div>,
|
||||
);
|
||||
|
||||
const stack = screen.getByTestId('modal-stack');
|
||||
const dialog = within(stack).getByRole('dialog', { name: '确认消耗泥点' });
|
||||
|
||||
expect(dialog).toBeTruthy();
|
||||
expect(
|
||||
container.querySelector('[data-testid="modal-stack"] [role="dialog"]'),
|
||||
).toBe(dialog);
|
||||
});
|
||||
|
||||
test('can hide the header close button for compact confirmations', () => {
|
||||
render(
|
||||
<UnifiedConfirmDialog
|
||||
open
|
||||
title="确认消耗泥点"
|
||||
onClose={() => {}}
|
||||
showCloseButton={false}
|
||||
showCancel
|
||||
>
|
||||
消耗 2 泥点
|
||||
</UnifiedConfirmDialog>,
|
||||
);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '确认消耗泥点' });
|
||||
|
||||
expect(within(dialog).queryByRole('button', { name: '关闭' })).toBeNull();
|
||||
expect(within(dialog).getByRole('button', { name: '取消' })).toBeTruthy();
|
||||
expect(within(dialog).getByRole('button', { name: '确定' })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('allows callers to adapt the confirm button layout without custom footer markup', () => {
|
||||
render(
|
||||
<UnifiedConfirmDialog
|
||||
open
|
||||
title="发布失败"
|
||||
onClose={() => {}}
|
||||
confirmLabel="知道了"
|
||||
confirmClassName="w-full justify-center"
|
||||
footerClassName="border-t-0 px-5 pb-5 pt-0"
|
||||
>
|
||||
发布校验未通过。
|
||||
</UnifiedConfirmDialog>,
|
||||
);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '发布失败' });
|
||||
const confirmButton = within(dialog).getByRole('button', { name: '知道了' });
|
||||
|
||||
expect(confirmButton.className).toContain('w-full');
|
||||
expect(confirmButton.className).toContain('justify-center');
|
||||
});
|
||||
|
||||
test('supports pixel variant for in-editor confirmations', () => {
|
||||
render(
|
||||
<UnifiedConfirmDialog
|
||||
open
|
||||
title="确认退出"
|
||||
onClose={() => {}}
|
||||
variant="pixel"
|
||||
showCancel
|
||||
confirmLabel="仍然退出"
|
||||
cancelLabel="继续编辑"
|
||||
>
|
||||
当前生成画面还未保存。
|
||||
</UnifiedConfirmDialog>,
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
121
src/components/common/UnifiedConfirmDialog.tsx
Normal file
121
src/components/common/UnifiedConfirmDialog.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { PlatformActionButton } from './PlatformActionButton';
|
||||
import { UnifiedModal } from './UnifiedModal';
|
||||
|
||||
type UnifiedConfirmDialogTone = 'primary' | 'danger';
|
||||
type UnifiedConfirmDialogVariant = 'platform' | 'pixel';
|
||||
|
||||
type UnifiedConfirmDialogProps = {
|
||||
open: boolean;
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
children?: ReactNode;
|
||||
onClose: () => void;
|
||||
confirmLabel?: string;
|
||||
busyConfirmLabel?: string;
|
||||
onConfirm?: () => void;
|
||||
confirmTone?: UnifiedConfirmDialogTone;
|
||||
confirmClassName?: string;
|
||||
confirmDisabled?: boolean;
|
||||
cancelLabel?: string;
|
||||
showCancel?: boolean;
|
||||
cancelDisabled?: boolean;
|
||||
busy?: boolean;
|
||||
closeOnBackdrop?: boolean;
|
||||
showCloseButton?: boolean;
|
||||
portal?: boolean;
|
||||
variant?: UnifiedConfirmDialogVariant;
|
||||
size?: 'sm' | 'md';
|
||||
overlayClassName?: string;
|
||||
panelClassName?: string;
|
||||
footerClassName?: string;
|
||||
zIndexClassName?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 统一提示 / 确认弹窗。
|
||||
* 业务层只描述操作语义,按钮布局、处理中禁用和关闭路径统一在这里收口。
|
||||
*/
|
||||
export function UnifiedConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
onClose,
|
||||
confirmLabel = '确定',
|
||||
busyConfirmLabel,
|
||||
onConfirm,
|
||||
confirmTone = 'primary',
|
||||
confirmClassName,
|
||||
confirmDisabled = false,
|
||||
cancelLabel = '取消',
|
||||
showCancel = false,
|
||||
cancelDisabled = false,
|
||||
busy = false,
|
||||
closeOnBackdrop = true,
|
||||
showCloseButton = true,
|
||||
portal = true,
|
||||
variant = 'platform',
|
||||
size = 'sm',
|
||||
overlayClassName,
|
||||
panelClassName,
|
||||
footerClassName,
|
||||
zIndexClassName,
|
||||
}: UnifiedConfirmDialogProps) {
|
||||
const disabled = busy || confirmDisabled;
|
||||
const renderedConfirmLabel =
|
||||
busy && busyConfirmLabel ? busyConfirmLabel : confirmLabel;
|
||||
|
||||
return (
|
||||
<UnifiedModal
|
||||
open={open}
|
||||
title={title}
|
||||
description={description}
|
||||
onClose={onClose}
|
||||
closeDisabled={busy}
|
||||
closeOnBackdrop={closeOnBackdrop && !busy}
|
||||
showCloseButton={showCloseButton}
|
||||
portal={portal}
|
||||
variant={variant}
|
||||
size={size}
|
||||
overlayClassName={overlayClassName}
|
||||
panelClassName={panelClassName}
|
||||
zIndexClassName={zIndexClassName}
|
||||
bodyClassName="px-4 py-4 sm:px-5 sm:py-5"
|
||||
footerClassName={footerClassName ?? 'justify-end px-4 py-4 sm:px-5'}
|
||||
footer={
|
||||
<>
|
||||
{showCancel ? (
|
||||
<PlatformActionButton
|
||||
onClick={onClose}
|
||||
disabled={busy || cancelDisabled}
|
||||
tone="ghost"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
className="min-h-0 px-4 py-2"
|
||||
>
|
||||
{cancelLabel}
|
||||
</PlatformActionButton>
|
||||
) : null}
|
||||
<PlatformActionButton
|
||||
onClick={onConfirm ?? onClose}
|
||||
disabled={disabled}
|
||||
tone={confirmTone}
|
||||
size="sm"
|
||||
shape="pill"
|
||||
className={`min-h-0 px-4 py-2 ${confirmClassName ?? ''}`}
|
||||
>
|
||||
{renderedConfirmLabel}
|
||||
</PlatformActionButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{children ? (
|
||||
<div className="text-sm leading-6 text-[var(--platform-text-base)]">
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
</UnifiedModal>
|
||||
);
|
||||
}
|
||||
62
src/components/common/platformActionButtonModel.test.ts
Normal file
62
src/components/common/platformActionButtonModel.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import { getPlatformActionButtonClassName } from './platformActionButtonModel';
|
||||
|
||||
test('builds platform action button class names', () => {
|
||||
const className = getPlatformActionButtonClassName({
|
||||
surface: 'platform',
|
||||
tone: 'danger',
|
||||
size: 'xs',
|
||||
shape: 'pill',
|
||||
fullWidth: true,
|
||||
});
|
||||
|
||||
expect(className).toContain('platform-button--danger');
|
||||
expect(className).toContain('rounded-full');
|
||||
expect(className).toContain('w-full');
|
||||
expect(className).toContain('text-xs');
|
||||
});
|
||||
|
||||
test('builds profile primary button class names', () => {
|
||||
const className = getPlatformActionButtonClassName({
|
||||
surface: 'profile',
|
||||
tone: 'primary',
|
||||
});
|
||||
|
||||
expect(className).toContain('platform-primary-button');
|
||||
expect(className).toContain('rounded-2xl');
|
||||
expect(className).toContain('disabled:cursor-not-allowed');
|
||||
});
|
||||
|
||||
test('builds large form action button class names', () => {
|
||||
const className = getPlatformActionButtonClassName({
|
||||
size: 'lg',
|
||||
});
|
||||
|
||||
expect(className).toContain('h-12');
|
||||
expect(className).toContain('text-base');
|
||||
});
|
||||
|
||||
test('builds left aligned action button class names', () => {
|
||||
const className = getPlatformActionButtonClassName({
|
||||
align: 'start',
|
||||
});
|
||||
|
||||
expect(className).toContain('justify-start');
|
||||
expect(className).toContain('text-left');
|
||||
});
|
||||
|
||||
test('builds editor dark action button class names', () => {
|
||||
const className = getPlatformActionButtonClassName({
|
||||
surface: 'editorDark',
|
||||
tone: 'success',
|
||||
size: 'xxs',
|
||||
shape: 'pill',
|
||||
});
|
||||
|
||||
expect(className).toContain('platform-action-button--editor-dark');
|
||||
expect(className).toContain('border-emerald-300/35');
|
||||
expect(className).toContain('bg-emerald-400');
|
||||
expect(className).toContain('text-[10px]');
|
||||
expect(className).toContain('rounded-full');
|
||||
});
|
||||
120
src/components/common/platformActionButtonModel.ts
Normal file
120
src/components/common/platformActionButtonModel.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
export type PlatformActionButtonTone =
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'ghost'
|
||||
| 'danger'
|
||||
| 'success'
|
||||
| 'warning';
|
||||
export type PlatformActionButtonSurface = 'platform' | 'profile' | 'editorDark';
|
||||
export type PlatformActionButtonSize = 'xxs' | 'xs' | 'sm' | 'md' | 'lg';
|
||||
export type PlatformActionButtonShape = 'default' | 'pill';
|
||||
export type PlatformActionButtonAlign = 'center' | 'start';
|
||||
|
||||
const PLATFORM_ACTION_BUTTON_PLATFORM_TONE_CLASS: Record<
|
||||
PlatformActionButtonTone,
|
||||
string
|
||||
> = {
|
||||
primary: 'platform-button platform-button--primary',
|
||||
secondary: 'platform-button platform-button--secondary',
|
||||
ghost: 'platform-button platform-button--ghost',
|
||||
danger: 'platform-button platform-button--danger',
|
||||
success: 'platform-button platform-button--primary',
|
||||
warning: 'platform-button platform-button--secondary',
|
||||
};
|
||||
|
||||
const PLATFORM_ACTION_BUTTON_PROFILE_TONE_CLASS: Record<
|
||||
PlatformActionButtonTone,
|
||||
string
|
||||
> = {
|
||||
primary: 'platform-primary-button',
|
||||
secondary: 'platform-button platform-button--secondary',
|
||||
ghost: 'platform-button platform-button--ghost',
|
||||
danger: 'platform-button platform-button--danger',
|
||||
success: 'platform-primary-button',
|
||||
warning: 'platform-button platform-button--secondary',
|
||||
};
|
||||
|
||||
const PLATFORM_ACTION_BUTTON_EDITOR_DARK_TONE_CLASS: Record<
|
||||
PlatformActionButtonTone,
|
||||
string
|
||||
> = {
|
||||
primary:
|
||||
'platform-action-button platform-action-button--editor-dark border border-sky-300/35 bg-sky-400 text-slate-950 hover:bg-sky-300',
|
||||
secondary:
|
||||
'platform-action-button platform-action-button--editor-dark border border-white/10 bg-white/5 text-zinc-200 hover:bg-white/10 hover:text-white',
|
||||
ghost:
|
||||
'platform-action-button platform-action-button--editor-dark border border-white/10 bg-black/20 text-zinc-200 hover:text-white',
|
||||
danger:
|
||||
'platform-action-button platform-action-button--editor-dark border border-rose-300/25 bg-rose-500/10 text-rose-100 hover:bg-rose-500/15',
|
||||
success:
|
||||
'platform-action-button platform-action-button--editor-dark border border-emerald-300/35 bg-emerald-400 text-slate-950 hover:bg-emerald-300',
|
||||
warning:
|
||||
'platform-action-button platform-action-button--editor-dark border border-amber-300/30 bg-amber-500/20 text-amber-50 hover:bg-amber-500/25',
|
||||
};
|
||||
|
||||
const PLATFORM_ACTION_BUTTON_SIZE_CLASS: Record<
|
||||
PlatformActionButtonSize,
|
||||
string
|
||||
> = {
|
||||
xxs: 'px-3 py-1 text-[10px]',
|
||||
xs: 'px-4 py-2 text-xs',
|
||||
sm: 'px-4 py-2.5 text-sm',
|
||||
md: 'px-4 py-3 text-sm',
|
||||
lg: 'h-12 px-4 text-base',
|
||||
};
|
||||
|
||||
const PLATFORM_ACTION_BUTTON_SHAPE_CLASS: Record<
|
||||
PlatformActionButtonShape,
|
||||
string
|
||||
> = {
|
||||
default: 'rounded-2xl',
|
||||
pill: 'rounded-full',
|
||||
};
|
||||
|
||||
const PLATFORM_ACTION_BUTTON_ALIGN_CLASS: Record<
|
||||
PlatformActionButtonAlign,
|
||||
string
|
||||
> = {
|
||||
center: 'justify-center',
|
||||
start: 'justify-start text-left',
|
||||
};
|
||||
|
||||
function getPlatformActionButtonToneClass(
|
||||
surface: PlatformActionButtonSurface,
|
||||
tone: PlatformActionButtonTone,
|
||||
) {
|
||||
if (surface === 'editorDark') {
|
||||
return PLATFORM_ACTION_BUTTON_EDITOR_DARK_TONE_CLASS[tone];
|
||||
}
|
||||
|
||||
return surface === 'profile'
|
||||
? PLATFORM_ACTION_BUTTON_PROFILE_TONE_CLASS[tone]
|
||||
: PLATFORM_ACTION_BUTTON_PLATFORM_TONE_CLASS[tone];
|
||||
}
|
||||
|
||||
export function getPlatformActionButtonClassName({
|
||||
surface = 'platform',
|
||||
tone = 'primary',
|
||||
size = 'sm',
|
||||
shape = 'default',
|
||||
align = 'center',
|
||||
fullWidth = false,
|
||||
}: {
|
||||
surface?: PlatformActionButtonSurface;
|
||||
tone?: PlatformActionButtonTone;
|
||||
size?: PlatformActionButtonSize;
|
||||
shape?: PlatformActionButtonShape;
|
||||
align?: PlatformActionButtonAlign;
|
||||
fullWidth?: boolean;
|
||||
}) {
|
||||
return [
|
||||
getPlatformActionButtonToneClass(surface, tone),
|
||||
PLATFORM_ACTION_BUTTON_SIZE_CLASS[size],
|
||||
PLATFORM_ACTION_BUTTON_SHAPE_CLASS[shape],
|
||||
PLATFORM_ACTION_BUTTON_ALIGN_CLASS[align],
|
||||
fullWidth ? 'w-full' : null,
|
||||
'inline-flex items-center gap-2 transition-colors font-black disabled:cursor-not-allowed disabled:opacity-60',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
68
src/components/common/platformPillBadgeModel.ts
Normal file
68
src/components/common/platformPillBadgeModel.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
export type PlatformPillBadgeTone =
|
||||
| 'muted'
|
||||
| 'neutral'
|
||||
| 'neutralSolid'
|
||||
| 'lightOverlay'
|
||||
| 'success'
|
||||
| 'warning'
|
||||
| 'danger'
|
||||
| 'cool'
|
||||
| 'profile'
|
||||
| 'profileAccent'
|
||||
| 'darkSoft'
|
||||
| 'darkNeutral'
|
||||
| 'darkSky'
|
||||
| 'darkEmerald'
|
||||
| 'darkAmber'
|
||||
| 'darkRose';
|
||||
|
||||
export type PlatformPillBadgeSize = 'xxs' | 'xs' | 'sm';
|
||||
|
||||
const PLATFORM_PILL_BADGE_TONE_CLASS: Record<PlatformPillBadgeTone, string> = {
|
||||
muted:
|
||||
'border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)] text-[var(--platform-text-soft)]',
|
||||
neutral:
|
||||
'border-[var(--platform-subpanel-border)] bg-white/72 text-[var(--platform-text-base)]',
|
||||
neutralSolid:
|
||||
'border-transparent bg-[var(--platform-neutral-bg)] text-[var(--platform-neutral-text)]',
|
||||
lightOverlay: 'border-white/30 bg-white/24 text-current',
|
||||
success: 'border-emerald-200 bg-emerald-50 text-emerald-700',
|
||||
warning:
|
||||
'border-[var(--platform-warm-border)] bg-[var(--platform-warm-bg)] text-[var(--platform-warm-text)]',
|
||||
danger:
|
||||
'border-[var(--platform-button-danger-border)] bg-[var(--platform-button-danger-fill)] text-[var(--platform-button-danger-text)]',
|
||||
cool: 'border-[var(--platform-cool-border)] bg-[var(--platform-cool-bg)] text-[var(--platform-cool-text)]',
|
||||
profile: 'border-rose-100 bg-rose-50 text-zinc-600',
|
||||
profileAccent: 'border-rose-100 bg-rose-50 text-[#ff4056]',
|
||||
darkSoft: 'border-white/10 bg-white/6 text-zinc-100',
|
||||
darkNeutral: 'border-white/10 bg-black/20 text-zinc-200',
|
||||
darkSky: 'border-sky-300/20 bg-sky-500/10 text-sky-100',
|
||||
darkEmerald: 'border-emerald-300/20 bg-emerald-500/10 text-emerald-100',
|
||||
darkAmber: 'border-amber-300/20 bg-amber-500/10 text-amber-100',
|
||||
darkRose: 'border-rose-300/20 bg-rose-500/10 text-rose-100',
|
||||
};
|
||||
|
||||
const PLATFORM_PILL_BADGE_SIZE_CLASS: Record<PlatformPillBadgeSize, string> = {
|
||||
xxs: 'px-2.5 py-1 text-[10px]',
|
||||
xs: 'px-2.5 py-0.5 text-[11px]',
|
||||
sm: 'px-3 py-1 text-xs',
|
||||
};
|
||||
|
||||
export function getPlatformPillBadgeClassName({
|
||||
tone = 'neutral',
|
||||
size = 'sm',
|
||||
className,
|
||||
}: {
|
||||
tone?: PlatformPillBadgeTone;
|
||||
size?: PlatformPillBadgeSize;
|
||||
className?: string;
|
||||
} = {}) {
|
||||
return [
|
||||
'inline-flex items-center gap-1 rounded-full border font-black',
|
||||
PLATFORM_PILL_BADGE_SIZE_CLASS[size],
|
||||
PLATFORM_PILL_BADGE_TONE_CLASS[tone],
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
38
src/components/common/squareImageCropModel.test.ts
Normal file
38
src/components/common/squareImageCropModel.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
buildCenteredSquareImageCropRect,
|
||||
clampSquareImageCropRect,
|
||||
} from './squareImageCropModel';
|
||||
|
||||
test('builds a centered square crop from a rectangular image', () => {
|
||||
expect(buildCenteredSquareImageCropRect({ width: 1200, height: 800 })).toEqual({
|
||||
x: 200,
|
||||
y: 0,
|
||||
size: 800,
|
||||
});
|
||||
});
|
||||
|
||||
test('clamps square crop size and position inside image bounds', () => {
|
||||
expect(
|
||||
clampSquareImageCropRect(
|
||||
{ width: 300, height: 200 },
|
||||
{ x: 260, y: -30, size: 500 },
|
||||
),
|
||||
).toEqual({
|
||||
x: 100,
|
||||
y: 0,
|
||||
size: 200,
|
||||
});
|
||||
|
||||
expect(
|
||||
clampSquareImageCropRect(
|
||||
{ width: 300, height: 200 },
|
||||
{ x: 120, y: 90, size: 10 },
|
||||
),
|
||||
).toEqual({
|
||||
x: 120,
|
||||
y: 90,
|
||||
size: 48,
|
||||
});
|
||||
});
|
||||
46
src/components/common/squareImageCropModel.ts
Normal file
46
src/components/common/squareImageCropModel.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
export type SquareImageCropRect = {
|
||||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
};
|
||||
|
||||
export function clampNumber(value: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
export function getSquareCropSizeBounds(imageSize: {
|
||||
width: number;
|
||||
height: number;
|
||||
}) {
|
||||
const maxSize = Math.max(1, Math.min(imageSize.width, imageSize.height));
|
||||
const minSize = Math.min(maxSize, Math.max(48, maxSize * 0.18));
|
||||
|
||||
return { minSize, maxSize };
|
||||
}
|
||||
|
||||
export function buildCenteredSquareImageCropRect(imageSize: {
|
||||
width: number;
|
||||
height: number;
|
||||
}): SquareImageCropRect {
|
||||
const size = Math.max(1, Math.min(imageSize.width, imageSize.height));
|
||||
|
||||
return {
|
||||
x: Math.max(0, (imageSize.width - size) / 2),
|
||||
y: Math.max(0, (imageSize.height - size) / 2),
|
||||
size,
|
||||
};
|
||||
}
|
||||
|
||||
export function clampSquareImageCropRect(
|
||||
imageSize: { width: number; height: number },
|
||||
crop: SquareImageCropRect,
|
||||
): SquareImageCropRect {
|
||||
const { minSize, maxSize } = getSquareCropSizeBounds(imageSize);
|
||||
const size = clampNumber(crop.size, minSize, maxSize);
|
||||
|
||||
return {
|
||||
x: clampNumber(crop.x, 0, Math.max(0, imageSize.width - size)),
|
||||
y: clampNumber(crop.y, 0, Math.max(0, imageSize.height - size)),
|
||||
size,
|
||||
};
|
||||
}
|
||||
85
src/components/common/useCopyFeedback.test.tsx
Normal file
85
src/components/common/useCopyFeedback.test.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import * as clipboardService from '../../services/clipboard';
|
||||
import { useCopyFeedback } from './useCopyFeedback';
|
||||
|
||||
vi.mock('../../services/clipboard', () => ({
|
||||
copyTextToClipboard: vi.fn(),
|
||||
}));
|
||||
|
||||
function CopyFeedbackHarness({
|
||||
value,
|
||||
resetDelayMs = 1400,
|
||||
}: {
|
||||
value: string;
|
||||
resetDelayMs?: number;
|
||||
}) {
|
||||
const { copyState, copyText, resetCopyState } = useCopyFeedback({
|
||||
resetDelayMs,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="copy-state">{copyState}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void copyText(value);
|
||||
}}
|
||||
>
|
||||
复制
|
||||
</button>
|
||||
<button type="button" onClick={resetCopyState}>
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('useCopyFeedback', () => {
|
||||
test('copies text and resets after the feedback delay', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(clipboardService.copyTextToClipboard).mockResolvedValue(true);
|
||||
|
||||
render(<CopyFeedbackHarness value="作品号:PZ-1" resetDelayMs={10} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }));
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(screen.getByTestId('copy-state').textContent).toBe('copied');
|
||||
expect(clipboardService.copyTextToClipboard).toHaveBeenCalledWith(
|
||||
'作品号:PZ-1',
|
||||
);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(10);
|
||||
});
|
||||
expect(screen.getByTestId('copy-state').textContent).toBe('idle');
|
||||
});
|
||||
|
||||
test('keeps failure state until reset or timeout', async () => {
|
||||
vi.mocked(clipboardService.copyTextToClipboard).mockResolvedValue(false);
|
||||
|
||||
render(<CopyFeedbackHarness value="" />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('copy-state').textContent).toBe('failed');
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '重置' }));
|
||||
|
||||
expect(screen.getByTestId('copy-state').textContent).toBe('idle');
|
||||
});
|
||||
});
|
||||
54
src/components/common/useCopyFeedback.ts
Normal file
54
src/components/common/useCopyFeedback.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { copyTextToClipboard } from '../../services/clipboard';
|
||||
|
||||
export type CopyFeedbackState = 'idle' | 'copied' | 'failed';
|
||||
|
||||
type UseCopyFeedbackOptions = {
|
||||
resetDelayMs?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* 统一剪贴板复制反馈。
|
||||
* 复制实现、成功/失败状态和定时复位集中在这里,调用方只负责渲染按钮文案。
|
||||
*/
|
||||
export function useCopyFeedback({
|
||||
resetDelayMs = 1400,
|
||||
}: UseCopyFeedbackOptions = {}) {
|
||||
const [copyState, setCopyState] = useState<CopyFeedbackState>('idle');
|
||||
const resetTimerRef = useRef<number | null>(null);
|
||||
|
||||
const clearResetTimer = useCallback(() => {
|
||||
if (resetTimerRef.current !== null) {
|
||||
window.clearTimeout(resetTimerRef.current);
|
||||
resetTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resetCopyState = useCallback(() => {
|
||||
clearResetTimer();
|
||||
setCopyState('idle');
|
||||
}, [clearResetTimer]);
|
||||
|
||||
const copyText = useCallback(
|
||||
async (value: string) => {
|
||||
const copied = await copyTextToClipboard(value);
|
||||
setCopyState(copied ? 'copied' : 'failed');
|
||||
clearResetTimer();
|
||||
resetTimerRef.current = window.setTimeout(() => {
|
||||
resetTimerRef.current = null;
|
||||
setCopyState('idle');
|
||||
}, resetDelayMs);
|
||||
return copied;
|
||||
},
|
||||
[clearResetTimer, resetDelayMs],
|
||||
);
|
||||
|
||||
useEffect(() => resetCopyState, [resetCopyState]);
|
||||
|
||||
return {
|
||||
copyState,
|
||||
copyText,
|
||||
resetCopyState,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user