继续沉淀工具信息弹窗与个人中心内容骨架
新增PlatformUtilityInfoModal统一工具信息弹窗白底骨架 收口profile副弹层的摘要头列表骨架与内容行 同步更新PlatformUiKit收口计划与共享决策记录
This commit is contained in:
@@ -64,6 +64,8 @@
|
||||
- 2026-06-11 追加:`PlatformFilterToolbar.tsx` 作为薄结构组件收口 RPG 首页分类工具条;组件只承接“筛选按钮 + tabs + 排序按钮”的排布与 `mobile / desktop` 两种布局差异,不持有筛选状态、空态或排序逻辑。后续只有在同构壳层真的复现时才继续往 `common` 扩覆盖面;如果只是单页内局部重复、接口会越抽越胖,就优先退回文件内 helper。
|
||||
- 2026-06-11 追加:`SquareImageCropModal.tsx` 的白底弹窗壳层改为复用 `UnifiedModal.tsx`,同时给 `UnifiedModal` 薄补 `titleId` 与 `closeIcon` 透传,让裁剪弹窗继续保留自定义 close icon、无 backdrop / Escape 关闭和两列 footer,而不把 `PlatformProfileModalShell` 这类带页面语义的壳层倒灌回 `common/`。这条规则适用于 `common` 级工具弹窗:先看 `UnifiedModal` 能不能承接,再决定是否需要新的薄壳。
|
||||
- 2026-06-11 追加:`CreativeImageInputPanel.tsx` 里参考图预览、主图预览和移除图片确认都继续并回 `UnifiedModal` 体系:两个预览弹窗直接复用 `UnifiedModal`,删除确认直接复用 `UnifiedConfirmDialog`,不再在图片面板里手写三段 `platform-modal-backdrop + platform-modal-shell`。当前没有新增 `PlatformImagePreviewModal`,因为这批差异还只在尺寸与文案层,继续组合已有 modal 原语的 leverage 更高。
|
||||
- 2026-06-11 追加:`src/components/common/PlatformUtilityInfoModal.tsx` 作为 `UnifiedModal` 之上的薄壳,统一承接 `PlatformReportDialog.tsx` 与 `PublishShareModal.tsx` 共同的工具信息弹窗骨架:平台主题 overlay、白底 panel,以及 body / footer 间距与标准 footer frame。该壳层不继续向上吸收报告字段列表、分享正文、复制逻辑、渠道按钮或品牌 icon;后续 `common` 级工具信息弹窗若只是重复这套白底信息壳,优先复用 `PlatformUtilityInfoModal`,业务正文和 footer 交互继续留在调用方。验证命令:`npx vitest run src/components/common/PlatformUtilityInfoModal.test.tsx src/components/common/PlatformReportDialog.test.tsx src/components/common/PublishShareModal.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||
- 2026-06-11 追加:profile 白底副弹层里的摘要头、列表骨架和内容行继续沉到 `PlatformProfileSummaryHeader.tsx`、`PlatformProfileSkeletonList.tsx` 与 `PlatformProfileContentRow.tsx`;这组组件只承接 `kicker + title + badge` 摘要层次、重复 skeleton 行以及 `PlatformSubpanel` 上的 `div / button` 内容行语义,不持有账单金额、任务进度、邀请用户信息、充值商品结构或状态切换逻辑。后续 profile modal 若只是重复这三类白底内容骨架,优先复用这组薄组件,不再把 skeleton、摘要头和 row chrome 写回各自 modal。验证命令:`npx vitest run src/components/common/PlatformProfileModalContent.shared.test.tsx src/components/platform-entry/PlatformProfileTaskCenterModal.test.tsx src/components/platform-entry/PlatformProfileWalletLedgerModal.test.tsx src/components/platform-entry/PlatformProfilePlayedWorksModal.test.tsx src/components/platform-entry/PlatformProfileReferralModal.test.tsx src/components/platform-entry/PlatformProfileRechargeModal.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||
- 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-11 追加:按钮层继续补齐轻量漏网项。`PlatformTagEditor.tsx` 的标签 chip 删除入口已改成紧凑 `PlatformIconButton`,保留透明背景和原 chip 高度;`RpgEntryCharacterSelectView.tsx` 的两处“返回”按钮统一沉到局部 `CharacterSelectBackButton`,底层委托 `PlatformActionButton surface="editorDark"`。同日 `GenerationProgressHero.tsx` 新增 `GenerationHeaderBackButton`,`CustomWorldGenerationView.tsx` 与 `BarkBattleGeneratingView.tsx` 已开始复用这套暖色生成页返回入口骨架;后续同类轻量返回按钮与 chip 删除按钮优先继续沿共享按钮 + 薄包装的方向推进。
|
||||
|
||||
@@ -274,6 +274,8 @@
|
||||
19.3.48. `RpgEntryHomeView.tsx` 里的分类筛选工具条继续从页面内重复 JSX 收口到 `src/components/common/PlatformFilterToolbar.tsx`;该 Module 只承接“筛选按钮 + 横向 tabs + 排序按钮”的结构排布,暴露 `mobile / desktop` 两种 layout 以覆盖移动端 divider + 独立排序行和桌面端同排布局差异,但不持有分类列表、筛选状态、空态或排序逻辑。当前 RPG 首页分类区已接入,后续若其它白底列表页也出现同构的筛选壳层,可直接复用这套薄结构组件;若场景只是在单页内局部重复、接口会为了兼容业务差异不断膨胀,则优先退回文件内 helper,不把 `common` 扩成假的“万能筛选条”。验证命令:`npx vitest run src/components/common/PlatformFilterToolbar.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||
19.3.49. `SquareImageCropModal.tsx` 的白底 modal 壳层与 footer 已收口到 `src/components/common/UnifiedModal.tsx`;`UnifiedModal` 为此只薄补了 `titleId` 与 `closeIcon` 透传,继续由调用方决定 `closeOnBackdrop`、`closeOnEscape`、`portal`、header/footer 样式和按钮内容,不额外掺入 profile 业务语义,也不让 `common/` 反向依赖 `platform-entry/`。`SquareImageCropModal.tsx` 继续保留裁剪拖拽、pointer capture、保存禁用态与两列等宽 footer 行为,只把 header / body / footer 外壳交给共享 modal 承接。后续 `common` 级白底工具弹窗若只是标准标题栏 + 内容区 + footer 按钮排布,优先先看 `UnifiedModal` 是否够用,再决定是否需要新的薄壳;不要为了一个弹窗把 `PlatformProfileModalShell` 之类带页面语义的壳层倒灌回 `common`。验证命令:`npx vitest run src/components/common/SquareImageCropModal.test.tsx src/components/common/UnifiedModal.test.tsx src/components/unified-creation/workspaces/PuzzleCreationWorkspace.interaction.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||
19.3.50. `CreativeImageInputPanel.tsx` 里内嵌的 white tool modal 继续并回 `UnifiedModal` 体系:参考图预览与主图预览都改成直接复用 `src/components/common/UnifiedModal.tsx`,继续保留各自 `max-w` / `max-h` 节奏、点击遮罩关闭与紧凑 header;移除图片确认改成复用 `src/components/common/UnifiedConfirmDialog.tsx`,不再在 panel 内手写 `platform-modal-backdrop + platform-modal-shell + 两列按钮`。这次没有新增 `PlatformImagePreviewModal`,因为当前预览弹窗差异还只在尺寸和文案层,继续直接组合 `UnifiedModal` 更深、更稳。后续 `common` 级图片面板若出现同类“预览大图 + 单标题栏 + 关闭按钮”弹窗,优先先复用 `UnifiedModal` 并把尺寸/文案留在调用方;只有当至少两到三个调用点开始重复同一套 preview body/header adapter 时,再考虑补新的薄壳。验证命令:`npx vitest run src/components/common/CreativeImageInputPanel.test.tsx src/components/common/UnifiedModal.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||
19.3.51. `PlatformReportDialog.tsx` 与 `PublishShareModal.tsx` 共同的工具信息弹窗壳层继续收口到 `src/components/common/PlatformUtilityInfoModal.tsx`;该 Module 只承接平台主题 overlay、白底 panel,以及 body / footer 的基础间距与标准 footer frame,底层继续委托 `UnifiedModal.tsx`,不吸收报告字段列表、分享正文、复制逻辑、渠道按钮或品牌图标这些业务内容。`PlatformReportDialog.tsx` 继续保留 `PlatformInfoBlock` 字段列表与 joined report copy 行为,`PublishShareModal.tsx` 继续保留分享文案、主复制动作和渠道按钮网格;后续 `common` 级白底工具信息弹窗若只是重复这套“共享 modal 外壳 + 业务正文 / footer 内容”的骨架,优先复用 `PlatformUtilityInfoModal`,只有当正文编排或 footer 交互明显偏离时才回退到直接组合 `UnifiedModal`。验证命令:`npx vitest run src/components/common/PlatformUtilityInfoModal.test.tsx src/components/common/PlatformReportDialog.test.tsx src/components/common/PublishShareModal.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||
19.3.52. profile 白底 modal 里的摘要头、列表骨架和内容行继续沉到 `src/components/common/PlatformProfileSummaryHeader.tsx`、`src/components/common/PlatformProfileSkeletonList.tsx` 与 `src/components/common/PlatformProfileContentRow.tsx`;这三个 Module 只承接 `kicker + title + badge` 的摘要层次、重复 skeleton 列表行,以及 `PlatformSubpanel` 上的 `div / button` 内容行语义,不持有账单金额、任务进度、邀请用户信息、充值商品结构或 modal 状态切换逻辑。`PlatformProfileWalletLedgerModal.tsx`、`PlatformProfileTaskCenterModal.tsx`、`PlatformProfilePlayedWorksModal.tsx`、`PlatformProfileReferralModal.tsx` 与 `PlatformProfileRechargeModal.tsx` 已接入;后续 profile 副弹层若只是重复这三类白底内容骨架,优先继续复用这组薄组件,不再把 skeleton、摘要头和 row chrome 写回各自 modal。验证命令:`npx vitest run src/components/common/PlatformProfileModalContent.shared.test.tsx src/components/platform-entry/PlatformProfileTaskCenterModal.test.tsx src/components/platform-entry/PlatformProfileWalletLedgerModal.test.tsx src/components/platform-entry/PlatformProfilePlayedWorksModal.test.tsx src/components/platform-entry/PlatformProfileReferralModal.test.tsx src/components/platform-entry/PlatformProfileRechargeModal.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`。
|
||||
|
||||
63
src/components/common/PlatformProfileContentRow.tsx
Normal file
63
src/components/common/PlatformProfileContentRow.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { PlatformSubpanel } from './PlatformSubpanel';
|
||||
|
||||
type PlatformProfileContentRowProps = {
|
||||
as?: 'div' | 'button';
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
surface?: 'platform' | 'flat' | 'soft';
|
||||
radius?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
padding?: 'none' | 'row' | 'xs' | 'sm' | 'md' | 'lg' | 'tight';
|
||||
interactive?: boolean;
|
||||
disabled?: boolean;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 个人中心白底 modal 内容行骨架。
|
||||
* 只承接常见的 row 外壳与可点击语义,具体字段布局仍留在业务 modal 内部。
|
||||
*/
|
||||
export function PlatformProfileContentRow({
|
||||
as = 'div',
|
||||
children,
|
||||
className,
|
||||
surface = 'flat',
|
||||
radius = 'xs',
|
||||
padding = 'md',
|
||||
interactive = as === 'button',
|
||||
disabled,
|
||||
type = 'button',
|
||||
onClick,
|
||||
}: PlatformProfileContentRowProps) {
|
||||
if (as === 'button') {
|
||||
return (
|
||||
<PlatformSubpanel
|
||||
as="button"
|
||||
type={type}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
interactive={interactive}
|
||||
surface={surface}
|
||||
radius={radius}
|
||||
padding={padding}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</PlatformSubpanel>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
surface={surface}
|
||||
radius={radius}
|
||||
padding={padding}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</PlatformSubpanel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import { PlatformProfileContentRow } from './PlatformProfileContentRow';
|
||||
import { PlatformProfileSkeletonList } from './PlatformProfileSkeletonList';
|
||||
import { PlatformProfileSummaryHeader } from './PlatformProfileSummaryHeader';
|
||||
|
||||
test('platform profile summary header renders kicker, title and badge slot', () => {
|
||||
render(
|
||||
<PlatformProfileSummaryHeader
|
||||
kicker="PLAYED"
|
||||
title="玩过"
|
||||
badge={<span>1.5小时</span>}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('PLAYED')).toBeTruthy();
|
||||
expect(screen.getByText('玩过')).toBeTruthy();
|
||||
expect(screen.getByText('1.5小时')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('platform profile skeleton list renders requested skeleton rows', () => {
|
||||
const { container } = render(
|
||||
<PlatformProfileSkeletonList
|
||||
count={3}
|
||||
containerClassName="space-y-3"
|
||||
itemClassName="h-16"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelectorAll('.animate-pulse')).toHaveLength(3);
|
||||
expect(container.querySelector('.space-y-3')).toBeTruthy();
|
||||
expect(container.querySelector('.h-16')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('platform profile content row preserves interactive button semantics', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClick = vi.fn();
|
||||
|
||||
render(
|
||||
<PlatformProfileContentRow as="button" onClick={onClick}>
|
||||
点击行
|
||||
</PlatformProfileContentRow>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '点击行' }));
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
31
src/components/common/PlatformProfileSkeletonList.tsx
Normal file
31
src/components/common/PlatformProfileSkeletonList.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
type PlatformProfileSkeletonListProps = {
|
||||
count: number;
|
||||
containerClassName?: string;
|
||||
itemClassName?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 个人中心白底 modal 列表读取骨架。
|
||||
* 只负责重复 skeleton 行与容器节奏,行高和栅格继续由调用方微调。
|
||||
*/
|
||||
export function PlatformProfileSkeletonList({
|
||||
count,
|
||||
containerClassName,
|
||||
itemClassName,
|
||||
}: PlatformProfileSkeletonListProps) {
|
||||
return (
|
||||
<div className={containerClassName}>
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={[
|
||||
'animate-pulse rounded-xl bg-zinc-100',
|
||||
itemClassName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
src/components/common/PlatformProfileSummaryHeader.tsx
Normal file
39
src/components/common/PlatformProfileSummaryHeader.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type PlatformProfileSummaryHeaderProps = {
|
||||
kicker: ReactNode;
|
||||
title: ReactNode;
|
||||
badge?: ReactNode;
|
||||
className?: string;
|
||||
titleClassName?: string;
|
||||
badgeClassName?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 个人中心白底副弹层标题摘要骨架。
|
||||
* 只收口 kicker、标题和摘要 badge 的层次,不承接业务数值拼装。
|
||||
*/
|
||||
export function PlatformProfileSummaryHeader({
|
||||
kicker,
|
||||
title,
|
||||
badge,
|
||||
className,
|
||||
titleClassName,
|
||||
badgeClassName,
|
||||
}: PlatformProfileSummaryHeaderProps) {
|
||||
return (
|
||||
<div className={['pr-10', className].filter(Boolean).join(' ')}>
|
||||
<div className="text-[10px] font-black tracking-[0.22em] text-[#ff4056]">
|
||||
{kicker}
|
||||
</div>
|
||||
<div className={['mt-1 text-2xl font-black', titleClassName].filter(Boolean).join(' ')}>
|
||||
{title}
|
||||
</div>
|
||||
{badge ? (
|
||||
<div className={['mt-3', badgeClassName].filter(Boolean).join(' ')}>
|
||||
{badge}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo } from 'react';
|
||||
|
||||
import { CopyFeedbackButton } from './CopyFeedbackButton';
|
||||
import { PlatformInfoBlock } from './PlatformInfoBlock';
|
||||
import { UnifiedModal } from './UnifiedModal';
|
||||
import { PlatformUtilityInfoModal } from './PlatformUtilityInfoModal';
|
||||
import { useCopyFeedback } from './useCopyFeedback';
|
||||
|
||||
export type PlatformReportDialogField = {
|
||||
@@ -31,8 +31,8 @@ export function PlatformReportDialog({
|
||||
onClose,
|
||||
copyIdleLabel,
|
||||
fields,
|
||||
overlayClassName = 'platform-theme platform-theme--light !items-center',
|
||||
panelClassName = 'platform-remap-surface rounded-[1.5rem]',
|
||||
overlayClassName,
|
||||
panelClassName = 'rounded-[1.5rem]',
|
||||
}: PlatformReportDialogProps) {
|
||||
const { copyState, copyText, resetCopyState } = useCopyFeedback();
|
||||
const reportText = useMemo(() => buildPlatformReportText(fields), [fields]);
|
||||
@@ -50,14 +50,13 @@ export function PlatformReportDialog({
|
||||
};
|
||||
|
||||
return (
|
||||
<UnifiedModal
|
||||
<PlatformUtilityInfoModal
|
||||
open={open}
|
||||
title={title}
|
||||
onClose={onClose}
|
||||
size="sm"
|
||||
overlayClassName={overlayClassName}
|
||||
panelClassName={panelClassName}
|
||||
bodyClassName="space-y-3 px-4 py-4 sm:px-5 sm:py-5"
|
||||
bodyClassName="space-y-3"
|
||||
footerClassName="justify-end px-4 py-4 sm:px-5"
|
||||
footer={
|
||||
<CopyFeedbackButton
|
||||
@@ -82,6 +81,6 @@ export function PlatformReportDialog({
|
||||
</PlatformInfoBlock>
|
||||
))
|
||||
: null}
|
||||
</UnifiedModal>
|
||||
</PlatformUtilityInfoModal>
|
||||
);
|
||||
}
|
||||
|
||||
51
src/components/common/PlatformUtilityInfoModal.test.tsx
Normal file
51
src/components/common/PlatformUtilityInfoModal.test.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import { PlatformUtilityInfoModal } from './PlatformUtilityInfoModal';
|
||||
|
||||
test('renders platform utility info modal shell with default platform styling', () => {
|
||||
render(
|
||||
<PlatformUtilityInfoModal
|
||||
open
|
||||
title="工具信息"
|
||||
onClose={() => {}}
|
||||
footer={<button type="button">知道了</button>}
|
||||
panelClassName="rounded-[1.5rem]"
|
||||
>
|
||||
<div>这里是正文</div>
|
||||
</PlatformUtilityInfoModal>,
|
||||
);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '工具信息' });
|
||||
expect(dialog.parentElement?.className).toContain('platform-theme--light');
|
||||
expect(dialog.parentElement?.className).toContain('!items-center');
|
||||
expect(dialog.className).toContain('platform-remap-surface');
|
||||
expect(dialog.className).toContain('rounded-[1.5rem]');
|
||||
expect(within(dialog).getByText('这里是正文')).toBeTruthy();
|
||||
expect(within(dialog).getByRole('button', { name: '知道了' })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('supports custom theme and spacing overrides', () => {
|
||||
render(
|
||||
<PlatformUtilityInfoModal
|
||||
open
|
||||
title="工具信息"
|
||||
onClose={() => {}}
|
||||
platformTheme="dark"
|
||||
bodyClassName="space-y-3"
|
||||
footerClassName="justify-center pt-0"
|
||||
footer={<button type="button">继续</button>}
|
||||
>
|
||||
<div>这里是正文</div>
|
||||
</PlatformUtilityInfoModal>,
|
||||
);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '工具信息' });
|
||||
expect(dialog.parentElement?.className).toContain('platform-theme--dark');
|
||||
expect(dialog.querySelector('.space-y-3')).toBeTruthy();
|
||||
expect(dialog.querySelector('.justify-center')).toBeTruthy();
|
||||
expect(dialog.querySelector('.pt-0')).toBeTruthy();
|
||||
expect(within(dialog).getByRole('button', { name: '继续' })).toBeTruthy();
|
||||
});
|
||||
63
src/components/common/PlatformUtilityInfoModal.tsx
Normal file
63
src/components/common/PlatformUtilityInfoModal.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { UnifiedModal } from './UnifiedModal';
|
||||
|
||||
type PlatformUtilityInfoModalProps = {
|
||||
open: boolean;
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
platformTheme?: 'light' | 'dark';
|
||||
overlayClassName?: string;
|
||||
panelClassName?: string;
|
||||
bodyClassName?: string;
|
||||
footerClassName?: string;
|
||||
};
|
||||
|
||||
function joinClassNames(...classNames: Array<string | null | undefined | false>) {
|
||||
return classNames.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具信息类弹窗统一走平台主题 overlay + 白底 panel 壳层。
|
||||
* 这里只收口通用窗口骨架,字段内容、分享渠道和复制按钮仍由业务组件自己渲染。
|
||||
*/
|
||||
export function PlatformUtilityInfoModal({
|
||||
open,
|
||||
title,
|
||||
onClose,
|
||||
children,
|
||||
footer,
|
||||
platformTheme = 'light',
|
||||
overlayClassName,
|
||||
panelClassName,
|
||||
bodyClassName,
|
||||
footerClassName,
|
||||
}: PlatformUtilityInfoModalProps) {
|
||||
return (
|
||||
<UnifiedModal
|
||||
open={open}
|
||||
title={title}
|
||||
onClose={onClose}
|
||||
size="sm"
|
||||
overlayClassName={joinClassNames(
|
||||
`platform-theme platform-theme--${platformTheme}`,
|
||||
'!items-center',
|
||||
overlayClassName,
|
||||
)}
|
||||
panelClassName={joinClassNames('platform-remap-surface', panelClassName)}
|
||||
bodyClassName={joinClassNames(
|
||||
'space-y-4 px-4 py-4 sm:px-5 sm:py-5',
|
||||
bodyClassName,
|
||||
)}
|
||||
footer={footer}
|
||||
footerClassName={joinClassNames(
|
||||
'px-4 py-4 sm:px-5',
|
||||
footerClassName,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</UnifiedModal>
|
||||
);
|
||||
}
|
||||
@@ -4,11 +4,11 @@ import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { CopyFeedbackButton } from './CopyFeedbackButton';
|
||||
import { PlatformInfoBlock } from './PlatformInfoBlock';
|
||||
import { PlatformSubpanel } from './PlatformSubpanel';
|
||||
import { PlatformUtilityInfoModal } from './PlatformUtilityInfoModal';
|
||||
import {
|
||||
buildPublishShareText,
|
||||
type PublishShareModalPayload,
|
||||
} from './publishShareModalModel';
|
||||
import { UnifiedModal } from './UnifiedModal';
|
||||
import { useCopyFeedback } from './useCopyFeedback';
|
||||
|
||||
type PublishShareModalProps = {
|
||||
@@ -115,14 +115,12 @@ export function PublishShareModal({
|
||||
};
|
||||
|
||||
return (
|
||||
<UnifiedModal
|
||||
<PlatformUtilityInfoModal
|
||||
open={open && Boolean(payload)}
|
||||
title="分享给朋友"
|
||||
onClose={onClose}
|
||||
size="sm"
|
||||
overlayClassName={`platform-theme platform-theme--${platformTheme} !items-center`}
|
||||
panelClassName="platform-remap-surface rounded-[1.75rem]"
|
||||
bodyClassName="space-y-4 px-4 py-4 sm:px-5 sm:py-5"
|
||||
platformTheme={platformTheme}
|
||||
panelClassName="rounded-[1.75rem]"
|
||||
footerClassName="justify-center border-t-0 px-4 pb-5 pt-0 sm:px-5"
|
||||
footer={
|
||||
<div className="grid w-full grid-cols-3 gap-3">
|
||||
@@ -168,6 +166,6 @@ export function PublishShareModal({
|
||||
actionFullWidth
|
||||
className="disabled:opacity-55"
|
||||
/>
|
||||
</UnifiedModal>
|
||||
</PlatformUtilityInfoModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,9 +8,11 @@ import type {
|
||||
import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel';
|
||||
import { PlatformEmptyState } from '../common/PlatformEmptyState';
|
||||
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
|
||||
import { PlatformProfileContentRow } from '../common/PlatformProfileContentRow';
|
||||
import { PlatformProfileSkeletonList } from '../common/PlatformProfileSkeletonList';
|
||||
import { PlatformProfileSummaryHeader } from '../common/PlatformProfileSummaryHeader';
|
||||
import { PlatformPillBadge } from '../common/PlatformPillBadge';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { PlatformSubpanel } from '../common/PlatformSubpanel';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
import { PlatformProfileSecondaryModalShell } from './PlatformProfileModalShell';
|
||||
import {
|
||||
@@ -143,19 +145,19 @@ export function PlatformProfilePlayedWorksModal({
|
||||
panelClassName="relative !max-h-[min(92vh,42rem)] !max-w-[38rem] bg-white text-zinc-950 shadow-2xl !rounded-[1.35rem] sm:!rounded-[1.35rem]"
|
||||
contentClassName="relative max-h-[min(92vh,42rem)] overflow-y-auto px-4 pb-5 pt-4 sm:px-5"
|
||||
>
|
||||
<div className="pr-10">
|
||||
<div className="text-[10px] font-black tracking-[0.22em] text-[#ff4056]">
|
||||
PLAYED
|
||||
</div>
|
||||
<div className="mt-1 text-2xl font-black">玩过</div>
|
||||
<PlatformPillBadge
|
||||
tone="profile"
|
||||
icon={<Clock3 className="h-3.5 w-3.5 text-[#ff4056]" />}
|
||||
className="mt-2"
|
||||
>
|
||||
{formatTotalPlayTimeHours(stats?.totalPlayTimeMs ?? 0)}
|
||||
</PlatformPillBadge>
|
||||
</div>
|
||||
<PlatformProfileSummaryHeader
|
||||
kicker="PLAYED"
|
||||
title="玩过"
|
||||
badge={
|
||||
<PlatformPillBadge
|
||||
tone="profile"
|
||||
icon={<Clock3 className="h-3.5 w-3.5 text-[#ff4056]" />}
|
||||
>
|
||||
{formatTotalPlayTimeHours(stats?.totalPlayTimeMs ?? 0)}
|
||||
</PlatformPillBadge>
|
||||
}
|
||||
badgeClassName="mt-2"
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<PlatformStatusMessage tone="error" className="mt-4">
|
||||
@@ -171,14 +173,11 @@ export function PlatformProfilePlayedWorksModal({
|
||||
<PlatformAsyncStatePanel
|
||||
isLoading={isLoading}
|
||||
loadingState={
|
||||
<div className="mt-5 space-y-3">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="h-20 animate-pulse rounded-xl bg-zinc-100"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<PlatformProfileSkeletonList
|
||||
count={4}
|
||||
containerClassName="mt-5 space-y-3"
|
||||
itemClassName="h-20"
|
||||
/>
|
||||
}
|
||||
isEmpty={!hasArchiveEntries && !hasPlayedWorks}
|
||||
emptyState={
|
||||
@@ -218,9 +217,8 @@ export function PlatformProfilePlayedWorksModal({
|
||||
</PlatformFieldLabel>
|
||||
<div className="space-y-3">
|
||||
{playedWorks.map((work) => (
|
||||
<PlatformSubpanel
|
||||
<PlatformProfileContentRow
|
||||
as="button"
|
||||
type="button"
|
||||
key={`${work.worldKey}:${work.lastPlayedAt}`}
|
||||
onClick={() => onOpenWork?.(work)}
|
||||
surface="flat"
|
||||
@@ -244,20 +242,20 @@ export function PlatformProfilePlayedWorksModal({
|
||||
tone="profileAccent"
|
||||
size="xs"
|
||||
className="shrink-0 border-transparent"
|
||||
>
|
||||
{formatPlayedWorkType(work.worldType)}
|
||||
</PlatformPillBadge>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2 text-xs text-zinc-500 sm:grid-cols-3">
|
||||
>
|
||||
{formatPlayedWorkType(work.worldType)}
|
||||
</PlatformPillBadge>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2 text-xs text-zinc-500 sm:grid-cols-3">
|
||||
<span className="truncate">作品号 {formatPlayedWorkId(work)}</span>
|
||||
<span className="truncate">
|
||||
最近 {formatSnapshotTime(work.lastPlayedAt)}
|
||||
</span>
|
||||
<span className="truncate">
|
||||
时长 {formatCompactPlayTime(work.lastObservedPlayTimeMs)}
|
||||
</span>
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
时长 {formatCompactPlayTime(work.lastObservedPlayTimeMs)}
|
||||
</span>
|
||||
</div>
|
||||
</PlatformProfileContentRow>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel';
|
||||
import { PlatformEmptyState } from '../common/PlatformEmptyState';
|
||||
import { PlatformPillBadge } from '../common/PlatformPillBadge';
|
||||
import { PlatformProfileSkeletonList } from '../common/PlatformProfileSkeletonList';
|
||||
import { PlatformSegmentedTabs } from '../common/PlatformSegmentedTabs';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { PlatformSubpanel } from '../common/PlatformSubpanel';
|
||||
@@ -220,14 +221,11 @@ export function PlatformProfileRechargeModal({
|
||||
}
|
||||
isLoading={isLoading}
|
||||
loadingState={
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="h-28 animate-pulse rounded-[1.15rem] bg-white/10"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<PlatformProfileSkeletonList
|
||||
count={4}
|
||||
containerClassName="mt-4 grid gap-3 sm:grid-cols-2"
|
||||
itemClassName="h-28 rounded-[1.15rem] bg-white/10"
|
||||
/>
|
||||
}
|
||||
isEmpty={products.length === 0}
|
||||
emptyState={
|
||||
|
||||
@@ -9,6 +9,8 @@ import { CopyFeedbackButton } from '../common/CopyFeedbackButton';
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformEmptyState } from '../common/PlatformEmptyState';
|
||||
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
|
||||
import { PlatformProfileContentRow } from '../common/PlatformProfileContentRow';
|
||||
import { PlatformProfileSkeletonList } from '../common/PlatformProfileSkeletonList';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { PlatformSubpanel } from '../common/PlatformSubpanel';
|
||||
import { PlatformTextField } from '../common/PlatformTextField';
|
||||
@@ -139,10 +141,11 @@ export function PlatformProfileReferralModal({
|
||||
<PlatformAsyncStatePanel
|
||||
isLoading={isLoading}
|
||||
loadingState={
|
||||
<div className="mt-5 space-y-3">
|
||||
<div className="h-12 animate-pulse rounded-xl bg-zinc-100" />
|
||||
<div className="h-11 animate-pulse rounded-xl bg-zinc-100" />
|
||||
</div>
|
||||
<PlatformProfileSkeletonList
|
||||
count={2}
|
||||
containerClassName="mt-5 space-y-3"
|
||||
itemClassName="h-12 even:h-11"
|
||||
/>
|
||||
}
|
||||
isEmpty={Boolean(center?.hasRedeemedCode)}
|
||||
emptyState={
|
||||
@@ -193,10 +196,11 @@ export function PlatformProfileReferralModal({
|
||||
<PlatformAsyncStatePanel
|
||||
isLoading={isLoading}
|
||||
loadingState={
|
||||
<div className="mt-5 space-y-3">
|
||||
<div className="h-20 animate-pulse rounded-xl bg-zinc-100" />
|
||||
<div className="h-10 animate-pulse rounded-xl bg-zinc-100" />
|
||||
</div>
|
||||
<PlatformProfileSkeletonList
|
||||
count={2}
|
||||
containerClassName="mt-5 space-y-3"
|
||||
itemClassName="h-20 odd:h-20 even:h-10"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="mt-5 space-y-3">
|
||||
@@ -251,8 +255,7 @@ export function PlatformProfileReferralModal({
|
||||
{center?.invitedUsers?.length ? (
|
||||
<div className="mt-3 max-h-44 space-y-2 overflow-y-auto pr-1">
|
||||
{center.invitedUsers.map((user) => (
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
<PlatformProfileContentRow
|
||||
key={`${user.userId}-${user.boundAt}`}
|
||||
surface="soft"
|
||||
radius="xs"
|
||||
@@ -268,7 +271,7 @@ export function PlatformProfileReferralModal({
|
||||
{user.displayName || '玩家'}
|
||||
</div>
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
</PlatformProfileContentRow>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { ProfileTaskCenterResponse } from '../../../packages/shared/src/contracts/runtime';
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel';
|
||||
import { PlatformProfileContentRow } from '../common/PlatformProfileContentRow';
|
||||
import { PlatformProfileSkeletonList } from '../common/PlatformProfileSkeletonList';
|
||||
import { PlatformEmptyState } from '../common/PlatformEmptyState';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { PlatformSubpanel } from '../common/PlatformSubpanel';
|
||||
import { PlatformProfileModalShell } from './PlatformProfileModalShell';
|
||||
import {
|
||||
buildProfileTaskProgressLabel,
|
||||
@@ -84,14 +85,11 @@ export function PlatformProfileTaskCenterModal({
|
||||
}
|
||||
isLoading={isLoading}
|
||||
loadingState={
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 2 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="h-20 animate-pulse rounded-2xl bg-white/10"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<PlatformProfileSkeletonList
|
||||
count={2}
|
||||
containerClassName="space-y-3"
|
||||
itemClassName="h-20 rounded-2xl bg-white/10"
|
||||
/>
|
||||
}
|
||||
isEmpty={tasks.length === 0}
|
||||
emptyState={
|
||||
@@ -107,8 +105,7 @@ export function PlatformProfileTaskCenterModal({
|
||||
const progressLabel = buildProfileTaskProgressLabel(task);
|
||||
|
||||
return (
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
<PlatformProfileContentRow
|
||||
key={task.taskId}
|
||||
radius="sm"
|
||||
padding="md"
|
||||
@@ -141,7 +138,7 @@ export function PlatformProfileTaskCenterModal({
|
||||
>
|
||||
{getProfileTaskClaimButtonLabel(task, isClaiming)}
|
||||
</PlatformActionButton>
|
||||
</PlatformSubpanel>
|
||||
</PlatformProfileContentRow>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -4,9 +4,11 @@ import type { ProfileWalletLedgerResponse } from '../../../packages/shared/src/c
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel';
|
||||
import { PlatformEmptyState } from '../common/PlatformEmptyState';
|
||||
import { PlatformProfileContentRow } from '../common/PlatformProfileContentRow';
|
||||
import { PlatformProfileSkeletonList } from '../common/PlatformProfileSkeletonList';
|
||||
import { PlatformProfileSummaryHeader } from '../common/PlatformProfileSummaryHeader';
|
||||
import { PlatformPillBadge } from '../common/PlatformPillBadge';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { PlatformSubpanel } from '../common/PlatformSubpanel';
|
||||
import { PlatformProfileSecondaryModalShell } from './PlatformProfileModalShell';
|
||||
import { buildWalletLedgerPresentation } from '../rpg-entry/rpgEntryProfileFundsViewModel';
|
||||
import { formatPlatformWorldTime } from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
@@ -47,19 +49,19 @@ export function PlatformProfileWalletLedgerModal({
|
||||
panelClassName="relative !max-h-[min(92vh,42rem)] !max-w-[30rem] bg-[linear-gradient(180deg,#fff7f8_0%,#ffffff_38%,#f8fafc_100%)] text-zinc-950 shadow-2xl !rounded-[1.35rem] sm:!rounded-[1.35rem]"
|
||||
contentClassName="relative max-h-[min(92vh,42rem)] overflow-y-auto px-4 pb-5 pt-4 sm:px-5"
|
||||
>
|
||||
<div className="pr-10">
|
||||
<div className="text-[10px] font-black tracking-[0.22em] text-[#ff4056]">
|
||||
LEDGER
|
||||
</div>
|
||||
<div className="mt-1 text-2xl font-black">泥点账单</div>
|
||||
<PlatformPillBadge
|
||||
tone="profile"
|
||||
icon={<Coins className="h-3.5 w-3.5 text-[#ff4056]" />}
|
||||
className="mt-3 bg-white/70"
|
||||
>
|
||||
{walletLedgerPresentation.balanceLabel}
|
||||
</PlatformPillBadge>
|
||||
</div>
|
||||
<PlatformProfileSummaryHeader
|
||||
kicker="LEDGER"
|
||||
title="泥点账单"
|
||||
badge={
|
||||
<PlatformPillBadge
|
||||
tone="profile"
|
||||
icon={<Coins className="h-3.5 w-3.5 text-[#ff4056]" />}
|
||||
className="bg-white/70"
|
||||
>
|
||||
{walletLedgerPresentation.balanceLabel}
|
||||
</PlatformPillBadge>
|
||||
}
|
||||
/>
|
||||
|
||||
<PlatformAsyncStatePanel
|
||||
errorState={
|
||||
@@ -83,14 +85,11 @@ export function PlatformProfileWalletLedgerModal({
|
||||
}
|
||||
isLoading={isLoading}
|
||||
loadingState={
|
||||
<div className="mt-5 space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="h-16 animate-pulse rounded-xl bg-zinc-100"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<PlatformProfileSkeletonList
|
||||
count={5}
|
||||
containerClassName="mt-5 space-y-3"
|
||||
itemClassName="h-16"
|
||||
/>
|
||||
}
|
||||
isEmpty={entries.length === 0}
|
||||
emptyState={
|
||||
@@ -105,8 +104,7 @@ export function PlatformProfileWalletLedgerModal({
|
||||
>
|
||||
<div className="mt-5 space-y-2.5">
|
||||
{entries.map((entry) => (
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
<PlatformProfileContentRow
|
||||
key={entry.id}
|
||||
surface="flat"
|
||||
radius="xs"
|
||||
@@ -133,7 +131,7 @@ export function PlatformProfileWalletLedgerModal({
|
||||
{entry.balanceLabel}
|
||||
</div>
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
</PlatformProfileContentRow>
|
||||
))}
|
||||
</div>
|
||||
</PlatformAsyncStatePanel>
|
||||
|
||||
Reference in New Issue
Block a user