From bd4baf66b1611651fe4c4da993770e0984b43e38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 31 Jul 2026 20:07:38 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E4=BF=AE=E5=A4=8DModal=20Por?= =?UTF-8?q?tal=E4=B8=BB=E9=A2=98=E7=BB=A7=E6=89=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在UnifiedModal集中解析Portal主题并支持显式覆盖 梳理危险确认、图片预览、报告和工具弹窗的主题边界 修复固定浅色画布弹窗与Agent菜单的主题回归 将相关公共组件测试纳入默认Vitest收集范围 同步Platform UI Kit、画布Agent与共享记忆文档 --- .../shared-memory/decision-log.md | 2 + docs/project-memory/shared-memory/pitfalls.md | 4 +- ...】PlatformUiKit弹窗组件收口计划-2026-06-08.md | 1 + .../【编辑器】画布Agent对话面板-2026-07-03.md | 3 +- .../auth/PlatformAuthModalShell.tsx | 3 +- .../common/CreativeImageInputPanel.test.tsx | 1 + src/components/common/LegalDocumentModal.tsx | 4 +- .../PlatformDangerConfirmDialog.test.tsx | 4 + .../common/PlatformDangerConfirmDialog.tsx | 12 +- .../common/PlatformImagePreviewModal.test.tsx | 1 + .../common/PlatformImagePreviewModal.tsx | 2 + .../common/PlatformReportDialog.test.tsx | 6 + .../common/PlatformReportDialog.tsx | 2 + .../common/PlatformToolModalShell.tsx | 5 - .../common/PlatformUtilityInfoModal.test.tsx | 8 +- .../common/PlatformUtilityInfoModal.tsx | 9 +- .../common/PublishShareModal.test.tsx | 6 +- src/components/common/PublishShareModal.tsx | 3 - .../common/UnifiedConfirmDialog.tsx | 5 +- src/components/common/UnifiedModal.tsx | 71 ++++++- .../CreationLandingView.test.tsx | 4 + .../creation-home/CreationLandingView.tsx | 5 +- .../EditorAgentConversationPanelView.test.tsx | 18 +- .../EditorAgentConversationPanelView.tsx | 4 +- .../MessageBubble.test.tsx | 6 +- .../MessageBubbleRightClickMenu.tsx | 195 +++++++++--------- ...mageCanvasEditGenerationModalView.test.tsx | 26 ++- .../ImageCanvasEditGenerationModalView.tsx | 6 +- .../ImageCanvasMetadataModalView.test.tsx | 72 ++++--- .../ImageCanvasMetadataModalView.tsx | 12 +- .../ImageCanvasShortcutDialogView.test.tsx | 16 +- .../ImageCanvasShortcutDialogView.tsx | 2 + .../UnifiedModalPortalTheme.test.tsx | 125 +++++++++++ .../PlatformActiveProfileView.test.tsx | 6 + vitest.config.ts | 6 + 35 files changed, 486 insertions(+), 169 deletions(-) create mode 100644 src/components/image-editor/UnifiedModalPortalTheme.test.tsx diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index d4a959f06..c94135484 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -3629,6 +3629,8 @@ - 决策:`PlatformSegmentedTabs` 继续承接首页 / 结果页剩余的横向 rail 与二选一切换;`RpgEntryHomeView.tsx` 的 discover channel bar、移动端 / 桌面端分类 chip rail,`CustomWorldEntityCatalog.tsx` 的 `RESULT_TABS` sticky rail,以及 `PlatformProfileRechargeModal.tsx` 的“泥点充值 / 会员卡”切换条已迁移。像 `CustomWorldEntityCatalog` 这种“标题 + count”内容直接走 `ReactNode label`;首页 / 创作入口 / 作品架 / 个人中心里稳定复用的频道下划线、创作 pill rail、二列 option segment 皮肤走 `PlatformSegmentedTabPresets`。同类切换在测试里应优先按 `role="tablist" / "tab"` 查询,而不是把它们继续当普通 button。 - 决策:简单泥点确认流的开关状态机统一收口到 `src/components/common/useMudPointConfirmController.ts`,只暴露 `open / requestOpen / close / confirm`,不持有点数、标题、描述或禁用态等业务字段;`PuzzleCreationWorkspace.tsx`、`Match3DCreationWorkspace.tsx` 与 `Match3DResultView.tsx` 的两个批量素材面板已接入。`PuzzleResultView.tsx` 和 `RpgCreationRoleAssetStudioModalImpl.tsx` 这类节奏不同或携带 pending payload 的场景继续保留本地状态机,避免把简单 hook 扩成泛型动作路由器。 - 决策:标准平台 modal header 的关闭入口继续统一到 `PlatformModalCloseButton variant="platformIcon"`;结果页 / 工具页重复的白底 portal 弹窗壳层收口到 `src/components/common/PlatformToolModalShell.tsx`,由它统一承接平台主题 overlay、白底 remap panel、标准 header/body/footer spacing、关闭按钮和遮罩 / Escape 关闭策略。`PuzzleResultView.tsx` 的关卡详情 / 发布弹窗、`Match3DResultView.tsx` 的封面 / 发布工具弹窗,以及 `PuzzleHistoryAssetPickerDialog.tsx` 的历史素材弹窗已迁移;`UnifiedModal` 新增 `ariaLabel` 支持可见标题动态、可访问名称固定的场景。像素风 runtime、drawer collapse、玩法规则面板和运行态 overlay 不跟这条线混收,继续保留局部 close 语义。 +- 决策:平台 portal 主题恢复下沉到 `UnifiedModal`;`portal=true` 默认从 `AuthUiContext` 注入当前 light / dark 主题,已显式给出主题的调用保留原选择,无 Provider 回退 light。`portalTheme="none"` 只用于全黑图片预览等完全自绘弹层,`portal=false` 仍使用原 DOM 主题作用域。图片信息、修改图片与画布快捷键弹窗在完整支持暗色样式前显式使用 `portalTheme="light"`,不将固定白底面板与暗色文本变量混用。共享业务壳不再重复读取 AuthUi 只为 portal 补 class,画布私有变量则继续通过 `ImageCanvasEditorPortal` 桥接。已退役玩法不因该底层修复恢复入口或维护范围。 +- 决策:`PlatformUtilityInfoModal` 未显式传主题时必须沿用 `UnifiedModal` 的 auto 主题,不在共享壳里默认锁定 light。`PublishShareModal` 跟随当前 light / dark 主题;`PlatformReportDialog` 因包含二维码 / 扫码展示区,显式固定 light 以保证白底对比度和识别率。 - 决策:平台入口的创作前置泥点阻断提示只在 `platform-entry` 局部抽成 `src/components/platform-entry/PlatformDraftGenerationPointNoticeDialog.tsx`,并使用 `DraftGenerationPointNotice` union(`insufficient-points` / `balance-load-failed`)承接业务真相;不要在 `common/` 再抽一个泛化 `BlockingNoticeDialog`,否则会把 `PlatformAcknowledgeStatusDialog` 的样式透传再包装一层而不缩小调用面。 - 决策:`PlatformAsyncStatePanel` 从 profile modal 扩展到作品架类白底 panel;`CustomWorldCreationHub.tsx` 的作品架主体现在也统一走 `loadingState / emptyState / children` 三段 slot,但 error + 重试继续留在业务层外侧,不把共享组件扩成“banner + retry + content”全能状态机。后续白底作品架或列表 panel 若只是互斥的 `loading / empty / content`,优先直接复用这套骨架。 - 决策:`CopyFeedbackButton.tsx` 的 `actionSurface` 分支继续收口到 `PlatformActionButton`,`pill` 分支继续保留 `PlatformPillBadge` 风格;复制反馈按钮不再直接调用 `getPlatformActionButtonClassName` 手拼平台按钮基础 chrome。后续同类“复制状态机 + 平台动作按钮”组合优先直接复用 `CopyFeedbackButton`,不要在业务页重新混写图标、文案、aria 和动作按钮 class。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index fe9792898..dfb4c60b5 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -3579,8 +3579,8 @@ - 现象:项目库点击“重命名”后,标题、输入框和按钮仍显示,但弹窗面板及遮罩背景变透明,看起来像“改名界面的背景没了”。 - 原因:`UnifiedModal` 默认 portal 到 `document.body`;若业务入口只在页面内层继承 `platform-theme`,portal 根节点不会继承该容器的 CSS 变量。此时 `.platform-modal-shell` 的 `background: var(--platform-modal-fill)` 和 `.platform-overlay` 的背景声明都会失效。 -- 处理:平台白底工具弹窗优先复用 `PlatformToolModalShell`,由共享壳读取当前 `AuthUiContext.platformTheme`,并把 `platform-theme platform-theme--` 挂到 portal overlay;不要用硬编码白底掩盖主题变量缺失。必须直接使用 `UnifiedModal` 的特殊场景,也要在 `overlayClassName` 显式传递当前平台主题。 -- 验证:在 light / dark 主题下打开 portal 弹窗,断言 dialog 的 overlay 携带对应主题类,并在真实浏览器核对 panel 与遮罩的 computed background 均非透明。 +- 处理:`UnifiedModal` 在 `portal=true` 时默认把 `AuthUiContext.platformTheme` 注入 overlay,共享白底弹窗和直接调用都不应再手工拼接主题 class。完全自绘的黑底预览显式使用 `portalTheme="none"`;已明确固定主题的弹窗使用 `light` / `dark`;局部 CSS 仍固定白底且未完成暗色样式的弹窗,必须暂时显式固定 `light`,否则会出现白底白字或深浅样式混杂;`portal=false` 继续依赖原 DOM 主题作用域。裸 `createPortal` 若使用平台或画布 CSS 变量,必须改用相应的主题 portal 壳,不要用硬编码白底掩盖主题变量缺失。 +- 验证:在真实 `AuthUiContext.platformTheme="dark"` Provider 下打开 portal 弹窗,断言 auto 弹窗的 overlay 携带暗色主题类,固定浅色弹窗只携带浅色主题类,panel 与遮罩的 computed background 均非透明;同时断言 `portalTheme="none"` 的黑底预览不被平台 remap。 - 关联:`src/components/project/ProjectGalleryView.tsx`、`src/components/common/PlatformToolModalShell.tsx`、`src/components/common/UnifiedModal.tsx`。 ## 自主试玩失败后的修复责任不能同时落给总控和专业 Agent diff --git a/docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md b/docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md index 9e7d27f4c..95936bc0b 100644 --- a/docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md +++ b/docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md @@ -283,6 +283,7 @@ 19.3.53. 认证入口白底弹窗壳层收口到 `src/components/auth/PlatformAuthModalShell.tsx`;该 Module 只承接平台主题 overlay、`platform-auth-card`、标准标题栏、关闭按钮、点击遮罩关闭和禁用 Escape 的认证弹窗策略,不持有短信 / 密码登录、重置密码、邀请码规范化、法律协议或错误状态。`LoginScreen.tsx` 与 `RegistrationInviteModal.tsx` 已接入,业务组件只保留表单状态与提交流程。后续认证域新增同形态白底弹窗时优先复用该壳层;账号安全详情和绑定手机号这类布局差异较大的卡片先独立评估,不把 auth shell 扩成万能认证容器。验证命令:`npx vitest run src/components/auth/PlatformAuthModalShell.test.tsx src/components/auth/AuthGate.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 19.3.54. 账号 / 运行态 / onboarding 这轮继续分场景收口:`AccountModal.tsx` 的设置入口外层 overlay 与 auth card 壳层复用 `PlatformAuthModalShell`,并通过 `overlaySpacing`、`overlayStyle`、`showHeader` 和尺寸透传保留账号弹窗的 safe-area 与 direct account 唯一 dialog 语义;拼图运行态新增 `src/components/puzzle-runtime/PuzzleRuntimeModalShell.tsx`,只在 `puzzle-runtime` 内承接道具确认、设置、退出改造提示、失败弹窗和通关结算的 overlay / dialog / footer / button 骨架,原图查看、拖拽 ghost、飞行动画和全屏 runtime 容器不纳入 modal 收口;抓大鹅与跳一跳结算弹窗分别在 `Match3DRuntimeShell.tsx` 和 `JumpHopRuntimeShell.tsx` 内提取本地结算壳层 / summary / actions,保留玩法视觉身份;拼图 onboarding 首屏继续保留沉浸式全屏体验,只把登录保存覆盖层迁入 `UnifiedModal`,保持无关闭按钮、禁用遮罩关闭和禁用 Escape。后续 runtime 专属弹窗优先先抽玩法目录内薄壳;只有出现跨玩法稳定同构接口时再上升到 `common/`,不要把 `PlatformToolModalShell` 强行套到像素 / 游戏运行态 overlay。验证命令:`npm run test -- src/components/auth/AccountModal.test.tsx src/components/auth/PlatformAuthModalShell.test.tsx src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleOnboardingView.test.tsx src/components/match3d-runtime/Match3DRuntimeShell.test.tsx src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 19.3.55. 拼图 / 拼消消运行态的剩余阻断层继续按玩法目录局部收口:`src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleRuntimeBlockingOverlay.tsx` 只承接平台入口里拼图“正在准备下一关”的短暂阻断层,继续复用 `UnifiedModal` 的遮罩、dialog 语义和关闭禁用策略,但不把这类运行态等待面板直接提升到 `common/`;`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx` 则在玩法目录内新增 `PuzzleClearRuntimeOverlayShell`、`PuzzleClearRuntimePendingOverlay` 与 `PuzzleClearRuntimeSettlementDialog`,把 `!activeRun` 的等待层和 `level_cleared / finished / level_failed` 的结算层统一成一条本地结构线,同时保留拼消消自己的视觉和动作分流。拖拽 ghost、swap flight、补牌 / 消除动画、全屏 runtime 容器和其它强玩法视觉层不算旧 modal 债务,不跟这条线混收。验证命令:`npm run test -- src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleRuntimeBlockingOverlay.test.tsx src/components/platform-entry/PlatformEntryFlowShellImpl.test.ts src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 +19.3.56. `UnifiedModal` 默认在 `portal=true` 时读取 `AuthUiContext.platformTheme`,并把 `platform-theme platform-theme--` 注入 portal overlay,从底层保证 `--platform-modal-fill`、`--platform-overlay-fill` 等 CSS 变量不因挂到 `document.body` 而丢失。新增 `portalTheme="auto|light|dark|none"`:默认 `auto` 优先保留调用方已给出的明确主题,否则使用当前主题并在无 Provider 时回退 light;纯黑底图片 / 素材预览使用 `none` 保持完全自绘;`portal=false` 不改变原 DOM 继承。`PlatformToolModalShell`、`PlatformDangerConfirmDialog`、认证与工具信息壳不再重复维护 portal 主题桥,但继续保留各自的 panel、间距、按钮和层级语义。编辑器裸 portal 菜单应复用 `ImageCanvasEditorPortal` 同时获得平台与画布变量;图片信息、修改图片和画布快捷键三个面板仍使用固定浅色 CSS,在完整支持暗色样式前显式传入 `portalTheme="light"`;已退役玩法目录不因本次收口重新进入维护范围。验证命令:`npm run test -- src/components/image-editor/UnifiedModalPortalTheme.test.tsx src/components/image-editor/ImageCanvasShortcutDialogView.test.tsx src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx src/components/image-editor/ImageCanvasMetadataModalView.test.tsx src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.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`。 diff --git a/docs/【编辑器】画布Agent对话面板-2026-07-03.md b/docs/【编辑器】画布Agent对话面板-2026-07-03.md index ade82fdd1..165f831e2 100644 --- a/docs/【编辑器】画布Agent对话面板-2026-07-03.md +++ b/docs/【编辑器】画布Agent对话面板-2026-07-03.md @@ -65,6 +65,7 @@ - Enter 发送必须同时排除 `isComposing` 和旧 Safari / WebKit 候选词确认事件的 `keyCode === 229`,避免输入法选词时误发送。 - 用户消息必须包含去除首尾空白后的非空文本;附件只能随文本消息发送,前端发送门禁与后端 `module-editor-agent` 领域校验必须同时拒绝纯附件消息。 - 会话管理入口在对话框头部:当前会话标题 + 历史会话下拉(按更新时间倒序)+ 新建对话按钮,全部包在对话框内。 +- 右上角删除当前对话的危险确认框继续使用 `PlatformDangerConfirmDialog`;其 portal 主题由 `UnifiedModal` 统一恢复,panel 使用 `platform-remap-surface`,且层级与附件选择弹窗一致,避免背景透明、错色或被画布控件遮挡。 - 当前会话没有任何已发送消息时,新建对话按钮置灰且不可点击;输入框草稿和未发送附件不算会话内容。当前会话已有消息时可新建,新建成功后只切换到返回的空白会话,输入文字、附件及附件选择状态与切换历史会话时一样原样保留,旧会话继续保留在历史会话下拉中;创建失败同样不修改草稿。 - 新会话创建请求 pending 时禁用历史会话下拉和发送动作,但输入框与附件仍可编辑;会话列表或历史消息加载期间同样禁用发送。表单提交处理器必须复用相同门禁,不能先清空草稿再由 hook 静默跳过发送。 - 快速切换会话或会话轮询刷新产生并发详情请求时,每个请求必须获得唯一且单调递增的请求序号;前端只允许最后发起且有权生效的请求更新当前会话、消息、错误和加载态。被正在进行的会话切换压制的旧会话 refresh 不得提前结束新切换的加载态,旧响应也不得覆盖用户最新选择。 @@ -78,7 +79,7 @@ - 「素材库」页签:账号级素材库(复用 `ImageCanvasAssetLibrary` 数据源); - 多选 + 底部「取消 / 应用」。 - 网格末尾上传格为后续补齐项;在上传格未落地前,对话附件只从已有画布资源和账号素材库选择。后续若从对话入口上传图片,必须复用素材库 / 画布资源登记链路,不新增对话私有图片类型。 -- 附件选择弹窗使用 `PlatformToolModalShell` 承接 portal 主题变量和不透明 panel 背景;不能直接把未注入 `platform-theme` 的 `UnifiedModal` portal 到 `document.body`,否则 `--platform-modal-fill` 失效后面板会变透明。 +- 附件选择弹窗使用 `PlatformToolModalShell` 承接白底 panel 和标准间距;底层 `UnifiedModal` 会把当前 `platform-theme` 自动注入 portal overlay,保证 `--platform-modal-fill` 在 `document.body` 下仍有效。 - 应用后附件以胶囊 chip 挂在输入框上方;发出的消息内附件渲染为纯文本胶囊 chip(名称 + 小图标),**默认无缩略图,鼠标悬浮才浮出缩略图预览**。 - 附件领域形状:统一为画布资源 / 素材库对象引用(`resourceId` / `assetId` + 可选 `objectKey`),不存在只属于对话的第三种图;单条消息上限 9 张(前后端共同校验)。前端可携带展示用 `imageSrc` / `thumbnailSrc`,后端必须按当前工程和当前账号重新归一、校验归属与 `objectKey`。 - 附件 `label` 是人类可读的展示元数据,统一限制为最多 24 个 Unicode 码点。归一化时先去掉首尾空白,删除控制字符以及除 `-`、`_`、`.` 之外的 ASCII 标点,把连续空白折叠为一个半角空格,再按 24 码点截断;只含被过滤字符的 label 视为缺失。中文等非 ASCII 标点不属于本轮过滤范围。 diff --git a/src/components/auth/PlatformAuthModalShell.tsx b/src/components/auth/PlatformAuthModalShell.tsx index e7ce21e21..c6c719a7e 100644 --- a/src/components/auth/PlatformAuthModalShell.tsx +++ b/src/components/auth/PlatformAuthModalShell.tsx @@ -57,9 +57,10 @@ export function PlatformAuthModalShell({ closeOnEscape={false} size={size} showHeader={showHeader} + portalTheme={platformTheme} zIndexClassName={zIndexClassName} overlayClassName={joinClassNames( - `platform-theme platform-theme--${platformTheme} text-[var(--platform-text-strong)]`, + 'text-[var(--platform-text-strong)]', overlaySpacing === 'default' && '!px-3 !py-4 sm:!p-4', overlayClassName, )} diff --git a/src/components/common/CreativeImageInputPanel.test.tsx b/src/components/common/CreativeImageInputPanel.test.tsx index 052491da1..cf0fa8ab9 100644 --- a/src/components/common/CreativeImageInputPanel.test.tsx +++ b/src/components/common/CreativeImageInputPanel.test.tsx @@ -289,6 +289,7 @@ test('creative image input panel confirms before removing uploaded image', () => fireEvent.click(screen.getByRole('button', { name: '移除拼图图片' })); const dialog = screen.getByRole('dialog', { name: '移除拼图图片?' }); + expect(dialog.parentElement?.className).toContain('platform-theme--light'); expect(within(dialog).getByText('移除后需要重新上传图片。')).toBeTruthy(); fireEvent.click(within(dialog).getByRole('button', { name: '移除' })); expect(onMainImageRemove).toHaveBeenCalledTimes(1); diff --git a/src/components/common/LegalDocumentModal.tsx b/src/components/common/LegalDocumentModal.tsx index f0291cd60..d6c6e93e2 100644 --- a/src/components/common/LegalDocumentModal.tsx +++ b/src/components/common/LegalDocumentModal.tsx @@ -88,9 +88,7 @@ export function LegalDocumentModal({ size="md" closeLabel="关闭法律信息" zIndexClassName={zIndexClassName ?? 'z-[150]'} - overlayClassName={`platform-theme ${ - platformTheme ? `platform-theme--${platformTheme}` : '' - }`} + portalTheme={platformTheme ?? 'auto'} panelClassName="platform-remap-surface rounded-t-[1.4rem] sm:rounded-[1.4rem]" headerClassName="items-center" bodyClassName="px-4 py-0 sm:px-5" diff --git a/src/components/common/PlatformDangerConfirmDialog.test.tsx b/src/components/common/PlatformDangerConfirmDialog.test.tsx index 9db88d4c3..f52395f95 100644 --- a/src/components/common/PlatformDangerConfirmDialog.test.tsx +++ b/src/components/common/PlatformDangerConfirmDialog.test.tsx @@ -24,6 +24,10 @@ test('renders a standard danger confirmation with cancel and confirm actions', ( const dialog = screen.getByRole('dialog', { name: '删除作品' }); + expect(dialog.parentElement?.className).toContain('platform-theme--light'); + expect(dialog.parentElement?.className).toContain('z-[140]'); + expect(dialog.className).toContain('platform-remap-surface'); + expect(dialog.className).toContain('shadow-[0_24px_80px_rgba(0,0,0,0.55)]'); expect(within(dialog).getByText('确认删除《潮雾列岛》吗?')).toBeTruthy(); expect(within(dialog).getByText('删除后不可恢复。')).toBeTruthy(); diff --git a/src/components/common/PlatformDangerConfirmDialog.tsx b/src/components/common/PlatformDangerConfirmDialog.tsx index 391bed0df..86e18ac29 100644 --- a/src/components/common/PlatformDangerConfirmDialog.tsx +++ b/src/components/common/PlatformDangerConfirmDialog.tsx @@ -21,8 +21,13 @@ type PlatformDangerConfirmDialogProps = { panelClassName?: string; footerClassName?: string; confirmClassName?: string; + zIndexClassName?: string; }; +function joinClassNames(...classNames: Array) { + return classNames.filter(Boolean).join(' '); +} + /** * 平台危险确认弹窗。 * 统一承接需要“确认 / 取消 + 危险主动作”语义的标准弹窗壳层。 @@ -46,6 +51,7 @@ export function PlatformDangerConfirmDialog({ panelClassName, footerClassName, confirmClassName, + zIndexClassName = 'z-[140]', }: PlatformDangerConfirmDialogProps) { return ( {children} diff --git a/src/components/common/PlatformImagePreviewModal.test.tsx b/src/components/common/PlatformImagePreviewModal.test.tsx index c09d35b2c..52a80d5cb 100644 --- a/src/components/common/PlatformImagePreviewModal.test.tsx +++ b/src/components/common/PlatformImagePreviewModal.test.tsx @@ -103,6 +103,7 @@ test('renders full-screen image preview with zoom controls and dark backdrop', ( expect( dialog.parentElement?.className.includes('!bg-black'), ).toBe(true); + expect(dialog.parentElement?.className).not.toContain('platform-theme--'); expect(screen.getByRole('button', { name: '放大图片' })).toBeTruthy(); expect(screen.getByRole('button', { name: '缩小图片' })).toBeTruthy(); expect(screen.getByRole('button', { name: '重置图片缩放' })).toBeTruthy(); diff --git a/src/components/common/PlatformImagePreviewModal.tsx b/src/components/common/PlatformImagePreviewModal.tsx index 8b9c2cf57..ab29a5087 100644 --- a/src/components/common/PlatformImagePreviewModal.tsx +++ b/src/components/common/PlatformImagePreviewModal.tsx @@ -243,6 +243,8 @@ export function PlatformImagePreviewModal({ showHeader={false} showCloseButton={false} size="fullscreen" + // 全黑自绘查看器不需要平台变量;避免 light / dark remap 改写黑底、白字和暗色控件。 + portalTheme="none" zIndexClassName={zIndexClassName} overlayClassName="!items-stretch !justify-stretch !bg-black !p-0 !backdrop-blur-none" panelClassName="platform-image-preview-modal !h-[100dvh] !max-h-none !max-w-none !rounded-none border-0 bg-black text-white shadow-none" diff --git a/src/components/common/PlatformReportDialog.test.tsx b/src/components/common/PlatformReportDialog.test.tsx index b6243ef07..993fa47cf 100644 --- a/src/components/common/PlatformReportDialog.test.tsx +++ b/src/components/common/PlatformReportDialog.test.tsx @@ -10,6 +10,10 @@ vi.mock('../../services/clipboard', () => ({ copyTextToClipboard: vi.fn(), })); +vi.mock('../auth/AuthUiContext', () => ({ + useAuthUi: () => ({ platformTheme: 'dark' }), +})); + afterEach(() => { vi.clearAllMocks(); }); @@ -31,6 +35,8 @@ test('renders report fields and copies the joined report lines', async () => { ); const dialog = screen.getByRole('dialog', { name: '统一报告' }); + expect(dialog.parentElement?.className).toContain('platform-theme--light'); + expect(dialog.parentElement?.className).not.toContain('platform-theme--dark'); expect(within(dialog).getByText('拼图草稿 puzzle-session-1')).toBeTruthy(); expect(within(dialog).getByText('已完成')).toBeTruthy(); diff --git a/src/components/common/PlatformReportDialog.tsx b/src/components/common/PlatformReportDialog.tsx index 8aaedf52f..92cc58bbc 100644 --- a/src/components/common/PlatformReportDialog.tsx +++ b/src/components/common/PlatformReportDialog.tsx @@ -54,6 +54,8 @@ export function PlatformReportDialog({ open={open} title={title} onClose={onClose} + // 报告中的二维码 / 扫码区固定使用浅色背景,保证对比度和识别率。 + platformTheme="light" overlayClassName={overlayClassName} panelClassName={panelClassName} bodyClassName="space-y-3" diff --git a/src/components/common/PlatformToolModalShell.tsx b/src/components/common/PlatformToolModalShell.tsx index ea069b80c..dbfb60907 100644 --- a/src/components/common/PlatformToolModalShell.tsx +++ b/src/components/common/PlatformToolModalShell.tsx @@ -1,6 +1,5 @@ import type { ReactNode } from 'react'; -import { useAuthUi } from '../auth/AuthUiContext'; import { UnifiedModal } from './UnifiedModal'; type PlatformToolModalShellProps = { @@ -50,9 +49,6 @@ export function PlatformToolModalShell({ bodyClassName, footerClassName, }: PlatformToolModalShellProps) { - const resolvedPlatformTheme = - useAuthUi()?.platformTheme ?? 'light'; - return ( ({ + useAuthUi: () => ({ platformTheme: 'dark' }), +})); + test('renders platform utility info modal shell with default platform styling', () => { render( ({ copyTextToClipboard: vi.fn(), })); +vi.mock('../auth/AuthUiContext', () => ({ + useAuthUi: () => ({ platformTheme: 'dark' }), +})); + const payload: PublishShareModalPayload = { title: '暖灯猫街', publicWorkCode: 'PZ-00000001', @@ -119,7 +123,7 @@ describe('PublishShareModal', () => { const dialog = screen.getByRole('dialog', { name: '分享给朋友' }); expect(dialog.parentElement?.className).toContain('!items-center'); - expect(dialog.parentElement?.className).toContain('platform-theme--light'); + expect(dialog.parentElement?.className).toContain('platform-theme--dark'); expect(dialog.className).toContain('platform-modal-shell'); expect(dialog.className).toContain('rounded-[1.75rem]'); expect(dialog.getAttribute('style')).toBeNull(); diff --git a/src/components/common/PublishShareModal.tsx b/src/components/common/PublishShareModal.tsx index 16e193aa6..eac1385f4 100644 --- a/src/components/common/PublishShareModal.tsx +++ b/src/components/common/PublishShareModal.tsx @@ -10,7 +10,6 @@ import { openHostShare, openHostShareGrid, } from '../../services/host-bridge/hostBridge'; -import { useAuthUi } from '../auth/AuthUiContext'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; import { PlatformUtilityInfoModal } from './PlatformUtilityInfoModal'; import { downloadPublishShareCardImage } from './publishShareCardImage'; @@ -77,7 +76,6 @@ export function PublishShareModal({ payload, onClose, }: PublishShareModalProps) { - const platformTheme = useAuthUi()?.platformTheme ?? 'light'; const [copyState, setCopyState] = useState('idle'); const [downloadState, setDownloadState] = useState('idle'); const [gridState, setGridState] = useState('idle'); @@ -233,7 +231,6 @@ export function PublishShareModal({ open={open && Boolean(payload)} title="分享给朋友" onClose={onClose} - platformTheme={platformTheme} panelClassName="rounded-[1.75rem]" footerClassName="border-t-0 px-4 pb-5 pt-0 sm:px-5" footer={ diff --git a/src/components/common/UnifiedConfirmDialog.tsx b/src/components/common/UnifiedConfirmDialog.tsx index 8bc3fb4b0..a6f59d718 100644 --- a/src/components/common/UnifiedConfirmDialog.tsx +++ b/src/components/common/UnifiedConfirmDialog.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from 'react'; import { PlatformActionButton } from './PlatformActionButton'; -import { UnifiedModal } from './UnifiedModal'; +import { UnifiedModal, type UnifiedModalPortalTheme } from './UnifiedModal'; type UnifiedConfirmDialogTone = 'primary' | 'danger'; @@ -24,6 +24,7 @@ type UnifiedConfirmDialogProps = { closeOnBackdrop?: boolean; showCloseButton?: boolean; portal?: boolean; + portalTheme?: UnifiedModalPortalTheme; size?: 'sm' | 'md'; overlayClassName?: string; panelClassName?: string; @@ -54,6 +55,7 @@ export function UnifiedConfirmDialog({ closeOnBackdrop = true, showCloseButton = true, portal = true, + portalTheme = 'auto', size = 'sm', overlayClassName, panelClassName, @@ -74,6 +76,7 @@ export function UnifiedConfirmDialog({ closeOnBackdrop={closeOnBackdrop && !busy} showCloseButton={showCloseButton} portal={portal} + portalTheme={portalTheme} size={size} overlayClassName={overlayClassName} panelClassName={panelClassName} diff --git a/src/components/common/UnifiedModal.tsx b/src/components/common/UnifiedModal.tsx index fc789b4f0..24f0a56e2 100644 --- a/src/components/common/UnifiedModal.tsx +++ b/src/components/common/UnifiedModal.tsx @@ -8,6 +8,8 @@ import { } from 'react'; import { createPortal } from 'react-dom'; +import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime'; +import { useAuthUi } from '../auth/AuthUiContext'; import { PlatformModalCloseButton } from './PlatformModalCloseButton'; type UnifiedModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'fullscreen'; @@ -18,6 +20,8 @@ type UnifiedModalCloseIcon = ComponentProps< typeof PlatformModalCloseButton >['icon']; +export type UnifiedModalPortalTheme = PlatformTheme | 'auto' | 'none'; + type UnifiedModalProps = { open: boolean; title: string; @@ -37,6 +41,7 @@ type UnifiedModalProps = { closeVariant?: UnifiedModalCloseVariant; closeIcon?: UnifiedModalCloseIcon; portal?: boolean; + portalTheme?: UnifiedModalPortalTheme; zIndexClassName?: string; overlayClassName?: string; overlayStyle?: CSSProperties; @@ -63,6 +68,44 @@ function joinClassNames( return classNames.filter(Boolean).join(' '); } +function resolvePortalOverlayClassName({ + overlayClassName, + portalTheme, + contextualTheme, +}: { + overlayClassName?: string; + portalTheme: UnifiedModalPortalTheme; + contextualTheme: PlatformTheme; +}) { + if (portalTheme === 'none') { + return overlayClassName; + } + + const classNames = overlayClassName?.split(/\s+/u).filter(Boolean) ?? []; + const explicitTheme = classNames.find( + (className): className is `platform-theme--${PlatformTheme}` => + className === 'platform-theme--light' || + className === 'platform-theme--dark', + ); + const resolvedTheme = + portalTheme === 'auto' + ? ((explicitTheme?.replace('platform-theme--', '') as + PlatformTheme | undefined) ?? contextualTheme) + : portalTheme; + const remainingClassNames = classNames.filter( + (className) => + className !== 'platform-theme' && + className !== 'platform-theme--light' && + className !== 'platform-theme--dark', + ); + + return joinClassNames( + 'platform-theme', + `platform-theme--${resolvedTheme}`, + ...remainingClassNames, + ); +} + function UnifiedModalContent({ open, title, @@ -91,7 +134,7 @@ function UnifiedModalContent({ bodyClassName, footerClassName, panelStyle, -}: Omit) { +}: Omit) { const generatedTitleId = useId(); const descriptionId = useId(); const titleId = titleIdProp ?? generatedTitleId; @@ -231,10 +274,30 @@ function UnifiedModalContent({ * 统一模态窗口外壳。 * 业务组件只传入标题、内容和操作区;遮罩、无障碍属性、Escape 与移动端布局在这里收口。 */ -export function UnifiedModal({ portal = true, ...props }: UnifiedModalProps) { +export function UnifiedModal({ + portal = true, + portalTheme = 'auto', + overlayClassName, + ...props +}: UnifiedModalProps) { + const contextualTheme = useAuthUi()?.platformTheme ?? 'light'; + const resolvedProps = { + ...props, + overlayClassName: portal + ? resolvePortalOverlayClassName({ + overlayClassName, + portalTheme, + contextualTheme, + }) + : overlayClassName, + }; + if (!portal || typeof document === 'undefined') { - return ; + return ; } - return createPortal(, document.body); + return createPortal( + , + document.body, + ); } diff --git a/src/components/creation-home/CreationLandingView.test.tsx b/src/components/creation-home/CreationLandingView.test.tsx index a85a5d614..d80b00858 100644 --- a/src/components/creation-home/CreationLandingView.test.tsx +++ b/src/components/creation-home/CreationLandingView.test.tsx @@ -569,6 +569,10 @@ describe('CreationLandingView', () => { ); const dialog = await screen.findByRole('dialog', { name: '角色英雄' }); + expect(dialog.parentElement?.className).not.toContain('platform-theme--'); + expect(dialog.parentElement?.className).toContain( + 'creation-landing__showcase-modal-overlay', + ); expect(within(dialog).getByText('赞')).toBeTruthy(); expect(within(dialog).getByText('3')).toBeTruthy(); expect(within(dialog).queryByRole('button', { name: /点赞/ })).toBeNull(); diff --git a/src/components/creation-home/CreationLandingView.tsx b/src/components/creation-home/CreationLandingView.tsx index b3496951f..1455f8c35 100644 --- a/src/components/creation-home/CreationLandingView.tsx +++ b/src/components/creation-home/CreationLandingView.tsx @@ -409,6 +409,8 @@ function CreationShowcaseModal({ showHeader={false} showCloseButton={false} size="fullscreen" + // 素材舞台是独立黑底视觉;避免平台主题 remap 把它改成普通白底工具弹窗。 + portalTheme="none" zIndexClassName="z-[120]" overlayClassName="creation-landing__showcase-modal-overlay" panelClassName="creation-landing__showcase-modal-panel" @@ -1174,7 +1176,8 @@ export function CreationLandingView({ showHeader={false} showCloseButton={false} size="sm" - overlayClassName="platform-theme platform-theme--light platform-mobile-home-welcome-overlay !items-center !p-4" + portalTheme="light" + overlayClassName="platform-mobile-home-welcome-overlay !items-center !p-4" panelClassName="platform-remap-surface platform-mobile-home-welcome-dialog" bodyClassName="platform-mobile-home-welcome-dialog__body" footerClassName="platform-mobile-home-welcome-dialog__footer" diff --git a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx index 03eb280bf..2bc4f979c 100644 --- a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx +++ b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx @@ -338,9 +338,7 @@ describe('EditorAgentConversationPanelView', () => { expect( screen.getByText('一二三四五六七八九十甲乙丙丁戊己庚辛壬癸子丑寅卯'), ).toBeTruthy(); - expect( - screen.getByRole('option', { name: '角色参考' }), - ).toBeTruthy(); + expect(screen.getByRole('option', { name: '角色参考' })).toBeTruthy(); expect(screen.getByRole('option', { name: '新对话' })).toBeTruthy(); fireEvent.change(screen.getByLabelText('发送给画布 Agent'), { @@ -530,9 +528,9 @@ describe('EditorAgentConversationPanelView', () => { (screen.getByLabelText('发送给画布 Agent') as HTMLTextAreaElement).value, ).toBe('需要保留的草稿'); expect(screen.getByText('已经看到画布内容')).toBeTruthy(); - expect( - (screen.getByLabelText('当前对话') as HTMLSelectElement).value, - ).toBe('conversation-1'); + expect((screen.getByLabelText('当前对话') as HTMLSelectElement).value).toBe( + 'conversation-1', + ); }); it('preserves newer draft edits while creating a conversation', async () => { @@ -1989,6 +1987,14 @@ describe('EditorAgentConversationPanelView', () => { fireEvent.click(screen.getByRole('button', { name: '删除当前对话' })); const confirmDialog = screen.getByRole('dialog', { name: '删除对话' }); + expect(confirmDialog.parentElement?.className).toContain( + 'platform-theme--light', + ); + expect(confirmDialog.parentElement?.className).toContain('z-[140]'); + expect(confirmDialog.className).toContain('platform-remap-surface'); + expect( + within(confirmDialog).getByText('确认删除这个对话吗?'), + ).toBeTruthy(); fireEvent.click( within(confirmDialog).getByRole('button', { name: '确认删除' }), ); diff --git a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx index 4b553436f..43416aeea 100644 --- a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx +++ b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx @@ -391,7 +391,9 @@ export function EditorAgentConversationPanelView({ setDeleteConfirmOpen(false), ); }} - /> + > + 确认删除这个对话吗? + ); } diff --git a/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx b/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx index 2702e41fb..b73105242 100644 --- a/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx +++ b/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx @@ -118,9 +118,11 @@ describe('MessageBubble', () => { clientX: 30, clientY: 40, }); + const menu = screen.getByRole('menu', { name: '消息右键菜单' }); + expect(menu.style.zIndex).toBe('60'); expect( - screen.getByRole('menu', { name: '消息右键菜单' }).style.zIndex, - ).toBe('60'); + menu.closest('.image-canvas-editor__portal-theme')?.className, + ).toContain('platform-theme--light'); fireEvent.click(screen.getByRole('menuitem', { name: '复制文本' })); await waitFor(() => diff --git a/src/components/image-editor/EditorAgentConversation/MessageBubbleRightClickMenu.tsx b/src/components/image-editor/EditorAgentConversation/MessageBubbleRightClickMenu.tsx index 39fe51442..4147147d7 100644 --- a/src/components/image-editor/EditorAgentConversation/MessageBubbleRightClickMenu.tsx +++ b/src/components/image-editor/EditorAgentConversation/MessageBubbleRightClickMenu.tsx @@ -1,8 +1,8 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react'; -import { createPortal } from 'react-dom'; import type { ImageCanvasActionResult } from '@/src/components/image-editor/ImageCanvasActionsContext.ts'; +import { ImageCanvasEditorPortal } from '../ImageCanvasEditorPortal.tsx'; import { contextAssetMediaSrc, type EditorAgentContextAsset, @@ -158,110 +158,115 @@ export function MessageBubbleRightClickMenu({ }; }, [onClose]); - if (typeof document === 'undefined') { - return null; - } - - return createPortal( -
event.preventDefault()} - > - {target.kind === 'text' ? ( - - ) : ( - <> - {target.asset.kind === 'generated_media' && - target.asset.resourceId?.trim() ? ( - - ) : null} - {target.asset.mediaType === 'image' ? ( - <> - {contextAssetMediaSrc(target.asset).trim() ? ( - - ) : null} - - - ) : null} + return ( + +
event.preventDefault()} + > + {target.kind === 'text' ? ( - - )} -
, - document.body, + ) : ( + <> + {target.asset.kind === 'generated_media' && + target.asset.resourceId?.trim() ? ( + + ) : null} + {target.asset.mediaType === 'image' ? ( + <> + {contextAssetMediaSrc(target.asset).trim() ? ( + + ) : null} + + + ) : null} + + + )} +
+ ); } diff --git a/src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx b/src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx index 381706120..082a6c920 100644 --- a/src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx @@ -1,12 +1,25 @@ /* @vitest-environment jsdom */ import { fireEvent, render, screen } from '@testing-library/react'; -import { useState } from 'react'; +import { type ComponentProps, type ReactNode, useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; +import { AuthUiContext } from '../auth/AuthUiContext'; import { ImageCanvasEditGenerationModalView } from './ImageCanvasEditGenerationModalView'; import type { GenerateDialogState } from './ImageCanvasEditorTypes'; +const DARK_AUTH_UI_VALUE = { + platformTheme: 'dark', +} as ComponentProps['value']; + +function withDarkAuthUi(children: ReactNode) { + return ( + + {children} + + ); +} + function createDialog( patch: Partial = {}, ): GenerateDialogState { @@ -48,8 +61,17 @@ function EditGenerationModalHarness({ describe('ImageCanvasEditGenerationModalView', () => { it('updates prompt and submits edit generation', () => { const submitEdit = vi.fn(); - render(); + render( + withDarkAuthUi(), + ); + const modal = screen + .getAllByRole('dialog', { name: '修改图片' }) + .find((element) => element.classList.contains('platform-modal-shell')); + expect(modal?.parentElement?.className).toContain('platform-theme--light'); + expect(modal?.parentElement?.className).not.toContain( + 'platform-theme--dark', + ); fireEvent.change(screen.getByLabelText('生成提示词'), { target: { value: '新的修改提示' }, }); diff --git a/src/components/image-editor/ImageCanvasEditGenerationModalView.tsx b/src/components/image-editor/ImageCanvasEditGenerationModalView.tsx index 7ebb37464..114f31175 100644 --- a/src/components/image-editor/ImageCanvasEditGenerationModalView.tsx +++ b/src/components/image-editor/ImageCanvasEditGenerationModalView.tsx @@ -3,9 +3,7 @@ import { type Dispatch, type SetStateAction } from 'react'; import { UnifiedModal } from '../common/UnifiedModal'; import { ImageCanvasBasicGenerationComposerView } from './ImageCanvasBasicGenerationComposerView'; import type { GenerateDialogState } from './ImageCanvasEditorTypes'; -import { - calculateEditorImageGenerationPrice, -} from './ImageCanvasGenerationModel'; +import { calculateEditorImageGenerationPrice } from './ImageCanvasGenerationModel'; type ImageCanvasEditGenerationModalViewProps = { dialog: GenerateDialogState | null; @@ -27,11 +25,13 @@ export function ImageCanvasEditGenerationModalView({ imageSize: dialogImageSize, }); + // TODO: Remove this override after full dark style support. return ( setGenerateDialog(null)} diff --git a/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx b/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx index e47cddfde..283468d8f 100644 --- a/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx +++ b/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx @@ -1,11 +1,25 @@ /* @vitest-environment jsdom */ import { fireEvent, render, screen, within } from '@testing-library/react'; +import type { ComponentProps, ReactNode } from 'react'; import { describe, expect, it, vi } from 'vitest'; +import { AuthUiContext } from '../auth/AuthUiContext'; import type { CanvasLayer } from './ImageCanvasEditorTypes'; import { ImageCanvasMetadataModalView } from './ImageCanvasMetadataModalView'; +const DARK_AUTH_UI_VALUE = { + platformTheme: 'dark', +} as ComponentProps['value']; + +function withDarkAuthUi(children: ReactNode) { + return ( + + {children} + + ); +} + function createLayer(overrides: Partial = {}): CanvasLayer { return { id: 'layer-1', @@ -27,37 +41,45 @@ function createLayer(overrides: Partial = {}): CanvasLayer { describe('ImageCanvasMetadataModalView', () => { it('renders generated layer metadata with generation inputs and references', () => { render( - , + withDarkAuthUi( + , + ), ); const dialog = screen.getByRole('dialog', { name: '图片信息' }); + expect(dialog.parentElement?.className).toContain('platform-theme--light'); + expect(dialog.parentElement?.className).not.toContain( + 'platform-theme--dark', + ); expect(within(dialog).queryByText('生成主图')).toBeNull(); expect(within(dialog).getByText('生成图片')).toBeTruthy(); expect(within(dialog).getByText('生成提示词')).toBeTruthy(); expect(within(dialog).getByText('清爽游戏按钮')).toBeTruthy(); expect(within(dialog).getByText('参考图')).toBeTruthy(); expect(within(dialog).getByText('角色立绘')).toBeTruthy(); - expect(within(dialog).getByText('项目资源 · resource-reference')).toBeTruthy(); + expect( + within(dialog).getByText('项目资源 · resource-reference'), + ).toBeTruthy(); expect(within(dialog).getByText('Model')).toBeTruthy(); expect(within(dialog).getByText('gpt-image-2')).toBeTruthy(); expect(within(dialog).getByText('1024 x 768 px')).toBeTruthy(); @@ -157,7 +179,9 @@ describe('ImageCanvasMetadataModalView', () => { expect(within(dialog).getByText('上传图片')).toBeTruthy(); expect(within(dialog).getAllByText('-').length).toBeGreaterThanOrEqual(3); - fireEvent.click(within(dialog).getByRole('button', { name: '关闭图片信息' })); + fireEvent.click( + within(dialog).getByRole('button', { name: '关闭图片信息' }), + ); expect(onClose).toHaveBeenCalledTimes(1); }); @@ -193,7 +217,9 @@ describe('ImageCanvasMetadataModalView', () => { expect(within(dialog).getByText('kling3.0-omni')).toBeTruthy(); expect(within(dialog).getByText('1280 x 720 px')).toBeTruthy(); - fireEvent.click(within(dialog).getByRole('button', { name: '关闭视频信息' })); + fireEvent.click( + within(dialog).getByRole('button', { name: '关闭视频信息' }), + ); expect(onClose).toHaveBeenCalledTimes(1); }); diff --git a/src/components/image-editor/ImageCanvasMetadataModalView.tsx b/src/components/image-editor/ImageCanvasMetadataModalView.tsx index 3b5a1768c..d0fb16e55 100644 --- a/src/components/image-editor/ImageCanvasMetadataModalView.tsx +++ b/src/components/image-editor/ImageCanvasMetadataModalView.tsx @@ -40,13 +40,15 @@ export function ImageCanvasMetadataModalView({ ? '视频' : layer?.mediaType === 'image-sequence' ? '序列帧' - : '图片'; + : '图片'; + // TODO: Remove this override after full dark style support. return (
Model
-
{getEditorLayerModelDisplayName(layer.model)}
+
+ {getEditorLayerModelDisplayName(layer.model)} +
{layer.mediaType === 'audio' ? ( <>
时长
-
- {formatLayerDurationForDisplay(layer)} -
+
{formatLayerDurationForDisplay(layer)}
) : ( <> diff --git a/src/components/image-editor/ImageCanvasShortcutDialogView.test.tsx b/src/components/image-editor/ImageCanvasShortcutDialogView.test.tsx index 53dd3356e..5b12684ff 100644 --- a/src/components/image-editor/ImageCanvasShortcutDialogView.test.tsx +++ b/src/components/image-editor/ImageCanvasShortcutDialogView.test.tsx @@ -1,16 +1,30 @@ /* @vitest-environment jsdom */ import { render, screen, within } from '@testing-library/react'; +import type { ComponentProps } from 'react'; import { describe, expect, it, vi } from 'vitest'; +import { AuthUiContext } from '../auth/AuthUiContext'; import { ImageCanvasShortcutDialogView } from './ImageCanvasShortcutDialogView'; +const DARK_AUTH_UI_VALUE = { + platformTheme: 'dark', +} as ComponentProps['value']; + describe('ImageCanvasShortcutDialogView', () => { it('renders the Windows canvas shortcut table', () => { - render(); + render( + + + , + ); const dialog = screen.getByRole('dialog', { name: '画布快捷键' }); + expect(dialog.parentElement?.className).toContain('platform-theme--light'); + expect(dialog.parentElement?.className).not.toContain( + 'platform-theme--dark', + ); expect(within(dialog).getByRole('heading', { name: '编辑' })).toBeTruthy(); expect(within(dialog).getByText('全选画布元素')).toBeTruthy(); expect(within(dialog).getAllByText('Ctrl').length).toBeGreaterThan(0); diff --git a/src/components/image-editor/ImageCanvasShortcutDialogView.tsx b/src/components/image-editor/ImageCanvasShortcutDialogView.tsx index 265d88def..f8f56340e 100644 --- a/src/components/image-editor/ImageCanvasShortcutDialogView.tsx +++ b/src/components/image-editor/ImageCanvasShortcutDialogView.tsx @@ -10,11 +10,13 @@ export function ImageCanvasShortcutDialogView({ open, onClose, }: ImageCanvasShortcutDialogViewProps) { + // TODO: Remove this override after full dark style support. return ( ['value']; + +afterEach(() => { + cleanup(); +}); + +function withDarkAuthUi(children: ReactNode) { + return ( + + {children} + + ); +} + +function getOverlay() { + return screen.getByRole('dialog', { name: '主题弹窗' }) + .parentElement as HTMLElement; +} + +test('restores the contextual platform theme on the portal overlay', () => { + render( + withDarkAuthUi( + {}}> + 弹窗内容 + , + ), + ); + + expect(getOverlay().className).toContain('platform-theme'); + expect(getOverlay().className).toContain('platform-theme--dark'); + expect(getOverlay().parentElement).toBe(document.body); +}); + +test('falls back to light when no AuthUi provider is available', () => { + render( + {}}> + 弹窗内容 + , + ); + + expect(getOverlay().className).toContain('platform-theme--light'); +}); + +test('respects an existing explicit theme and supports a forced override', () => { + const { rerender } = render( + withDarkAuthUi( + {}} + overlayClassName="platform-theme platform-theme--light custom-overlay" + > + 弹窗内容 + , + ), + ); + + expect(getOverlay().className).toContain('platform-theme--light'); + expect(getOverlay().className).not.toContain('platform-theme--dark'); + expect(getOverlay().className).toContain('custom-overlay'); + + rerender( + withDarkAuthUi( + {}} + portalTheme="dark" + overlayClassName="platform-theme platform-theme--light custom-overlay" + > + 弹窗内容 + , + ), + ); + + expect(getOverlay().className).toContain('platform-theme--dark'); + expect(getOverlay().className).not.toContain('platform-theme--light'); +}); + +test('keeps custom and inline modals outside automatic portal theming', () => { + const { rerender } = render( + withDarkAuthUi( + {}} + portalTheme="none" + overlayClassName="custom-overlay" + > + 弹窗内容 + , + ), + ); + + expect(getOverlay().className).toContain('custom-overlay'); + expect(getOverlay().className).not.toContain('platform-theme--'); + + rerender( + withDarkAuthUi( + {}} + portal={false} + overlayClassName="inline-overlay" + > + 弹窗内容 + , + ), + ); + + expect(getOverlay().className).toContain('inline-overlay'); + expect(getOverlay().className).not.toContain('platform-theme--'); +}); diff --git a/src/components/platform-entry/PlatformActiveProfileView.test.tsx b/src/components/platform-entry/PlatformActiveProfileView.test.tsx index 8b3e46468..ad71ea2d2 100644 --- a/src/components/platform-entry/PlatformActiveProfileView.test.tsx +++ b/src/components/platform-entry/PlatformActiveProfileView.test.tsx @@ -83,6 +83,12 @@ describe('PlatformActiveProfileView', () => { expect( screen.getByRole('button', { name: /开发者 API Key/u }), ).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: '用户协议' })); + const legalDialog = screen.getByRole('dialog', { name: '用户协议' }); + expect(legalDialog.parentElement?.className).toContain( + 'platform-theme--light', + ); }); it('edits the nickname through the profile identity action', async () => { diff --git a/vitest.config.ts b/vitest.config.ts index 844370c57..fe6a7cb21 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -50,6 +50,12 @@ export default defineConfig({ 'src/components/auth/**/*.test.tsx', 'src/components/creation-home/**/*.test.ts', 'src/components/creation-home/**/*.test.tsx', + 'src/components/common/CreativeImageInputPanel.test.tsx', + 'src/components/common/PlatformDangerConfirmDialog.test.tsx', + 'src/components/common/PlatformImagePreviewModal.test.tsx', + 'src/components/common/PlatformReportDialog.test.tsx', + 'src/components/common/PlatformUtilityInfoModal.test.tsx', + 'src/components/common/PublishShareModal.test.tsx', 'src/components/image-editor/**/*.test.ts', 'src/components/image-editor/**/*.test.tsx', 'src/components/project/**/*.test.ts',