主操作行不再内联状态文本:状态/警告改走平台 toast(复用 PlatformRuntimeStatusToast + portal 宿主),手动面板改 portal 浮层,行内子项不收缩不换行
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
This commit is contained in:
@@ -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<typeof clearTimeout>[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<ToastItem[]>([]);
|
||||
const timersRef = useRef(new Map<number, ToastTimerHandle>());
|
||||
|
||||
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(
|
||||
<div className="platform-toast-host" data-testid="platform-toast-host">
|
||||
{toasts.map((toast) => (
|
||||
<PlatformRuntimeStatusToast
|
||||
key={toast.id}
|
||||
tone={toast.tone}
|
||||
size="sm"
|
||||
shape="rounded"
|
||||
className="platform-toast-host__item"
|
||||
// 错误级用 alert(立即播报),其余 status(礼貌播报)。
|
||||
role={toast.tone === 'error' ? 'alert' : 'status'}
|
||||
aria-live={toast.tone === 'error' ? 'assertive' : 'polite'}
|
||||
>
|
||||
<span className="platform-toast-host__text">{toast.text}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="platform-toast-host__close"
|
||||
aria-label={`关闭提示:${toast.text}`}
|
||||
onClick={() => dismiss(toast.id)}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</PlatformRuntimeStatusToast>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export default PlatformToastHost;
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* 平台级轻提示(toast)的最小总线。
|
||||
*
|
||||
* 为什么要有它:页面上「状态/错误/未安装提示」这类**一次性反馈**不该内联渲染在操作行里
|
||||
* (会把同行按钮挤成两行、竖排)。这里给一个收口:任何地方 `showPlatformToast(...)`,
|
||||
* 由唯一挂载的 `<PlatformToastHost />` 统一渲染(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<PlatformToastInput>(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<PlatformToastInput>).detail ?? { text: '' });
|
||||
};
|
||||
window.addEventListener(PLATFORM_TOAST_EVENT, listener);
|
||||
return () => window.removeEventListener(PLATFORM_TOAST_EVENT, listener);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="game-detail-page">
|
||||
<button type="button" className="game-back-button" onClick={onBack}>
|
||||
<ArrowLeft /> 返回
|
||||
</button>
|
||||
{/* toast 宿主:portal 到 body、固定定位,因此完全不参与本页布局。 */}
|
||||
<PlatformToastHost />
|
||||
<GameDetailDisplay
|
||||
game={displayGame}
|
||||
authorContent={
|
||||
@@ -1034,11 +1073,6 @@ function GameDetailContent({
|
||||
>
|
||||
{isCollected ? '已收藏' : '收藏'}
|
||||
</PlatformActionButton>
|
||||
{collectionNotice ? (
|
||||
<span className="game-collection-notice" role="status">
|
||||
{collectionNotice}
|
||||
</span>
|
||||
) : null}
|
||||
{/*
|
||||
「一键开始共创」从共创信息卡搬到主操作行(2026-10-07 用户口径):
|
||||
它是这部作品的主操作之一,和「立即玩 / 收藏」并列;共创卡内不再重复出现该按钮。
|
||||
@@ -1085,67 +1119,78 @@ function GameDetailContent({
|
||||
>
|
||||
{isAuthenticated ? '一键开始共创' : '登录后一键开始共创'}
|
||||
</button>
|
||||
{(isAuthenticated || forkAuthorization === 'forbidden') &&
|
||||
forkLaunchNotice ? (
|
||||
<span className="game-fork-remix-status" role="status">
|
||||
{forkLaunchNotice}
|
||||
</span>
|
||||
) : null}
|
||||
{isAuthenticated && isForkPanelOpen ? (
|
||||
<span
|
||||
className="game-fork-remix-panel"
|
||||
id="game-fork-remix-panel"
|
||||
>
|
||||
<span className="game-fork-remix-steps">
|
||||
在陶泥儿客户端里打开「从平台作品开始创作」,粘贴下面的作品
|
||||
ID 或作品链接。
|
||||
</span>
|
||||
<span className="game-fork-remix-value">
|
||||
作品 ID:<code>{game.id}</code>
|
||||
</span>
|
||||
<span className="game-fork-remix-value">
|
||||
作品链接:<code>{workLink}</code>
|
||||
</span>
|
||||
<span className="game-fork-remix-actions">
|
||||
<CopyFeedbackButton
|
||||
state={forkIdCopyState}
|
||||
idleLabel="复制作品 ID"
|
||||
copiedLabel="作品 ID 已复制"
|
||||
failedLabel="复制失败"
|
||||
className="game-fork-remix-copy"
|
||||
onClick={() => void copyForkId(game.id)}
|
||||
/>
|
||||
<CopyFeedbackButton
|
||||
state={forkLinkCopyState}
|
||||
idleLabel="复制作品链接"
|
||||
copiedLabel="作品链接已复制"
|
||||
failedLabel="复制失败"
|
||||
className="game-fork-remix-copy"
|
||||
onClick={() => void copyForkLink(workLink)}
|
||||
/>
|
||||
</span>
|
||||
<span className="game-fork-remix-note">
|
||||
开始共创会把这部作品的整个工程(含素材与文档)复制到你的项目里继续改造;
|
||||
复制后对他人可见,且不可撤销。
|
||||
</span>
|
||||
{forkIdCopyState === 'copied' ? (
|
||||
<span className="game-fork-remix-status" role="status">
|
||||
作品 ID 已复制,可在客户端「从平台作品开始创作」里粘贴。
|
||||
</span>
|
||||
) : null}
|
||||
{forkIdCopyState === 'failed' ? (
|
||||
<span className="game-fork-remix-status" role="status">
|
||||
复制失败:请手动选中作品 ID 文本。
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
) : null}
|
||||
{isAuthenticated && isForkPanelOpen
|
||||
? createPortal(
|
||||
<div
|
||||
className="game-fork-remix-panel game-fork-remix-panel--portal"
|
||||
id="game-fork-remix-panel"
|
||||
role="dialog"
|
||||
aria-label="手动开始共创"
|
||||
>
|
||||
<span className="game-fork-remix-steps">
|
||||
在陶泥儿客户端里打开「从平台作品开始创作」,粘贴下面的作品
|
||||
ID 或作品链接。
|
||||
</span>
|
||||
<span className="game-fork-remix-value">
|
||||
作品 ID:<code>{game.id}</code>
|
||||
</span>
|
||||
<span className="game-fork-remix-value">
|
||||
作品链接:<code>{workLink}</code>
|
||||
</span>
|
||||
<span className="game-fork-remix-actions">
|
||||
<CopyFeedbackButton
|
||||
state={forkIdCopyState}
|
||||
idleLabel="复制作品 ID"
|
||||
copiedLabel="作品 ID 已复制"
|
||||
failedLabel="复制失败"
|
||||
className="game-fork-remix-copy"
|
||||
onClick={() => void copyForkId(game.id)}
|
||||
/>
|
||||
<CopyFeedbackButton
|
||||
state={forkLinkCopyState}
|
||||
idleLabel="复制作品链接"
|
||||
copiedLabel="作品链接已复制"
|
||||
failedLabel="复制失败"
|
||||
className="game-fork-remix-copy"
|
||||
onClick={() => void copyForkLink(workLink)}
|
||||
/>
|
||||
</span>
|
||||
<span className="game-fork-remix-note">
|
||||
开始共创会把这部作品的整个工程(含素材与文档)复制到你的项目里继续改造;
|
||||
复制后对他人可见,且不可撤销。
|
||||
</span>
|
||||
{forkIdCopyState === 'copied' ? (
|
||||
<span
|
||||
className="game-fork-remix-status"
|
||||
role="status"
|
||||
>
|
||||
作品 ID
|
||||
已复制,可在客户端「从平台作品开始创作」里粘贴。
|
||||
</span>
|
||||
) : null}
|
||||
{forkIdCopyState === 'failed' ? (
|
||||
<span
|
||||
className="game-fork-remix-status"
|
||||
role="status"
|
||||
>
|
||||
复制失败:请手动选中作品 ID 文本。
|
||||
</span>
|
||||
) : null}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</>
|
||||
) : null}
|
||||
{/*
|
||||
这些警告过去是行内 span,会把同行按钮挤成两行。现在:**视觉上隐藏但保留 id**
|
||||
(`aria-describedby` 还指着 `#game-mobile-playback-warning` / `#game-entry-missing-warning`,
|
||||
读屏仍能关联到),可见提示改由 toast 承担。
|
||||
*/}
|
||||
{!isPaidLocked && mobilePlaybackBlocked ? (
|
||||
<span
|
||||
id="game-mobile-playback-warning"
|
||||
className="game-device-warning"
|
||||
className="game-device-warning game-device-warning--sr"
|
||||
role="status"
|
||||
>
|
||||
请在电脑上游玩
|
||||
@@ -1154,14 +1199,17 @@ function GameDetailContent({
|
||||
{isEntryMissing && !mobilePlaybackBlocked ? (
|
||||
<span
|
||||
id="game-entry-missing-warning"
|
||||
className="game-device-warning"
|
||||
className="game-device-warning game-device-warning--sr"
|
||||
role="status"
|
||||
>
|
||||
暂时拿不到可玩入口,请刷新页面后重试。
|
||||
</span>
|
||||
) : null}
|
||||
{ownerView && !publishedVersion ? (
|
||||
<span className="game-device-warning" role="status">
|
||||
<span
|
||||
className="game-device-warning game-device-warning--sr"
|
||||
role="status"
|
||||
>
|
||||
作品尚未公开,暂时没有在线可玩版本
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
@@ -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(
|
||||
<GameDetailPage
|
||||
gameId="game-current"
|
||||
onBack={vi.fn()}
|
||||
onPlay={vi.fn()}
|
||||
onOpenGameDetail={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(<PlatformToastHost />);
|
||||
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(<PlatformToastHost />);
|
||||
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(<PlatformToastHost />);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user