继续扩展共享异步状态壳
将 PlatformAsyncStatePanel 扩展到公共素材选择网格 将 PlatformAsyncStatePanel 扩展到视觉小说存档面板与账号安全子区块 将兑换码弹窗提交动作改为使用标准 profile modal footer 补充对应测试并更新 PlatformUiKit 收口计划与共享决策记录
This commit is contained in:
@@ -60,6 +60,7 @@
|
||||
- 2026-06-11 追加:`PlatformAsyncStatePanel` 继续补齐 RPG 首页分类分支;移动端“发现 -> 分类”、桌面发现页“分类”和桌面首页“作品分类”模块现在都统一委托共享状态壳切换外层 `loading / empty / content`,分类控制条与排序按钮继续留在内容 slot 中。筛选后无结果的“当前筛选下没有作品。”也统一改成内层 `PlatformAsyncStatePanel` 切换,不再在三处 JSX 中各自维护嵌套 ternary。
|
||||
- 2026-06-11 追加:`PlatformDarkModalFooter` 不只收动作按钮区,也继续覆盖纯内容 footer;`CompanionCampModal.tsx` 底部“营地气氛”区域已改成 `layout="content"` + `padding="roomy"` 的共享 footer frame,保留原有文案和卡片布局,不再单独手写 `border-t border-white/10 px-5 py-4`。
|
||||
- 2026-06-11 追加:`PlatformProfileModalShell` 继续补齐标准 footer 插槽,直接透传 `UnifiedModal.footer` 与 `footerClassName`;`RpgEntryHomeView.tsx` 的昵称修改弹窗已改成标准 profile footer,不再把双按钮动作区手写在 body 末尾。后续个人中心里同类“表单内容 + 底部双按钮”弹窗优先走壳层 footer 接法。
|
||||
- 2026-06-11 追加:`PlatformProfileModalShell` 的标准 footer 接法继续扩展到单 CTA 表单收尾;`PlatformProfileRewardCodeRedeemModal.tsx` 的兑换按钮已迁到壳层 footer,body 只保留输入和反馈消息。`PlatformAsyncStatePanel` 同日继续扩展到 `PlatformAssetPickerGrid`、`VisualNovelSavePanel.tsx` 与 `AccountModal.tsx` 的账号安全三个子区块;其中公共素材网格继续把 `error` banner 放在状态壳外层,保持错误提示可与加载态或内容并存的原语义。
|
||||
- 2026-06-09 追加:通用输入 Composer 的上传参考图、发送和移除参考图已迁移到 `PlatformIconButton`;图标上传仍使用 `asChild="label"` 保留 label + file input 语义,公共组件会自动写入隐藏文本,确保内嵌 file input 继承可访问名称。
|
||||
- 2026-06-10 追加:creation-agent composer 的上传文档 / 上传参考图入口使用 `PlatformIconButton` 默认 `platformIcon`;工作台只保留动态 label、title、busy 状态和 picker 回调,发送按钮继续保留主题色动作布局。验证命令:`npm run test -- src/components/creation-agent/CreationAgentWorkspace.test.tsx src/components/common/PlatformIconButton.test.tsx`。
|
||||
- 2026-06-10 追加:作品详情顶部返回 / 分享和封面轮播上一张 / 下一张入口使用 `PlatformIconButton variant="platformIcon"`;详情页保留原 `platform-work-detail__*` 局部 class 控制位置和尺寸,点赞、复制三态等专用动作暂不迁移。验证命令:`npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/common/PlatformIconButton.test.tsx`。
|
||||
|
||||
@@ -237,7 +237,8 @@
|
||||
19.3.13. RPG 首页个人中心的充值 / 钱包 / 每日任务 / 邀请 / 兑换码等商业与账户控制逻辑收口到 `src/components/platform-entry/usePlatformProfileCenterController.ts`;`RpgEntryHomeView` 仅保留个人中心展示、昵称头像编辑、扫码入口和页面级编排 / 交互,不再直接承接账户动作分流、商业状态派生和面板控制。该收口默认保持现有弹层与充值链路语义不变,避免在职责迁移时顺带扩张行为面。验证命令:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`。
|
||||
19.3.14. RPG 首页个人中心的“玩过 / 可继续”历史弹层抽到 `src/components/platform-entry/PlatformProfilePlayedWorksModal.tsx`;`RpgEntryHomeView` 不再内联 `SaveArchiveCard`、`ProfilePlayedWorksModal` 和旧的 `ProfileSaveArchivesModal`。当前真实产品语义已经把存档恢复并入“玩过”弹层的“可继续”分区,因此未连通的 `saveArchives` profile popup 分支一并删除,避免继续维护没有入口的独立壳层。组件级验证新增 `src/components/platform-entry/PlatformProfilePlayedWorksModal.test.tsx`,并继续复用 `RpgEntryHomeView.recharge.test.tsx` 的个人中心集成断言。验证命令:`npm run test -- src/components/platform-entry/PlatformProfilePlayedWorksModal.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`。
|
||||
19.3.15. 个人中心标准头部弹窗与白底副弹层壳层统一抽到 `src/components/platform-entry/PlatformProfileModalShell.tsx`;`PlatformProfileModalShell` 负责标准账户弹窗的 overlay、header、title、description、close variant 和 `closeOnBackdrop={false} / closeOnEscape={false}` 约束,`PlatformProfileSecondaryModalShell` 负责白底副弹层的 overlay、floating close、`bodyClassName="!p-0"` 和内容外壳。`RpgEntryHomeView` 内的昵称修改、账户充值、每日任务、兑换码、泥点账单与“玩过”弹层已接到共享壳层,页面不再重复手写个人中心弹窗的基础 chrome 与关闭策略。
|
||||
19.3.15.1. `PlatformProfileModalShell` 继续补齐标准 footer 插槽:壳层现已直接透传 `UnifiedModal.footer` 与 `footerClassName`,`RpgEntryHomeView.tsx` 的昵称修改弹窗不再把双按钮动作区塞在 body 末尾,而是改成标准 profile modal footer。后续个人中心里同类“表单 body + 底部双按钮动作区”弹窗,优先走 `PlatformProfileModalShell + footer`,不要把共享按钮再手写回内容区。验证命令:`npx vitest run src/components/platform-entry/PlatformProfileModalShell.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||
19.3.15.1. `PlatformProfileModalShell` 继续补齐标准 footer 插槽:壳层现已直接透传 `UnifiedModal.footer` 与 `footerClassName`,`RpgEntryHomeView.tsx` 的昵称修改弹窗不再把双按钮动作区塞在 body 末尾,而是改成标准 profile modal footer。后续个人中心里同类“表单 body + 底部双按钮动作区”弹窗,优先走 `PlatformProfileModalShell + footer`,不要把共享按钮再手写回内容区。
|
||||
19.3.15.2. `PlatformProfileModalShell` 的 footer 接法继续扩展到单 CTA 表单收尾:`PlatformProfileRewardCodeRedeemModal.tsx` 的兑换动作已迁到标准 profile footer,body 仅保留输入与反馈消息;后续个人中心里这种“输入表单 + 底部唯一主动作”弹窗,也优先复用壳层 footer,而不是把按钮继续塞在内容区。验证命令:`npx vitest run src/components/platform-entry/PlatformProfileModalShell.test.tsx src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.test.tsx src/components/common/PlatformAssetPickerCard.test.tsx src/components/visual-novel-runtime/VisualNovelRuntimePanels.emptyState.test.tsx src/components/auth/AccountModal.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||
19.3.16. RPG 首页个人中心的邀请好友 / 填邀请码 / 玩家社区三态弹层抽到 `src/components/platform-entry/PlatformProfileReferralModal.tsx`;组件统一复用 `PlatformProfileSecondaryModalShell` 承接居中白底浮层、floatingPlain 关闭按钮和成功 / 失败提示区,`RpgEntryHomeView` 不再内联邀请码规范化、社区二维码卡片和邀请用户头像行。组件级验证新增 `src/components/platform-entry/PlatformProfileReferralModal.test.tsx`,首页继续复用 `RpgEntryHomeView.recharge.test.tsx` 的邀请链路断言。验证命令:`npm run test -- src/components/platform-entry/PlatformProfileReferralModal.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "renders invite panel with shared profile content|submits redeem panel with the shared form shell|renders community QR panels|profile community shortcut shows reward subtitle and invited users|invite query opens redeem modal directly for logged in users|profile redeem invite query modal submits code after login"`、`npm run typecheck`。
|
||||
19.3.17. RPG 首页个人中心的账户充值弹层抽到 `src/components/platform-entry/PlatformProfileRechargeModal.tsx`;组件承接 Native 二维码生成、点数 / 会员 tab、套餐卡片、空态和错误重试,继续复用 `PlatformProfileModalShell` 与平台白底卡片 token,`RpgEntryHomeView` 不再内联 `useWechatNativeQrCode`、`RechargeProductCard` 和 `ProfileRechargeModal`。组件级验证新增 `src/components/platform-entry/PlatformProfileRechargeModal.test.tsx`,首页继续复用 `RpgEntryHomeView.recharge.test.tsx` 的充值入口与 Native 二维码断言。验证命令:`npm run test -- src/components/platform-entry/PlatformProfileRechargeModal.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "renders point products and forwards buy action|shows empty state when the selected tab has no products|profile recharge modal shows native qr code on desktop web by default|create tab wallet chip opens recharge when recharge entry is enabled"`、`npm run typecheck`。
|
||||
19.3.18. RPG 首页个人中心的泥点账单、每日任务和兑换码三类标准 profile 弹层分别抽到 `src/components/platform-entry/PlatformProfileWalletLedgerModal.tsx`、`src/components/platform-entry/PlatformProfileTaskCenterModal.tsx` 与 `src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.tsx`;账单继续复用 `PlatformProfileSecondaryModalShell`,任务和兑换码继续复用 `PlatformProfileModalShell`,页面不再内联账单余额 badge、任务领取列表和兑换码输入提交实现。三者均新增组件级测试,并继续复用 `RpgEntryHomeView.recharge.test.tsx` 的真实入口断言。验证命令:`npm run test -- src/components/platform-entry/PlatformProfileWalletLedgerModal.test.tsx src/components/platform-entry/PlatformProfileTaskCenterModal.test.tsx src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "renders ledger entries with shared balance presentation|retries from the shared error state|renders claimable tasks and forwards claim action|keeps incomplete tasks disabled|submits on button click and enter key|disables submit when the code is blank|opens wallet ledger modal from narrative coin card|profile daily task shortcut reflects task progress and claim updates|wallet ledger modal shows empty and error states|opens reward code modal from profile action on mobile|create tab wallet chip opens reward code when recharge entry is hidden"`、`npm run typecheck`。
|
||||
@@ -264,6 +265,7 @@
|
||||
19.3.39. 桌面首页里的轻量可点击扁平行统一收口到 `src/components/common/PlatformNavigableListItem.tsx`;该 Module 只承接 `button + 左侧主内容 + 右侧 affordance` 的结构、默认 `type="button"` 和 `leading / trailing` 插槽,不承接卡片封面、复杂摘要或 runtime 专属交互。`RpgEntryHomeView.tsx` 的搜索结果行、桌面“最近作品”、桌面“最近浏览”以及桌面“今日游戏”趋势行已接入。教培 promo card、分类卡片、世界卡和 runtime 列表项继续保留各自语义,等出现更多同构 desktop flat row 再逐步扩覆盖面。验证命令:`npx vitest run src/components/common/PlatformNavigableListItem.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||
19.3.40. `PlatformNavigableListItem` 继续从桌面首页扩展到 profile 设置行:`src/components/platform-entry/PlatformProfilePrimitives.tsx` 里的 `ProfileSettingsRow` 现已统一委托共享 `button + leading + trailing` 骨架,保留本地 `platform-profile-settings-row` class 承接行间分隔、icon 胶囊和字号微调。后续 profile / 账户中心里同类“左图标标题 + 右箭头”的轻量导航行,优先直接复用 `PlatformNavigableListItem`,不要再回退成原生 `<button>` 手写布局。验证命令:`npx vitest run src/components/platform-entry/PlatformProfilePrimitives.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||
19.3.41. `PlatformAsyncStatePanel` 继续补齐首页分类分支:`RpgEntryHomeView.tsx` 的移动端“发现 -> 分类”、桌面发现页“分类”以及桌面首页“作品分类”模块都改成共享状态壳承接外层 `loading / empty / content` 切换,分类控制条与排序按钮继续保留在内容 slot 中;筛选后无结果的“当前筛选下没有作品。”也统一改由内层 `PlatformAsyncStatePanel` 切换,不再在三处 JSX 中各自手写空态分支。后续同类“外层数据可用性 + 内层筛选空态”面板优先沿用这套双层状态壳,不要回退成嵌套 ternary。验证命令:`npx vitest run src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||
19.3.42. `PlatformAsyncStatePanel` 继续从首页扩展到公共素材网格、runtime 面板和账号子区块:`PlatformAssetPickerGrid` 现已统一用共享状态壳承接 `loading / empty / content`,但继续把 `error` banner 留在外层,以保持“错误提示可与内容或加载态并存”的原语义;`VisualNovelSavePanel.tsx` 的存档列表,以及 `AccountModal.tsx` 里的“安全状态 / 当前登录设备 / 账号操作记录”三个子区块也都改成各自使用 `PlatformAsyncStatePanel`。后续白底列表、素材选择器或账号子面板若只是标准互斥异步状态,优先按这三种接法复用共享状态壳,不再把读取态和空态分支手写回组件内部。验证命令:`npx vitest run src/components/common/PlatformAssetPickerCard.test.tsx src/components/visual-novel-runtime/VisualNovelRuntimePanels.emptyState.test.tsx src/components/auth/AccountModal.test.tsx src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||
19.3. creative-agent 首页的侧边栏菜单、账号入口、开启新对话、我的创作、首页激励 CTA 和 prompt suggestion 按钮迁移到 `PlatformIconButton` / `PlatformActionButton`;首页继续保留 `creative-agent-home__*` 本地 class 承接透明顶栏、抽屉和品牌化胶囊视觉,不把视觉回收和语义收口绑成一次大改。`Beta` 徽标和历史记录纯文本行暂保留本地实现,等出现更多同构轻量列表行后再评估是否抽新的共享 row primitive。
|
||||
19.4. 大鱼吃小鱼结果页 hero 的返回入口迁移到 `PlatformIconButton variant="darkMini"`,测试 / 发布动作迁移到 `PlatformActionButton surface="editorDark"`;结果页只保留测试运行、发布提交和文案状态语义,不再手写 hero 顶栏按钮壳。
|
||||
19.4.1. 大鱼吃小鱼结果页的发布失败弹层迁移到 `src/components/common/PlatformStatusDialog.tsx`;`PlatformStatusDialog` 补充自定义图标、可访问标签和动作按钮样式透传后,`BigFishResultView` 不再保留 `BigFishResultErrorModal` 内联的 `UnifiedConfirmDialog + PlatformIconBadge` 组合。结果页只保留失败文案和关闭回调,发布失败的状态图标、遮罩、白底面板和“知道了”主动作统一由共享状态弹层承接。验证命令:`npm run test -- src/components/common/PlatformStatusDialog.test.tsx src/components/big-fish-result/BigFishResultView.test.tsx`、`npm run typecheck`。
|
||||
|
||||
@@ -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