继续扩展共享异步状态壳
将 PlatformAsyncStatePanel 扩展到公共素材选择网格 将 PlatformAsyncStatePanel 扩展到视觉小说存档面板与账号安全子区块 将兑换码弹窗提交动作改为使用标准 profile modal footer 补充对应测试并更新 PlatformUiKit 收口计划与共享决策记录
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user