From 832c1787f9c7e14d74da35e0e44521f4948cd6d1 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Wed, 7 Oct 2026 16:28:34 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=BB=E6=93=8D=E4=BD=9C=E8=A1=8C=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E5=86=85=E8=81=94=E7=8A=B6=E6=80=81=E6=96=87=E6=9C=AC?= =?UTF-8?q?=EF=BC=9A=E7=8A=B6=E6=80=81/=E8=AD=A6=E5=91=8A=E6=94=B9?= =?UTF-8?q?=E8=B5=B0=E5=B9=B3=E5=8F=B0=20toast=EF=BC=88=E5=A4=8D=E7=94=A8?= =?UTF-8?q?=20PlatformRuntimeStatusToast=20+=20portal=20=E5=AE=BF=E4=B8=BB?= =?UTF-8?q?=EF=BC=89=EF=BC=8C=E6=89=8B=E5=8A=A8=E9=9D=A2=E6=9D=BF=E6=94=B9?= =?UTF-8?q?=20portal=20=E6=B5=AE=E5=B1=82=EF=BC=8C=E8=A1=8C=E5=86=85?= =?UTF-8?q?=E5=AD=90=E9=A1=B9=E4=B8=8D=E6=94=B6=E7=BC=A9=E4=B8=8D=E6=8D=A2?= =?UTF-8?q?=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/common/PlatformToastHost.tsx | 100 ++++++++++ src/components/common/platformToast.ts | 68 +++++++ .../game-distribution/GameDetailPage.tsx | 174 +++++++++++------- .../GameDistributionPages.test.tsx | 168 ++++++++++++++++- .../game-distribution/gameDistribution.css | 69 +++++++ 5 files changed, 511 insertions(+), 68 deletions(-) create mode 100644 src/components/common/PlatformToastHost.tsx create mode 100644 src/components/common/platformToast.ts diff --git a/src/components/common/PlatformToastHost.tsx b/src/components/common/PlatformToastHost.tsx new file mode 100644 index 000000000..6b3d88cd3 --- /dev/null +++ b/src/components/common/PlatformToastHost.tsx @@ -0,0 +1,100 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; + +import { PlatformRuntimeStatusToast } from './PlatformRuntimeStatusToast'; +import { + normalizePlatformToast, + type PlatformToastInput, + type PlatformToastTone, + subscribePlatformToast, +} from './platformToast'; + +type ToastItem = { + id: number; + text: string; + tone: PlatformToastTone; + durationMs: number | null; +}; + +/** 定时器句柄:浏览器是 number、Node 下是 Timeout 对象,直接用 `clearTimeout` 的入参类型。 */ +type ToastTimerHandle = Parameters[0]; + +let toastSeed = 0; + +/** + * 轻提示宿主:在页面里**只挂一次**。 + * + * - **portal 到 `body`** + `position: fixed`(右下角堆叠),因此完全不参与页面布局 —— + * 这正是用户口径「显示成 toast,不要在这里挤占别的元素的空间」的落点; + * - 视觉与无障碍复用共享组件 `PlatformRuntimeStatusToast`(`role=status`/`alert` + `aria-live`); + * - 自动消失(默认 5s;**错误/警告不自动消失**)+ 每条都能手动关闭(键盘可达); + * - 多条可堆叠;**不抢焦点**(除非是错误级 —— 错误级也只做 `role=alert` 播报,不 programmatic focus, + * 避免打断用户正在输入的内容)。 + */ +export function PlatformToastHost() { + const [toasts, setToasts] = useState([]); + const timersRef = useRef(new Map()); + + const dismiss = useCallback((id: number) => { + const timer = timersRef.current.get(id); + if (timer) { + clearTimeout(timer); + timersRef.current.delete(id); + } + setToasts((current) => current.filter((toast) => toast.id !== id)); + }, []); + + useEffect(() => { + const unsubscribe = subscribePlatformToast((input: PlatformToastInput) => { + if (!input.text?.trim()) return; + const normalized = normalizePlatformToast(input); + toastSeed += 1; + const id = toastSeed; + setToasts((current) => [...current.slice(-3), { id, ...normalized }]); + if (normalized.durationMs !== null) { + timersRef.current.set( + id, + setTimeout(() => dismiss(id), normalized.durationMs), + ); + } + }); + const timers = timersRef.current; + return () => { + unsubscribe(); + for (const timer of timers.values()) clearTimeout(timer); + timers.clear(); + }; + }, [dismiss]); + + if (toasts.length === 0) return null; + + return createPortal( +
+ {toasts.map((toast) => ( + + {toast.text} + + + ))} +
, + document.body, + ); +} + +export default PlatformToastHost; diff --git a/src/components/common/platformToast.ts b/src/components/common/platformToast.ts new file mode 100644 index 000000000..ea67d2b7c --- /dev/null +++ b/src/components/common/platformToast.ts @@ -0,0 +1,68 @@ +/** + * 平台级轻提示(toast)的最小总线。 + * + * 为什么要有它:页面上「状态/错误/未安装提示」这类**一次性反馈**不该内联渲染在操作行里 + * (会把同行按钮挤成两行、竖排)。这里给一个收口:任何地方 `showPlatformToast(...)`, + * 由唯一挂载的 `` 统一渲染(portal 到 `body` + 固定定位 + 自动消失 + 可手动关闭)。 + * + * 视觉与无障碍**复用既有共享组件** `PlatformRuntimeStatusToast`(`role=status|alert` + + * `aria-live` 已由它处理),不另造一套 toast 外观。总线本身用最小的事件方案,和 + * `clientDownloadLauncher` 同一套做法,避免为这一个能力引入全局 store。 + */ +export type PlatformToastTone = + | 'info' + | 'success' + | 'warning' + | 'error' + | 'neutral'; + +export type PlatformToastInput = { + text: string; + tone?: PlatformToastTone; + /** 自动消失毫秒数;`null` = 不自动消失(错误类默认如此,只能手动关)。 */ + durationMs?: number | null; +}; + +export const PLATFORM_TOAST_EVENT = 'genarrative:platform-toast'; + +/** 默认自动消失时间;错误类不自动消失(见 `normalizeToast`)。 */ +export const PLATFORM_TOAST_DEFAULT_DURATION_MS = 5000; + +export function normalizePlatformToast(input: PlatformToastInput): { + text: string; + tone: PlatformToastTone; + durationMs: number | null; +} { + const tone = input.tone ?? 'info'; + const durationMs = + input.durationMs === undefined + ? // 错误/警告都要用户看清,默认不自动消失;其余 5s 消失。 + tone === 'error' || tone === 'warning' + ? null + : PLATFORM_TOAST_DEFAULT_DURATION_MS + : input.durationMs; + return { text: input.text, tone, durationMs }; +} + +/** 弹一条轻提示(等价于把文案交给唯一的 toast 宿主)。 */ +export function showPlatformToast(input: PlatformToastInput | string) { + if (typeof window === 'undefined') return; + const payload = typeof input === 'string' ? { text: input } : input; + window.dispatchEvent( + new CustomEvent(PLATFORM_TOAST_EVENT, { + detail: payload, + }), + ); +} + +/** 订阅轻提示请求;返回取消订阅函数。 */ +export function subscribePlatformToast( + handler: (input: PlatformToastInput) => void, +) { + if (typeof window === 'undefined') return () => {}; + const listener = (event: Event) => { + handler((event as CustomEvent).detail ?? { text: '' }); + }; + window.addEventListener(PLATFORM_TOAST_EVENT, listener); + return () => window.removeEventListener(PLATFORM_TOAST_EVENT, listener); +} diff --git a/src/components/game-distribution/GameDetailPage.tsx b/src/components/game-distribution/GameDetailPage.tsx index 43d6ae9ba..027e34dba 100644 --- a/src/components/game-distribution/GameDetailPage.tsx +++ b/src/components/game-distribution/GameDetailPage.tsx @@ -6,6 +6,7 @@ import { } from '@genarrative/shared/components'; import { ArrowLeft, Gamepad2, Maximize2 } from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; import { GAME_DISTRIBUTION_FORK_AUTHORIZATION_LABELS, @@ -35,6 +36,8 @@ import { CopyFeedbackButton } from '../common/CopyFeedbackButton'; import { PlatformActionButton } from '../common/PlatformActionButton'; import { PlatformMudPointConfirmDialog } from '../common/PlatformMudPointConfirmDialog'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; +import { showPlatformToast } from '../common/platformToast'; +import { PlatformToastHost } from '../common/PlatformToastHost'; import { useCopyFeedback } from '../common/useCopyFeedback'; import { requestClientDownload } from '../creation-home/clientDownloadLauncher'; import { CreatorAuthor } from '../creator/CreatorRelationshipControl'; @@ -673,6 +676,22 @@ function GameDetailContent({ const [isCollectionBusy, setIsCollectionBusy] = useState(false); const [collectionNotice, setCollectionNotice] = useState(''); const serverCollected = game.collected; + /** + * 操作行的状态/提示一律走 toast(portal + fixed):**不在行内占位**,否则会把 + * 「立即玩 / 收藏 / 一键开始共创」挤成两行甚至竖排(2026-10-07 用户口径)。 + */ + useEffect(() => { + if (!collectionNotice) return; + showPlatformToast({ text: collectionNotice, tone: 'warning' }); + }, [collectionNotice]); + useEffect(() => { + if (!forkLaunchNotice) return; + // 「已唤起…」是信息,其余(没检测到客户端 / 禁止共创 / 无法直接唤起)是要用户处理的警告。 + const tone = /没有检测到|无法直接唤起|禁止/u.test(forkLaunchNotice) + ? 'warning' + : 'info'; + showPlatformToast({ text: forkLaunchNotice, tone }); + }, [forkLaunchNotice]); const isCollected = collectionOverride ?? serverCollected ?? false; const canKnowCollectionState = typeof serverCollected === 'boolean' || collectionOverride !== null; @@ -828,11 +847,31 @@ function GameDetailContent({ }, ] : []; + /** 设备/归属类警告同样走 toast(行内那份只留给读屏)。 */ + useEffect(() => { + const warning = + !isPaidLocked && mobilePlaybackBlocked + ? '请在电脑上游玩' + : isEntryMissing && !mobilePlaybackBlocked + ? '暂时拿不到可玩入口,请刷新页面后重试。' + : ownerView && !publishedVersion + ? '作品尚未公开,暂时没有在线可玩版本' + : ''; + if (warning) showPlatformToast({ text: warning, tone: 'warning' }); + }, [ + isEntryMissing, + isPaidLocked, + mobilePlaybackBlocked, + ownerView, + publishedVersion, + ]); return (
+ {/* toast 宿主:portal 到 body、固定定位,因此完全不参与本页布局。 */} + {isCollected ? '已收藏' : '收藏'} - {collectionNotice ? ( - - {collectionNotice} - - ) : null} {/* 「一键开始共创」从共创信息卡搬到主操作行(2026-10-07 用户口径): 它是这部作品的主操作之一,和「立即玩 / 收藏」并列;共创卡内不再重复出现该按钮。 @@ -1085,67 +1119,78 @@ function GameDetailContent({ > {isAuthenticated ? '一键开始共创' : '登录后一键开始共创'} - {(isAuthenticated || forkAuthorization === 'forbidden') && - forkLaunchNotice ? ( - - {forkLaunchNotice} - - ) : null} - {isAuthenticated && isForkPanelOpen ? ( - - - 在陶泥儿客户端里打开「从平台作品开始创作」,粘贴下面的作品 - ID 或作品链接。 - - - 作品 ID:{game.id} - - - 作品链接:{workLink} - - - void copyForkId(game.id)} - /> - void copyForkLink(workLink)} - /> - - - 开始共创会把这部作品的整个工程(含素材与文档)复制到你的项目里继续改造; - 复制后对他人可见,且不可撤销。 - - {forkIdCopyState === 'copied' ? ( - - 作品 ID 已复制,可在客户端「从平台作品开始创作」里粘贴。 - - ) : null} - {forkIdCopyState === 'failed' ? ( - - 复制失败:请手动选中作品 ID 文本。 - - ) : null} - - ) : null} + {isAuthenticated && isForkPanelOpen + ? createPortal( + , + document.body, + ) + : null} ) : null} + {/* + 这些警告过去是行内 span,会把同行按钮挤成两行。现在:**视觉上隐藏但保留 id** + (`aria-describedby` 还指着 `#game-mobile-playback-warning` / `#game-entry-missing-warning`, + 读屏仍能关联到),可见提示改由 toast 承担。 + */} {!isPaidLocked && mobilePlaybackBlocked ? ( 请在电脑上游玩 @@ -1154,14 +1199,17 @@ function GameDetailContent({ {isEntryMissing && !mobilePlaybackBlocked ? ( 暂时拿不到可玩入口,请刷新页面后重试。 ) : null} {ownerView && !publishedVersion ? ( - + 作品尚未公开,暂时没有在线可玩版本 ) : null} diff --git a/src/components/game-distribution/GameDistributionPages.test.tsx b/src/components/game-distribution/GameDistributionPages.test.tsx index 044182164..65f48cde6 100644 --- a/src/components/game-distribution/GameDistributionPages.test.tsx +++ b/src/components/game-distribution/GameDistributionPages.test.tsx @@ -24,6 +24,12 @@ import type { GameDistributionMyGame, GameDistributionVersionStatusEntry, } from '../../services/gameDistributionClient'; +import { + normalizePlatformToast, + PLATFORM_TOAST_DEFAULT_DURATION_MS, + showPlatformToast, +} from '../common/platformToast'; +import { PlatformToastHost } from '../common/PlatformToastHost'; import { setCreatorViewer } from '../creator/creatorRelationshipState'; import { GameComparePage } from './GameComparePage'; import { GameDetailPage } from './GameDetailPage'; @@ -596,7 +602,15 @@ describe('GameDetailPage', () => { const playButton = await screen.findByRole('button', { name: /立即玩/u }); expect((playButton as HTMLButtonElement).disabled).toBe(true); - expect(screen.getByText('请在电脑上游玩')).toBeTruthy(); + expect( + within(await screen.findByTestId('platform-toast-host')).getByText( + '请在电脑上游玩', + ), + ).toBeTruthy(); + // 行内那份保留(读屏用 `aria-describedby` 关联),但视觉隐藏、不占布局空间。 + expect( + document.getElementById('game-mobile-playback-warning')?.className, + ).toContain('game-device-warning--sr'); }); it('rejects an invalid query id without requesting the API', async () => { @@ -905,9 +919,17 @@ describe('GameDetailPage', () => { expect((playButton as HTMLButtonElement).disabled).toBe(true), ); // 禁用按钮必须带可操作说明,否则用户只看到一个点不动的按钮。 - const reason = screen.getByText('暂时拿不到可玩入口,请刷新页面后重试。'); - expect(reason.id).toBe('game-entry-missing-warning'); - expect(playButton.getAttribute('aria-describedby')).toBe(reason.id); + // 可见提示走 toast(不占操作行布局);行内那份保留 id 供 aria-describedby 关联,但视觉隐藏。 + expect( + within(await screen.findByTestId('platform-toast-host')).getByText( + '暂时拿不到可玩入口,请刷新页面后重试。', + ), + ).toBeTruthy(); + const reason = document.getElementById('game-entry-missing-warning'); + expect(reason?.className).toContain('game-device-warning--sr'); + expect(playButton.getAttribute('aria-describedby')).toBe( + 'game-entry-missing-warning', + ); }); }); @@ -972,7 +994,11 @@ describe('GameDetailPage 作者视角', () => { const playButton = screen.getByRole('button', { name: /立即玩/u }); expect((playButton as HTMLButtonElement).disabled).toBe(true); - expect(screen.getByText('作品尚未公开,暂时没有在线可玩版本')).toBeTruthy(); + expect( + within(await screen.findByTestId('platform-toast-host')).getByText( + '作品尚未公开,暂时没有在线可玩版本', + ), + ).toBeTruthy(); }); it('未公开作品的封面显示审核中,且不请求评价与评分接口', async () => { @@ -4776,6 +4802,59 @@ describe('GameDetailPage 共创模块(文档对齐)', () => { ); }); + it('主操作行不再内联状态文本:行内不换行/不被挤扁,面板改 portal 浮层', async () => { + // 窄屏:唤起直接进入「手动获取作品 ID」的降级面板(无需等失焦超时)。 + setViewportWidth(390); + getGameMock.mockResolvedValueOnce( + createGame({ id: 'game-current', forkAuthorization: 'full' }), + ); + render( + , + ); + + const playButton = await screen.findByRole('button', { name: /立即玩/u }); + const actionsRow = playButton.closest( + '.game-detail-hero__actions', + ) as HTMLElement; + expect(actionsRow).toBeTruthy(); + + // 结构契约:行内**没有**任何内联状态/提示节点(历史类名都不该再出现在行里)。 + expect(actionsRow.querySelector('.game-collection-notice')).toBeNull(); + expect(actionsRow.querySelector('.game-fork-remix-status')).toBeNull(); + // 警告节点仍在(`aria-describedby` 关联要它),但视觉隐藏、不占行内空间。 + for (const warning of actionsRow.querySelectorAll('.game-device-warning')) { + expect(warning.className).toContain('game-device-warning--sr'); + } + + // 触发唤起 → 面板必须是 portal 到 body 的浮层(不参与行布局),且可关。 + fireEvent.click(screen.getByRole('button', { name: '一键开始共创' })); + const panel = await screen.findByRole('dialog', { name: '手动开始共创' }); + expect(panel.parentElement).toBe(document.body); + expect(panel.className).toContain('game-fork-remix-panel--portal'); + expect( + within(panel).getByRole('button', { name: '复制作品 ID' }), + ).toBeTruthy(); + + // CSS 契约:行内子项不收缩、不折行(无论旁边再加什么元素都不会把按钮压成两行)。 + const css = await import('node:fs').then((fs) => + fs.readFileSync( + 'src/components/game-distribution/gameDistribution.css', + 'utf8', + ), + ); + expect(css).toMatch( + /\.game-detail-hero__actions > \* \{[^}]*flex: 0 0 auto;[^}]*white-space: nowrap;/u, + ); + expect(css).toMatch( + /\.game-fork-remix-panel--portal \{[^}]*position: fixed;/u, + ); + }); + it('区块默认以根为作用域,并把当前作品定位进视口', async () => { // jsdom 不做布局:给画布一个真机尺寸,初始适配与定位才有可断言的数值。 const widthSpy = vi @@ -5165,3 +5244,82 @@ describe('GameDetailPage 共创模块(文档对齐)', () => { expect(screen.queryByRole('button', { name: '已收藏' })).toBeNull(); }); }); + +describe('PlatformToastHost(轻提示宿主)', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('portal 到 body、`role=status` + `aria-live=polite`,可手动关闭', async () => { + render(); + expect(screen.queryByTestId('platform-toast-host')).toBeNull(); + + act(() => { + showPlatformToast({ text: '作品 ID 已复制' }); + }); + + const host = await screen.findByTestId('platform-toast-host'); + // portal 到 body:不在渲染它的容器里,因此不参与任何页面布局。 + expect(host.parentElement).toBe(document.body); + const toast = within(host).getByRole('status'); + expect(toast.getAttribute('aria-live')).toBe('polite'); + expect(toast.textContent).toContain('作品 ID 已复制'); + + // 键盘可达的关闭按钮。 + fireEvent.click( + screen.getByRole('button', { name: '关闭提示:作品 ID 已复制' }), + ); + expect(screen.queryByTestId('platform-toast-host')).toBeNull(); + }); + + it('错误级用 `role=alert` + `aria-live=assertive`,且默认不自动消失', async () => { + vi.useFakeTimers(); + render(); + act(() => { + showPlatformToast({ text: '开始共创失败', tone: 'error' }); + }); + const host = screen.getByTestId('platform-toast-host'); + expect(within(host).getByRole('alert').getAttribute('aria-live')).toBe( + 'assertive', + ); + act(() => { + vi.advanceTimersByTime(PLATFORM_TOAST_DEFAULT_DURATION_MS * 4); + }); + // 错误不会自己消失,必须用户关闭。 + expect(screen.getByTestId('platform-toast-host')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: /关闭提示/ })); + expect(screen.queryByTestId('platform-toast-host')).toBeNull(); + }); + + it('普通提示自动消失(默认 5s),多条可堆叠', async () => { + vi.useFakeTimers(); + render(); + act(() => { + showPlatformToast({ text: '第一条' }); + showPlatformToast({ text: '第二条' }); + }); + const host = screen.getByTestId('platform-toast-host'); + expect(within(host).getAllByRole('status')).toHaveLength(2); + act(() => { + vi.advanceTimersByTime(PLATFORM_TOAST_DEFAULT_DURATION_MS + 50); + }); + // 两条都到点 → 宿主整块收起。 + expect(screen.queryByTestId('platform-toast-host')).toBeNull(); + void host; + }); + + it('normalizePlatformToast:默认 tone 与时长规则稳定', () => { + expect(normalizePlatformToast({ text: 'a' })).toEqual({ + text: 'a', + tone: 'info', + durationMs: PLATFORM_TOAST_DEFAULT_DURATION_MS, + }); + expect( + normalizePlatformToast({ text: 'a', tone: 'warning' }).durationMs, + ).toBe(null); + expect( + normalizePlatformToast({ text: 'a', tone: 'error', durationMs: 800 }) + .durationMs, + ).toBe(800); + }); +}); diff --git a/src/components/game-distribution/gameDistribution.css b/src/components/game-distribution/gameDistribution.css index fd5304280..d721174d9 100644 --- a/src/components/game-distribution/gameDistribution.css +++ b/src/components/game-distribution/gameDistribution.css @@ -2864,3 +2864,72 @@ object-fit: cover; object-position: center; } + +/* + * 主操作行「任何状态都不换行、不被挤扁」(2026-10-07 用户口径:提示显示成 toast,别挤占元素空间)。 + * 行内状态文本已经全部改走 toast / portal 浮层;这里再钉一层样式保险: + * 子项不收缩、文字不折行 —— 无论旁边以后再加什么,都不会把「立即玩 / 收藏 / 一键开始共创」压成两行。 + */ +.game-detail-hero__actions > * { + flex: 0 0 auto; + white-space: nowrap; +} +/* 读屏专用:保留 id(`aria-describedby` 还指着它)但不占布局空间。 */ +.game-device-warning--sr { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} +/* + * 「手动开始共创」面板:portal 到 `body` 的浮层(底部居中), + * 不再作为主操作行的子项参与布局 —— 打开面板不会把同行按钮挤走。 + */ +.game-fork-remix-panel--portal { + position: fixed; + left: 50%; + bottom: calc(1rem + env(safe-area-inset-bottom)); + transform: translateX(-50%); + z-index: 110; + max-width: min(34rem, calc(100vw - 2rem)); + box-shadow: 0 18px 48px rgba(48, 32, 22, 0.22); +} +/* 轻提示宿主:固定右下角堆叠,`pointer-events: none` 让空白区域不挡点击。 */ +.platform-toast-host { + position: fixed; + right: 1rem; + bottom: calc(1rem + env(safe-area-inset-bottom)); + z-index: 130; + display: flex; + flex-direction: column; + gap: 0.5rem; + max-width: min(26rem, calc(100vw - 2rem)); + pointer-events: none; +} +.platform-toast-host__item { + pointer-events: auto; + display: inline-flex; + align-items: center; + gap: 0.5rem; + box-shadow: 0 12px 32px rgba(48, 32, 22, 0.18); +} +.platform-toast-host__text { + white-space: normal; + line-height: 1.5; +} +.platform-toast-host__close { + flex: 0 0 auto; + border: 0; + padding: 0; + background: transparent; + color: inherit; + font: inherit; + font-size: 0.78rem; + text-decoration: underline; + cursor: pointer; +}