收口个人中心标准弹窗壳层

扩展 UnifiedModal 支持关闭按钮变体与局部交互语义覆写
将昵称修改充值任务与兑换码弹窗迁移到 UnifiedModal
更新 PlatformUiKit 收口计划和 Hermes 决策记录
This commit is contained in:
2026-06-10 17:05:05 +08:00
parent 9a04ea55dc
commit ba5f84d963
5 changed files with 400 additions and 343 deletions

View File

@@ -54,6 +54,7 @@
- 2026-06-10 追加RPG 大编辑器主壳层和紧凑对话壳层的右上角关闭入口使用 `PlatformModalCloseButton variant="platformIcon"`,暗色编辑器保留 `platform-icon-button` 视觉 token但业务 JSX 不再手写关闭按钮 aria、默认 X 图标和禁用态拼接。 - 2026-06-10 追加RPG 大编辑器主壳层和紧凑对话壳层的右上角关闭入口使用 `PlatformModalCloseButton variant="platformIcon"`,暗色编辑器保留 `platform-icon-button` 视觉 token但业务 JSX 不再手写关闭按钮 aria、默认 X 图标和禁用态拼接。
- 2026-06-10 追加:`PlatformModalCloseButton variant="editorDark"` 承接 RPG 暗色弹窗中非像素风的圆形 X 关闭入口,根节点固定带 `platform-modal-close-button--editor-dark` 稳定类名;自定义选择弹窗头部关闭按钮已迁移,并补齐 `aria-label`,业务 JSX 不再手写暗色关闭按钮边框、底色、hover 和默认 X 图标。验证命令:`npm run test -- src/components/common/PlatformModalCloseButton.test.tsx src/components/SelectionCustomizationModals.test.tsx` - 2026-06-10 追加:`PlatformModalCloseButton variant="editorDark"` 承接 RPG 暗色弹窗中非像素风的圆形 X 关闭入口,根节点固定带 `platform-modal-close-button--editor-dark` 稳定类名;自定义选择弹窗头部关闭按钮已迁移,并补齐 `aria-label`,业务 JSX 不再手写暗色关闭按钮边框、底色、hover 和默认 X 图标。验证命令:`npm run test -- src/components/common/PlatformModalCloseButton.test.tsx src/components/SelectionCustomizationModals.test.tsx`
- 2026-06-10 追加:`PlatformModalCloseButton variant="pixel"` 承接 `UnifiedModal variant="pixel"` 头部圆形关闭入口;`UnifiedModal` 只选择 `platformIcon / pixel` 变体并保留 closeDisabled、Backdrop、Escape 和 portal 语义,不再手写 X 图标、aria 和关闭按钮 class。验证命令`npm run test -- src/components/common/UnifiedModal.test.tsx src/components/common/PlatformModalCloseButton.test.tsx src/components/common/UnifiedConfirmDialog.test.tsx` - 2026-06-10 追加:`PlatformModalCloseButton variant="pixel"` 承接 `UnifiedModal variant="pixel"` 头部圆形关闭入口;`UnifiedModal` 只选择 `platformIcon / pixel` 变体并保留 closeDisabled、Backdrop、Escape 和 portal 语义,不再手写 X 图标、aria 和关闭按钮 class。验证命令`npm run test -- src/components/common/UnifiedModal.test.tsx src/components/common/PlatformModalCloseButton.test.tsx src/components/common/UnifiedConfirmDialog.test.tsx`
- 2026-06-10 追加:`UnifiedModal` 新增 `closeVariant``closeOnEscape``titleClassName``descriptionClassName`,用于在收口标准平台弹窗壳层时保留个人中心 `profile / profileCompact` 关闭按钮、原有标题层级和“不响应 Escape / backdrop”的交互语义RPG 首页个人中心里的昵称修改、账户充值、每日任务和兑换码弹窗已迁移到 `UnifiedModal`,支付结果 / 支付确认遮罩 / 泥点账单这类头部结构不同的弹窗继续保留专用实现。验证命令:`npm run test -- src/components/common/UnifiedModal.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`
- 2026-06-09 追加RPG 大编辑器暗色面板内的保存和角色槽动作继续走本地 `ActionButton`,不再混用白底平台 `platform-button` class平台白底动作收口和编辑器暗色动作收口保持两套视觉边界。 - 2026-06-09 追加RPG 大编辑器暗色面板内的保存和角色槽动作继续走本地 `ActionButton`,不再混用白底平台 `platform-button` class平台白底动作收口和编辑器暗色动作收口保持两套视觉边界。
- 2026-06-10 追加:`PlatformActionButton surface="editorDark"` 承接 RPG 暗色弹窗 / 运行面板里的普通取消、确认、刷新和编组动作,支持 `size="xxs"``tone="success" | "warning"``tone="accent"` 承接暗色壳层内的琥珀实心 CTA`tone="accentSoft"` 承接依赖局部 accent 变量的柔和强调按钮。角色自定义 footer、自定义世界生成 footer、地图切换确认、营地编组普通动作和角色聊天刷新动作已迁移。暗色可选项卡仍使用 `PlatformDarkOptionCard`,像素风发送 / 强品牌动作继续保留专用布局。验证命令:`npm run test -- src/components/common/platformActionButtonModel.test.ts src/components/common/PlatformActionButton.test.tsx src/components/SelectionCustomizationModals.test.tsx src/components/CompanionCampModal.test.tsx src/components/MapModal.test.tsx src/components/CharacterChatModal.test.tsx` - 2026-06-10 追加:`PlatformActionButton surface="editorDark"` 承接 RPG 暗色弹窗 / 运行面板里的普通取消、确认、刷新和编组动作,支持 `size="xxs"``tone="success" | "warning"``tone="accent"` 承接暗色壳层内的琥珀实心 CTA`tone="accentSoft"` 承接依赖局部 accent 变量的柔和强调按钮。角色自定义 footer、自定义世界生成 footer、地图切换确认、营地编组普通动作和角色聊天刷新动作已迁移。暗色可选项卡仍使用 `PlatformDarkOptionCard`,像素风发送 / 强品牌动作继续保留专用布局。验证命令:`npm run test -- src/components/common/platformActionButtonModel.test.ts src/components/common/PlatformActionButton.test.tsx src/components/SelectionCustomizationModals.test.tsx src/components/CompanionCampModal.test.tsx src/components/MapModal.test.tsx src/components/CharacterChatModal.test.tsx`
- 2026-06-10 追加RPG 首页创作 / 草稿顶栏的钱包快捷入口通过同文件 `TopbarWalletShortcutButton` 复用 `PlatformActionButton tone="accentSoft" shape="pill" size="xs"``PlatformIconBadge`;移动端 / 桌面端继续保留 `.platform-mobile-create-wallet-chip``.platform-desktop-create-wallet-chip``.platform-desktop-search` 兼容 class承接余额截断、桌面顶栏胶囊壳和既有测试锚点点击语义仍统一走 `openRechargeOrRewardCodeModal`。验证命令:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx` - 2026-06-10 追加RPG 首页创作 / 草稿顶栏的钱包快捷入口通过同文件 `TopbarWalletShortcutButton` 复用 `PlatformActionButton tone="accentSoft" shape="pill" size="xs"``PlatformIconBadge`;移动端 / 桌面端继续保留 `.platform-mobile-create-wallet-chip``.platform-desktop-create-wallet-chip``.platform-desktop-search` 兼容 class承接余额截断、桌面顶栏胶囊壳和既有测试锚点点击语义仍统一走 `openRechargeOrRewardCodeModal`。验证命令:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`

View File

@@ -223,6 +223,7 @@
19.2. 拼图广场详情 hero 的返回、上一张 / 下一张关卡图入口迁移到 `PlatformIconButton variant="darkMini"`,修改作品和进入第 1 关迁移到 `PlatformActionButton`,分享动作继续使用 `CopyFeedbackButton` 但复用共享动作按钮 chrome详情页只保留轮播、复制和跳转语义不再手写 hero 区按钮壳。 19.2. 拼图广场详情 hero 的返回、上一张 / 下一张关卡图入口迁移到 `PlatformIconButton variant="darkMini"`,修改作品和进入第 1 关迁移到 `PlatformActionButton`,分享动作继续使用 `CopyFeedbackButton` 但复用共享动作按钮 chrome详情页只保留轮播、复制和跳转语义不再手写 hero 区按钮壳。
19.3. 个人中心充值商品卡里的“购买 / 处理中”胶囊暂保留局部 `span`,不直接套用 `PlatformActionButton`,避免在 `PlatformSubpanel as="button"` 内再嵌套交互按钮;待出现第二个同形态的非交互 action chip 后,再决定是否沉淀独立的共享展示基元。 19.3. 个人中心充值商品卡里的“购买 / 处理中”胶囊暂保留局部 `span`,不直接套用 `PlatformActionButton`,避免在 `PlatformSubpanel as="button"` 内再嵌套交互按钮;待出现第二个同形态的非交互 action chip 后,再决定是否沉淀独立的共享展示基元。
19.3.1. RPG 首页创作 / 草稿顶栏的钱包快捷入口迁移到同文件适配器 `TopbarWalletShortcutButton`,内部复用 `PlatformActionButton tone="accentSoft" shape="pill" size="xs"``PlatformIconBadge`;移动端和桌面端继续保留 `.platform-mobile-create-wallet-chip``.platform-desktop-create-wallet-chip``.platform-desktop-search` 兼容 class承接移动端余额截断、桌面顶栏胶囊底色以及既有测试锚点。入口点击仍统一走 `openRechargeOrRewardCodeModal`,不把充值 / 兑换码平台分流逻辑改散到两个顶栏分支里。 19.3.1. RPG 首页创作 / 草稿顶栏的钱包快捷入口迁移到同文件适配器 `TopbarWalletShortcutButton`,内部复用 `PlatformActionButton tone="accentSoft" shape="pill" size="xs"``PlatformIconBadge`;移动端和桌面端继续保留 `.platform-mobile-create-wallet-chip``.platform-desktop-create-wallet-chip``.platform-desktop-search` 兼容 class承接移动端余额截断、桌面顶栏胶囊底色以及既有测试锚点。入口点击仍统一走 `openRechargeOrRewardCodeModal`,不把充值 / 兑换码平台分流逻辑改散到两个顶栏分支里。
19.3.2. 个人中心昵称修改、账户充值、每日任务和兑换码四类标准头部弹窗迁移到 `UnifiedModal``UnifiedModal` 新增 `closeVariant``closeOnEscape``titleClassName``descriptionClassName`,用于在收口弹窗壳层时保留个人中心 `profile / profileCompact` 关闭按钮、居中浮层布局和标题层级。上述弹窗统一通过 `closeOnBackdrop={false}``closeOnEscape={false}` 保持原有交互语义,不把 backdrop / Escape 关闭行为悄悄带进个人中心;支付确认遮罩、支付结果提示和泥点账单这类头部结构不同或浮动关闭语义不同的弹窗继续保留专用实现,等出现更多同构 case 再扩充 `UnifiedModal` 能力。
19.3. creative-agent 首页的侧边栏菜单、账号入口、开启新对话、我的创作、首页激励 CTA 和 prompt suggestion 按钮迁移到 `PlatformIconButton` / `PlatformActionButton`;首页继续保留 `creative-agent-home__*` 本地 class 承接透明顶栏、抽屉和品牌化胶囊视觉,不把视觉回收和语义收口绑成一次大改。`Beta` 徽标和历史记录纯文本行暂保留本地实现,等出现更多同构轻量列表行后再评估是否抽新的共享 row primitive。 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. 大鱼吃小鱼结果页 hero 的返回入口迁移到 `PlatformIconButton variant="darkMini"`,测试 / 发布动作迁移到 `PlatformActionButton surface="editorDark"`;结果页只保留测试运行、发布提交和文案状态语义,不再手写 hero 顶栏按钮壳。
20. 平台方形上传入口和紧凑虚线新增入口迁移到 `PlatformUploadTile`,上传后的图片预览迁移到 `PlatformUploadPreviewCard`;反馈页上传凭证入口 / 预览、敲木鱼工作台新增功德词条入口、通用创作图片面板的提示词参考图缩略图、抓大鹅封面编辑参考图缩略图、通用输入 Composer 已选参考图条、creation-agent 已选参考图条和拼图结果页关卡引用图横条已先迁移。方形缩略图使用默认 `layout="square"`,横向“已选择参考图 / 文件名 / 素材名 / 移除”条使用 `layout="inline"`;只读引用图条不传 `onRemove`,避免公共组件额外渲染删除入口。后续继续收口结果页素材上传、工作台参考图上传、紧凑虚线新增入口等上传 / 动作块时,业务页只保留文件选择、预览数组、预览回调、删除回调、校验逻辑或新增回调,上传方块外观、主副文案、缩略图壳、预览按钮、标题行、横向已选条、移除按钮和禁用态统一由 Module 承接;工具栏中的小图标上传仍继续使用 `PlatformIconButton asChild="label"` 20. 平台方形上传入口和紧凑虚线新增入口迁移到 `PlatformUploadTile`,上传后的图片预览迁移到 `PlatformUploadPreviewCard`;反馈页上传凭证入口 / 预览、敲木鱼工作台新增功德词条入口、通用创作图片面板的提示词参考图缩略图、抓大鹅封面编辑参考图缩略图、通用输入 Composer 已选参考图条、creation-agent 已选参考图条和拼图结果页关卡引用图横条已先迁移。方形缩略图使用默认 `layout="square"`,横向“已选择参考图 / 文件名 / 素材名 / 移除”条使用 `layout="inline"`;只读引用图条不传 `onRemove`,避免公共组件额外渲染删除入口。后续继续收口结果页素材上传、工作台参考图上传、紧凑虚线新增入口等上传 / 动作块时,业务页只保留文件选择、预览数组、预览回调、删除回调、校验逻辑或新增回调,上传方块外观、主副文案、缩略图壳、预览按钮、标题行、横向已选条、移除按钮和禁用态统一由 Module 承接;工具栏中的小图标上传仍继续使用 `PlatformIconButton asChild="label"`

View File

@@ -39,6 +39,30 @@ test('closes through backdrop and escape', () => {
expect(onClose).toHaveBeenCalledTimes(2); expect(onClose).toHaveBeenCalledTimes(2);
}); });
test('supports disabling escape close while keeping the custom close button chrome', () => {
const onClose = vi.fn();
render(
<UnifiedModal
open
title="个人中心弹窗"
onClose={onClose}
closeOnEscape={false}
closeVariant="profileCompact"
titleClassName="font-black"
portal={false}
>
<div></div>
</UnifiedModal>,
);
fireEvent.keyDown(window, { key: 'Escape' });
expect(onClose).not.toHaveBeenCalled();
const closeButton = screen.getByRole('button', { name: '关闭' });
expect(closeButton.className).toContain('platform-profile-icon-button');
expect(screen.getByRole('dialog', { name: '个人中心弹窗' })).toBeTruthy();
});
test('respects closeDisabled for every default close path', () => { test('respects closeDisabled for every default close path', () => {
const onClose = vi.fn(); const onClose = vi.fn();
render( render(

View File

@@ -1,4 +1,5 @@
import { import {
type ComponentProps,
type CSSProperties, type CSSProperties,
type ReactNode, type ReactNode,
useEffect, useEffect,
@@ -11,6 +12,9 @@ import { PlatformModalCloseButton } from './PlatformModalCloseButton';
type UnifiedModalVariant = 'platform' | 'pixel'; type UnifiedModalVariant = 'platform' | 'pixel';
type UnifiedModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'fullscreen'; type UnifiedModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'fullscreen';
type UnifiedModalCloseVariant = NonNullable<
ComponentProps<typeof PlatformModalCloseButton>['variant']
>;
type UnifiedModalProps = { type UnifiedModalProps = {
open: boolean; open: boolean;
@@ -23,13 +27,17 @@ type UnifiedModalProps = {
size?: UnifiedModalSize; size?: UnifiedModalSize;
closeDisabled?: boolean; closeDisabled?: boolean;
closeOnBackdrop?: boolean; closeOnBackdrop?: boolean;
closeOnEscape?: boolean;
showCloseButton?: boolean; showCloseButton?: boolean;
closeLabel?: string; closeLabel?: string;
closeVariant?: UnifiedModalCloseVariant;
portal?: boolean; portal?: boolean;
zIndexClassName?: string; zIndexClassName?: string;
overlayClassName?: string; overlayClassName?: string;
panelClassName?: string; panelClassName?: string;
headerClassName?: string; headerClassName?: string;
titleClassName?: string;
descriptionClassName?: string;
bodyClassName?: string; bodyClassName?: string;
footerClassName?: string; footerClassName?: string;
panelStyle?: CSSProperties; panelStyle?: CSSProperties;
@@ -80,12 +88,16 @@ function UnifiedModalContent({
size = 'md', size = 'md',
closeDisabled = false, closeDisabled = false,
closeOnBackdrop = true, closeOnBackdrop = true,
closeOnEscape = true,
showCloseButton = true, showCloseButton = true,
closeLabel = '关闭', closeLabel = '关闭',
closeVariant,
zIndexClassName = 'z-[90]', zIndexClassName = 'z-[90]',
overlayClassName, overlayClassName,
panelClassName, panelClassName,
headerClassName, headerClassName,
titleClassName,
descriptionClassName,
bodyClassName, bodyClassName,
footerClassName, footerClassName,
panelStyle, panelStyle,
@@ -94,7 +106,7 @@ function UnifiedModalContent({
const descriptionId = useId(); const descriptionId = useId();
useEffect(() => { useEffect(() => {
if (!open || closeDisabled) { if (!open || closeDisabled || !closeOnEscape) {
return; return;
} }
@@ -106,7 +118,7 @@ function UnifiedModalContent({
window.addEventListener('keydown', handleKeyDown); window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [closeDisabled, onClose, open]); }, [closeDisabled, closeOnEscape, onClose, open]);
if (!open) { if (!open) {
return null; return null;
@@ -169,11 +181,20 @@ function UnifiedModalContent({
> >
<div className={joinClassNames(headerClasses, headerClassName)}> <div className={joinClassNames(headerClasses, headerClassName)}>
<div className="min-w-0"> <div className="min-w-0">
<div id={titleId} className={titleClasses}> <div
id={titleId}
className={joinClassNames(titleClasses, titleClassName)}
>
{title} {title}
</div> </div>
{description ? ( {description ? (
<div id={descriptionId} className={descriptionClasses}> <div
id={descriptionId}
className={joinClassNames(
descriptionClasses,
descriptionClassName,
)}
>
{description} {description}
</div> </div>
) : null} ) : null}
@@ -183,7 +204,7 @@ function UnifiedModalContent({
label={closeLabel} label={closeLabel}
onClick={onClose} onClick={onClose}
disabled={closeDisabled} disabled={closeDisabled}
variant={isPixel ? 'pixel' : 'platformIcon'} variant={closeVariant ?? (isPixel ? 'pixel' : 'platformIcon')}
/> />
) : null} ) : null}
</div> </div>

View File

@@ -132,6 +132,7 @@ import { PlatformSubpanel } from '../common/PlatformSubpanel';
import { PlatformTextField } from '../common/PlatformTextField'; import { PlatformTextField } from '../common/PlatformTextField';
import { RUNTIME_RESOURCE_PENDING_SELECTOR } from '../common/RuntimeResourcePendingMarker'; import { RUNTIME_RESOURCE_PENDING_SELECTOR } from '../common/RuntimeResourcePendingMarker';
import { SquareImageCropModal } from '../common/SquareImageCropModal'; import { SquareImageCropModal } from '../common/SquareImageCropModal';
import { UnifiedModal } from '../common/UnifiedModal';
import { import {
buildCenteredSquareImageCropRect, buildCenteredSquareImageCropRect,
clampSquareImageCropRect, clampSquareImageCropRect,
@@ -2824,6 +2825,13 @@ function ProfileReferralUserAvatar({
); );
} }
const PROFILE_MODAL_OVERLAY_CLASS =
'platform-modal-backdrop !items-center !justify-center !px-4 !py-6';
const PROFILE_MODAL_HEADER_CLASS = 'border-white/10 px-5 py-4';
const PROFILE_MODAL_TITLE_CLASS = 'text-base font-black';
const PROFILE_MODAL_DESCRIPTION_CLASS =
'mt-1 text-xs font-semibold text-[var(--platform-text-soft)]';
function ProfileNicknameModal({ function ProfileNicknameModal({
value, value,
error, error,
@@ -2840,65 +2848,61 @@ function ProfileNicknameModal({
onSubmit: () => void; onSubmit: () => void;
}) { }) {
return ( return (
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6"> <UnifiedModal
<div open
role="dialog" title="修改昵称"
aria-modal="true" onClose={onClose}
aria-labelledby="profile-nickname-title" closeLabel="关闭昵称修改"
className="platform-modal-shell platform-remap-surface w-full max-w-sm overflow-hidden rounded-[1.4rem]" closeVariant="profileCompact"
> closeOnBackdrop={false}
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4"> closeOnEscape={false}
<div id="profile-nickname-title" className="text-base font-black"> portal={false}
size="sm"
</div> zIndexClassName="z-[80]"
<PlatformModalCloseButton overlayClassName={PROFILE_MODAL_OVERLAY_CLASS}
label="关闭昵称修改" panelClassName="platform-remap-surface !max-w-sm rounded-[1.4rem]"
variant="profileCompact" headerClassName={PROFILE_MODAL_HEADER_CLASS}
onClick={onClose} titleClassName={PROFILE_MODAL_TITLE_CLASS}
icon="×" bodyClassName="px-5 py-5"
/> >
</div> <label className="block">
<div className="px-5 py-5"> <span className="sr-only"></span>
<label className="block"> <PlatformTextField
<span className="sr-only"></span> autoFocus
<PlatformTextField value={value}
autoFocus onChange={(event) => onChange(event.target.value)}
value={value} onKeyDown={(event) => {
onChange={(event) => onChange(event.target.value)} if (event.key === 'Enter') {
onKeyDown={(event) => { event.preventDefault();
if (event.key === 'Enter') { onSubmit();
event.preventDefault(); }
onSubmit(); }}
} maxLength={20}
}} surface="editorDark"
maxLength={20} size="lg"
surface="editorDark" density="roomy"
size="lg" className="rounded-2xl border-white/12 bg-white/10 text-[var(--platform-text-strong)] focus:border-[var(--platform-surface-hover-border)]"
density="roomy" placeholder="输入新昵称"
className="rounded-2xl border-white/12 bg-white/10 text-[var(--platform-text-strong)] focus:border-[var(--platform-surface-hover-border)]" />
placeholder="输入新昵称" </label>
/> {error ? (
</label> <PlatformStatusMessage
{error ? ( tone="error"
<PlatformStatusMessage surface="tinted"
tone="error" className="mt-3 rounded-2xl border-rose-400/25 text-rose-600"
surface="tinted" >
className="mt-3 rounded-2xl border-rose-400/25 text-rose-600" {error}
> </PlatformStatusMessage>
{error} ) : null}
</PlatformStatusMessage> <div className="mt-5 grid grid-cols-2 gap-3">
) : null} <PlatformActionButton tone="secondary" onClick={onClose}>
<div className="mt-5 grid grid-cols-2 gap-3">
<PlatformActionButton tone="secondary" onClick={onClose}> </PlatformActionButton>
<PlatformActionButton onClick={onSubmit} disabled={isSaving}>
</PlatformActionButton> {isSaving ? '保存中' : '保存'}
<PlatformActionButton onClick={onSubmit} disabled={isSaving}> </PlatformActionButton>
{isSaving ? '保存中' : '保存'}
</PlatformActionButton>
</div>
</div>
</div> </div>
</div> </UnifiedModal>
); );
} }
@@ -3189,125 +3193,126 @@ function ProfileRechargeModal({
); );
return ( return (
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6"> <UnifiedModal
<div className="platform-recharge-modal w-full max-w-[34rem] overflow-hidden rounded-[1.4rem]"> open
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4"> title="账户充值"
<div> description={
<div className="text-base font-black"></div> center ? `${center.walletBalance}泥点 · ${memberLabel}` : '读取中'
<div className="mt-1 text-xs font-semibold text-[var(--platform-text-soft)]"> }
{center onClose={onClose}
? `${center.walletBalance}泥点 · ${memberLabel}` closeLabel="关闭账户充值"
: '读取中'} closeVariant="profile"
</div> closeOnBackdrop={false}
</div> closeOnEscape={false}
<PlatformModalCloseButton portal={false}
label="关闭账户充值" size="md"
onClick={onClose} zIndexClassName="z-[80]"
icon="×" overlayClassName={PROFILE_MODAL_OVERLAY_CLASS}
/> panelClassName="platform-recharge-modal !max-w-[34rem] rounded-[1.4rem]"
</div> headerClassName={PROFILE_MODAL_HEADER_CLASS}
<div className="max-h-[min(76vh,36rem)] overflow-y-auto px-5 py-5"> titleClassName={PROFILE_MODAL_TITLE_CLASS}
<div className="grid grid-cols-2 gap-2"> descriptionClassName={PROFILE_MODAL_DESCRIPTION_CLASS}
<button bodyClassName="max-h-[min(76vh,36rem)] overflow-y-auto px-5 py-5"
type="button" >
onClick={() => onTabChange('points')} <div className="grid grid-cols-2 gap-2">
className={`platform-category-chip justify-center ${activeTab === 'points' ? 'platform-category-chip--active' : ''}`} <button
> type="button"
onClick={() => onTabChange('points')}
</button> className={`platform-category-chip justify-center ${activeTab === 'points' ? 'platform-category-chip--active' : ''}`}
<button >
type="button"
onClick={() => onTabChange('membership')} </button>
className={`platform-category-chip justify-center ${activeTab === 'membership' ? 'platform-category-chip--active' : ''}`} <button
> type="button"
onClick={() => onTabChange('membership')}
</button> className={`platform-category-chip justify-center ${activeTab === 'membership' ? 'platform-category-chip--active' : ''}`}
</div> >
{error ? ( </button>
<PlatformStatusMessage
tone="error"
surface="profile"
size="xs"
className="mt-4 rounded-2xl font-semibold"
>
<div>{error}</div>
<PlatformActionButton
surface="profile"
size="xs"
className="mt-3"
onClick={onRetry}
>
</PlatformActionButton>
</PlatformStatusMessage>
) : null}
{isLoading ? (
<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>
) : products.length > 0 ? (
<div className="mt-4 grid gap-3 sm:grid-cols-2">
{products.map((product) => (
<RechargeProductCard
key={product.productId}
product={product}
submittingProductId={submittingProductId}
onBuy={onBuy}
/>
))}
</div>
) : (
<PlatformEmptyState
surface="subpanel"
size="inline"
className="mt-4"
>
</PlatformEmptyState>
)}
{nativePayment ? (
<PlatformSubpanel
as="div"
radius="sm"
padding="md"
className="mt-4 text-center"
>
<div className="text-sm font-black"></div>
<div className="mx-auto mt-3 flex h-[180px] w-[180px] items-center justify-center rounded-xl bg-white p-2">
{nativeQrImageUrl ? (
<img
src={nativeQrImageUrl}
alt="微信 Native 支付二维码"
className="h-full w-full"
/>
) : (
<span className="text-xs font-semibold text-slate-500">
</span>
)}
</div>
<PlatformActionButton
surface="profile"
size="xs"
className="mt-4 disabled:cursor-wait"
onClick={onConfirmNativePayment}
disabled={nativePayment.isConfirming}
>
{nativePayment.isConfirming ? '确认中' : '我已支付'}
</PlatformActionButton>
</PlatformSubpanel>
) : null}
</div>
</div> </div>
</div>
{error ? (
<PlatformStatusMessage
tone="error"
surface="profile"
size="xs"
className="mt-4 rounded-2xl font-semibold"
>
<div>{error}</div>
<PlatformActionButton
surface="profile"
size="xs"
className="mt-3"
onClick={onRetry}
>
</PlatformActionButton>
</PlatformStatusMessage>
) : null}
{isLoading ? (
<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>
) : products.length > 0 ? (
<div className="mt-4 grid gap-3 sm:grid-cols-2">
{products.map((product) => (
<RechargeProductCard
key={product.productId}
product={product}
submittingProductId={submittingProductId}
onBuy={onBuy}
/>
))}
</div>
) : (
<PlatformEmptyState
surface="subpanel"
size="inline"
className="mt-4"
>
</PlatformEmptyState>
)}
{nativePayment ? (
<PlatformSubpanel
as="div"
radius="sm"
padding="md"
className="mt-4 text-center"
>
<div className="text-sm font-black"></div>
<div className="mx-auto mt-3 flex h-[180px] w-[180px] items-center justify-center rounded-xl bg-white p-2">
{nativeQrImageUrl ? (
<img
src={nativeQrImageUrl}
alt="微信 Native 支付二维码"
className="h-full w-full"
/>
) : (
<span className="text-xs font-semibold text-slate-500">
</span>
)}
</div>
<PlatformActionButton
surface="profile"
size="xs"
className="mt-4 disabled:cursor-wait"
onClick={onConfirmNativePayment}
disabled={nativePayment.isConfirming}
>
{nativePayment.isConfirming ? '确认中' : '我已支付'}
</PlatformActionButton>
</PlatformSubpanel>
) : null}
</UnifiedModal>
); );
} }
@@ -3545,113 +3550,114 @@ function ProfileTaskCenterModal({
const walletBalance = center?.walletBalance ?? fallbackBalance; const walletBalance = center?.walletBalance ?? fallbackBalance;
return ( return (
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6"> <UnifiedModal
<div className="platform-recharge-modal w-full max-w-md overflow-hidden rounded-[1.4rem]"> open
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4"> title="每日任务"
<div> description={`${walletBalance}泥点`}
<div className="text-base font-black"></div> onClose={onClose}
<div className="mt-1 text-xs font-semibold text-[var(--platform-text-soft)]"> closeLabel="关闭每日任务"
{walletBalance} closeVariant="profile"
</div> closeOnBackdrop={false}
</div> closeOnEscape={false}
<PlatformModalCloseButton portal={false}
label="关闭每日任务" size="sm"
onClick={onClose} zIndexClassName="z-[80]"
icon="×" overlayClassName={PROFILE_MODAL_OVERLAY_CLASS}
/> panelClassName="platform-recharge-modal !max-w-md rounded-[1.4rem]"
headerClassName={PROFILE_MODAL_HEADER_CLASS}
titleClassName={PROFILE_MODAL_TITLE_CLASS}
descriptionClassName={PROFILE_MODAL_DESCRIPTION_CLASS}
bodyClassName="space-y-3 px-5 py-5"
>
{error ? (
<PlatformStatusMessage
tone="error"
surface="profile"
size="xs"
className="rounded-2xl font-semibold"
>
<div>{error}</div>
<PlatformActionButton
surface="profile"
size="xs"
className="mt-3"
onClick={onRetry}
>
</PlatformActionButton>
</PlatformStatusMessage>
) : null}
{success ? (
<PlatformStatusMessage
tone="success"
surface="profile"
size="xs"
className="rounded-2xl font-semibold"
>
{success}
</PlatformStatusMessage>
) : null}
{isLoading ? (
<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> </div>
<div className="space-y-3 px-5 py-5"> ) : tasks.length === 0 ? (
{error ? ( <PlatformEmptyState surface="subpanel" size="inline">
<PlatformStatusMessage
tone="error" </PlatformEmptyState>
surface="profile" ) : (
size="xs" <div className="space-y-3">
className="rounded-2xl font-semibold" {tasks.map((task) => {
> const isClaimable = task.status === 'claimable';
<div>{error}</div> const isClaiming = claimingTaskId === task.taskId;
<PlatformActionButton const progressLabel = buildProfileTaskProgressLabel(task);
surface="profile"
size="xs"
className="mt-3"
onClick={onRetry}
>
</PlatformActionButton>
</PlatformStatusMessage>
) : null}
{success ? (
<PlatformStatusMessage
tone="success"
surface="profile"
size="xs"
className="rounded-2xl font-semibold"
>
{success}
</PlatformStatusMessage>
) : null}
{isLoading ? (
<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>
) : tasks.length === 0 ? (
<PlatformEmptyState surface="subpanel" size="inline">
</PlatformEmptyState>
) : (
<div className="space-y-3">
{tasks.map((task) => {
const isClaimable = task.status === 'claimable';
const isClaiming = claimingTaskId === task.taskId;
const progressLabel = buildProfileTaskProgressLabel(task);
return ( return (
<PlatformSubpanel <PlatformSubpanel
as="div" as="div"
key={task.taskId} key={task.taskId}
radius="sm" radius="sm"
padding="md" padding="md"
> >
<div className="flex min-w-0 items-start justify-between gap-3"> <div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<div className="text-base font-black text-[var(--platform-text-strong)]"> <div className="text-base font-black text-[var(--platform-text-strong)]">
{task.title} {task.title}
</div>
<div className="mt-1 text-xs font-semibold text-[var(--platform-text-soft)]">
{progressLabel}
</div>
</div>
<div className="shrink-0 text-right">
<div className="text-sm font-black text-[var(--platform-text-strong)]">
+{task.rewardPoints}
</div>
<div className="mt-1 text-[11px] font-semibold text-[var(--platform-text-soft)]">
{getProfileTaskStatusLabel(task.status)}
</div>
</div>
</div> </div>
<PlatformActionButton <div className="mt-1 text-xs font-semibold text-[var(--platform-text-soft)]">
surface="profile" {progressLabel}
fullWidth </div>
size="sm" </div>
className="mt-3 disabled:opacity-50" <div className="shrink-0 text-right">
disabled={!isClaimable || Boolean(claimingTaskId)} <div className="text-sm font-black text-[var(--platform-text-strong)]">
onClick={() => onClaim(task.taskId)} +{task.rewardPoints}
> </div>
{getProfileTaskClaimButtonLabel(task, isClaiming)} <div className="mt-1 text-[11px] font-semibold text-[var(--platform-text-soft)]">
</PlatformActionButton> {getProfileTaskStatusLabel(task.status)}
</PlatformSubpanel> </div>
); </div>
})} </div>
</div> <PlatformActionButton
)} surface="profile"
fullWidth
size="sm"
className="mt-3 disabled:opacity-50"
disabled={!isClaimable || Boolean(claimingTaskId)}
onClick={() => onClaim(task.taskId)}
>
{getProfileTaskClaimButtonLabel(task, isClaiming)}
</PlatformActionButton>
</PlatformSubpanel>
);
})}
</div> </div>
</div> )}
</div> </UnifiedModal>
); );
} }
@@ -3673,65 +3679,69 @@ function RewardCodeRedeemModal({
onClose: () => void; onClose: () => void;
}) { }) {
return ( return (
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6"> <UnifiedModal
<div className="platform-recharge-modal w-full max-w-sm overflow-hidden rounded-[1.4rem]"> open
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4"> title="兑换码"
<div className="text-base font-black"></div> onClose={onClose}
<PlatformModalCloseButton closeLabel="关闭兑换码"
label="关闭兑换码" closeVariant="profile"
onClick={onClose} closeOnBackdrop={false}
icon="×" closeOnEscape={false}
/> portal={false}
</div> size="sm"
<div className="space-y-3 px-5 py-5"> zIndexClassName="z-[80]"
<PlatformTextField overlayClassName={PROFILE_MODAL_OVERLAY_CLASS}
value={value} panelClassName="platform-recharge-modal !max-w-sm rounded-[1.4rem]"
onChange={(event) => onChange(event.target.value)} headerClassName={PROFILE_MODAL_HEADER_CLASS}
onKeyDown={(event) => { titleClassName={PROFILE_MODAL_TITLE_CLASS}
if (event.key === 'Enter') { bodyClassName="space-y-3 px-5 py-5"
onSubmit(); >
} <PlatformTextField
}} value={value}
size="sm" onChange={(event) => onChange(event.target.value)}
density="roomy" onKeyDown={(event) => {
className="uppercase tracking-normal" if (event.key === 'Enter') {
placeholder="输入兑换码" onSubmit();
aria-label="兑换码" }
autoFocus }}
/> size="sm"
<PlatformActionButton density="roomy"
surface="profile" className="uppercase tracking-normal"
fullWidth placeholder="输入兑换码"
size="md" aria-label="兑换码"
className="disabled:opacity-50" autoFocus
onClick={onSubmit} />
disabled={isSubmitting || !value.trim()} <PlatformActionButton
> surface="profile"
{isSubmitting ? '兑换中' : '兑换'} fullWidth
</PlatformActionButton> size="md"
{error ? ( className="disabled:opacity-50"
<PlatformStatusMessage onClick={onSubmit}
tone="error" disabled={isSubmitting || !value.trim()}
surface="profile" >
size="xs" {isSubmitting ? '兑换中' : '兑换'}
className="rounded-2xl font-semibold" </PlatformActionButton>
> {error ? (
{error} <PlatformStatusMessage
</PlatformStatusMessage> tone="error"
) : null} surface="profile"
{success ? ( size="xs"
<PlatformStatusMessage className="rounded-2xl font-semibold"
tone="success" >
surface="profile" {error}
size="xs" </PlatformStatusMessage>
className="rounded-2xl font-semibold" ) : null}
> {success ? (
{success} <PlatformStatusMessage
</PlatformStatusMessage> tone="success"
) : null} surface="profile"
</div> size="xs"
</div> className="rounded-2xl font-semibold"
</div> >
{success}
</PlatformStatusMessage>
) : null}
</UnifiedModal>
); );
} }