继续扩展共享异步状态壳

将 PlatformAsyncStatePanel 扩展到公共素材选择网格

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

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

补充对应测试并更新 PlatformUiKit 收口计划与共享决策记录
This commit is contained in:
2026-06-11 04:11:32 +08:00
parent 7431b1b9a4
commit 7f8400fd3a
10 changed files with 220 additions and 115 deletions

View File

@@ -32,6 +32,9 @@ function renderAccountModal(overrides?: {
riskBlocks?: AuthRiskBlockSummary[];
sessions?: AuthSessionSummary[];
auditLogs?: AuthAuditLogEntry[];
loadingRiskBlocks?: boolean;
loadingSessions?: boolean;
loadingAuditLogs?: boolean;
onRevokeSession?: (session: AuthSessionSummary) => Promise<void>;
revokingSessionIds?: string[];
initialSection?:
@@ -52,9 +55,9 @@ function renderAccountModal(overrides?: {
riskBlocks={overrides?.riskBlocks ?? []}
sessions={overrides?.sessions ?? []}
auditLogs={overrides?.auditLogs ?? []}
loadingRiskBlocks={false}
loadingSessions={false}
loadingAuditLogs={false}
loadingRiskBlocks={overrides?.loadingRiskBlocks ?? false}
loadingSessions={overrides?.loadingSessions ?? false}
loadingAuditLogs={overrides?.loadingAuditLogs ?? false}
isHydratingSettings={false}
isPersistingSettings={false}
settingsError={null}
@@ -461,6 +464,37 @@ test('account panel empty shells reuse PlatformEmptyState subpanel chrome', asyn
}
});
test('account panel loading shells reuse PlatformEmptyState subpanel chrome', async () => {
const user = userEvent.setup();
renderAccountModal({
loadingRiskBlocks: true,
loadingSessions: true,
loadingAuditLogs: true,
});
await user.click(screen.getByRole('button', { name: /账号与安全/ }));
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
const loadingMessages = [
'正在读取安全状态...',
'正在读取当前登录设备...',
'正在读取账号操作记录...',
];
for (const message of loadingMessages) {
const shell = findNearestClassName(
within(accountDialog).getByText(message),
'platform-empty-state',
);
expect(shell?.className).toContain('rounded-[1rem]');
expect(shell?.className).toContain('bg-white/74');
expect(shell?.className).toContain('px-4');
expect(shell?.className).toContain('py-3');
}
});
test('current merged session group hides kick action and shows count', async () => {
const user = userEvent.setup();

View File

@@ -16,6 +16,7 @@ import type {
AuthUser,
} from '../../services/authService';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
@@ -742,12 +743,21 @@ export function AccountModal({
</div>
<div className="mt-3 grid gap-2.5">
{loadingRiskBlocks ? (
<AccountSubpanelState>
...
</AccountSubpanelState>
) : riskBlocks.length > 0 ? (
riskBlocks.map((block) => (
<PlatformAsyncStatePanel
isLoading={loadingRiskBlocks}
loadingState={
<AccountSubpanelState>
...
</AccountSubpanelState>
}
isEmpty={riskBlocks.length === 0}
emptyState={
<AccountSubpanelState>
</AccountSubpanelState>
}
>
{riskBlocks.map((block) => (
<PlatformStatusMessage
key={`${block.scopeType}:${block.expiresAt}`}
tone="warning"
@@ -778,12 +788,8 @@ export function AccountModal({
</PlatformActionButton>
</PlatformStatusMessage>
))
) : (
<AccountSubpanelState>
</AccountSubpanelState>
)}
))}
</PlatformAsyncStatePanel>
</div>
</PlatformSubpanel>
@@ -813,12 +819,21 @@ export function AccountModal({
</div>
<div className="mt-3 grid gap-2.5">
{loadingSessions ? (
<AccountSubpanelState>
...
</AccountSubpanelState>
) : sessions.length > 0 ? (
sessions.map((session) => {
<PlatformAsyncStatePanel
isLoading={loadingSessions}
loadingState={
<AccountSubpanelState>
...
</AccountSubpanelState>
}
isEmpty={sessions.length === 0}
emptyState={
<AccountSubpanelState>
</AccountSubpanelState>
}
>
{sessions.map((session) => {
const isRevoking = revokingSessionIds.includes(
session.sessionId,
);
@@ -879,12 +894,8 @@ export function AccountModal({
) : null}
</PlatformSubpanel>
);
})
) : (
<AccountSubpanelState>
</AccountSubpanelState>
)}
})}
</PlatformAsyncStatePanel>
</div>
</PlatformSubpanel>
@@ -914,12 +925,21 @@ export function AccountModal({
</div>
<div className="mt-3 grid gap-2.5">
{loadingAuditLogs ? (
<AccountSubpanelState>
...
</AccountSubpanelState>
) : auditLogs.length > 0 ? (
auditLogs.map((log) => (
<PlatformAsyncStatePanel
isLoading={loadingAuditLogs}
loadingState={
<AccountSubpanelState>
...
</AccountSubpanelState>
}
isEmpty={auditLogs.length === 0}
emptyState={
<AccountSubpanelState>
</AccountSubpanelState>
}
>
{auditLogs.map((log) => (
<PlatformSubpanel
as="div"
key={log.id}
@@ -943,12 +963,8 @@ export function AccountModal({
</div>
) : null}
</PlatformSubpanel>
))
) : (
<AccountSubpanelState>
</AccountSubpanelState>
)}
))}
</PlatformAsyncStatePanel>
</div>
</PlatformSubpanel>

View File

@@ -187,6 +187,26 @@ test('renders selectable asset grid cards with shared error chrome', () => {
);
});
test('asset grid keeps error banner while loading state remains mutually exclusive with empty state', () => {
render(
<PlatformAssetPickerGrid
items={[]}
isLoading
error="历史素材读取失败。"
loadingLabel="读取中..."
emptyLabel="暂无历史素材"
getKey={(item: { id: string }) => item.id}
getImageSrc={(item) => item.id}
getImageAlt={() => ''}
onSelect={() => {}}
/>,
);
expect(screen.getByText('历史素材读取失败。')).toBeTruthy();
expect(screen.getByText('读取中...')).toBeTruthy();
expect(screen.queryByText('暂无历史素材')).toBeNull();
});
test('supports dark editor surface with an in-card select affordance', () => {
const onSelect = vi.fn();

View File

@@ -1,6 +1,7 @@
import type { ButtonHTMLAttributes, Key, ReactNode } from 'react';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import { PlatformAsyncStatePanel } from './PlatformAsyncStatePanel';
import { PlatformEmptyState } from './PlatformEmptyState';
import { PlatformStatusMessage } from './PlatformStatusMessage';
@@ -250,6 +251,13 @@ export function PlatformAssetPickerGrid<TItem>({
imageClassName,
bodyClassName,
}: PlatformAssetPickerGridProps<TItem>) {
const sharedEmptyStateClassName = [
PLATFORM_ASSET_PICKER_GRID_EMPTY_CLASS[surface],
emptyClassName,
]
.filter(Boolean)
.join(' ');
return (
<>
{error ? (
@@ -262,61 +270,53 @@ export function PlatformAssetPickerGrid<TItem>({
{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}
<PlatformAsyncStatePanel
isLoading={isLoading}
loadingState={
<PlatformEmptyState
surface="dashed"
size="panel"
className={sharedEmptyStateClassName}
>
{loadingLabel}
</PlatformEmptyState>
}
isEmpty={items.length <= 0}
emptyState={
<PlatformEmptyState
surface="dashed"
size="panel"
className={sharedEmptyStateClassName}
>
{emptyLabel}
</PlatformEmptyState>
}
>
{items.length > 0 ? (
<div className={gridClassName}>
{items.map((item) => (
<PlatformAssetPickerCard
key={getKey(item)}
disabled={disabled}
aria-label={getAriaLabel?.(item)}
onClick={() => onSelect(item)}
imageSrc={getImageSrc(item)}
imageAlt={getImageAlt(item)}
assetTitle={getTitle?.(item)}
subtitle={getSubtitle?.(item)}
surface={surface}
selectLabel={selectLabel}
selected={isSelected?.(item) ?? false}
className={cardClassName}
cardRadiusClassName={cardRadiusClassName}
imageShellClassName={imageShellClassName}
imageClassName={imageClassName}
bodyClassName={bodyClassName}
/>
))}
</div>
) : null}
</PlatformAsyncStatePanel>
</>
);
}

View File

@@ -51,4 +51,25 @@ describe('PlatformProfileRewardCodeRedeemModal', () => {
screen.getByRole('button', { name: '兑换' }).hasAttribute('disabled'),
).toBe(true);
});
test('reuses the shared profile modal footer chrome for submit action', () => {
render(
<PlatformProfileRewardCodeRedeemModal
value="ab12"
isSubmitting={false}
error={null}
success={null}
onChange={vi.fn()}
onSubmit={vi.fn()}
onClose={vi.fn()}
/>,
);
const submitButton = screen.getByRole('button', { name: '兑换' });
const footer = submitButton.closest('div');
expect(footer?.className).toContain('border-t');
expect(footer?.className).toContain('pb-5');
expect(footer?.className).toContain('pt-0');
});
});

View File

@@ -33,6 +33,19 @@ export function PlatformProfileRewardCodeRedeemModal({
closeLabel="关闭兑换码"
panelClassName="platform-recharge-modal !max-w-sm rounded-[1.4rem]"
bodyClassName="space-y-3 px-5 py-5"
footerClassName="px-5 pb-5 pt-0"
footer={
<PlatformActionButton
surface="profile"
fullWidth
size="md"
className="disabled:opacity-50"
onClick={onSubmit}
disabled={isSubmitting || !value.trim()}
>
{isSubmitting ? '兑换中' : '兑换'}
</PlatformActionButton>
}
>
<PlatformTextField
value={value}
@@ -49,16 +62,6 @@ export function PlatformProfileRewardCodeRedeemModal({
aria-label="兑换码"
autoFocus
/>
<PlatformActionButton
surface="profile"
fullWidth
size="md"
className="disabled:opacity-50"
onClick={onSubmit}
disabled={isSubmitting || !value.trim()}
>
{isSubmitting ? '兑换中' : '兑换'}
</PlatformActionButton>
{error ? (
<PlatformStatusMessage
tone="error"

View File

@@ -50,10 +50,12 @@ test('visual novel save panel uses platform empty states', () => {
);
expect(screen.getByText('读取中').className).toContain('bg-white/74');
expect(screen.queryByText('暂无存档')).toBeNull();
rerender(<VisualNovelSavePanel run={mockVisualNovelRun} saveArchives={[]} />);
expect(screen.getByText('暂无存档').className).toContain('bg-white/74');
expect(screen.queryByText('读取中')).toBeNull();
});
test('visual novel runtime list cards use platform subpanel chrome', () => {

View File

@@ -3,6 +3,7 @@ import { Bookmark, Loader2, Play } from 'lucide-react';
import type { ProfileSaveArchiveSummary } from '../../../packages/shared/src/contracts/runtime';
import type { VisualNovelRunSnapshot } from '../../../packages/shared/src/contracts/visualNovel';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
import { PlatformIconBadge } from '../common/PlatformIconBadge';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
@@ -57,11 +58,20 @@ export function VisualNovelSavePanel({
<span>{isSaving ? '保存中' : '保存'}</span>
</PlatformActionButton>
{isLoadingArchives ? (
<PlatformEmptyState surface="subpanel" size="inline">
</PlatformEmptyState>
) : saveArchives.length > 0 ? (
<PlatformAsyncStatePanel
isLoading={isLoadingArchives}
loadingState={
<PlatformEmptyState surface="subpanel" size="inline">
</PlatformEmptyState>
}
isEmpty={saveArchives.length === 0}
emptyState={
<PlatformEmptyState surface="subpanel" size="inline">
</PlatformEmptyState>
}
>
<div className="grid gap-3">
{saveArchives.map((entry) => {
const isResuming = resumingWorldKey === entry.worldKey;
@@ -104,11 +114,7 @@ export function VisualNovelSavePanel({
);
})}
</div>
) : (
<PlatformEmptyState surface="subpanel" size="inline">
</PlatformEmptyState>
)}
</PlatformAsyncStatePanel>
</div>
);
}