优化 AGC 工作台顶栏布局:一行到底、版本入口钉右分级、播放并入运行页签

- 顶栏去掉两侧 flex-wrap、工具条改 overflow: hidden,空间不足按 data-layout 三档降级而不是换行
- 新增 workbenchToolbarModel(档位顺序与 1px 判据)与 useWorkbenchToolbarLayout(ResizeObserver 管宽度、MutationObserver 管内容,实测内容宽度写档位)
- 版本入口 margin-left: auto 钉在动作区最右,标识改「版本 N(原因 · 时间)」,窄档只留「版本 N」(序号复用资源画布版本卡编号口径)
- 新增 WorkbenchMoreActionsMenu:「打开项目目录 / 资源面板 / 整理画布」在 collapsed-actions 档收进「更多」下拉
- 删除独立「播放」按钮,运行页签前加 ▶ 图标,点页签即切视图并载入游戏资源
- 运行画面右下角新增「刷新运行画面」,LocalGamePreviewFrame 新增 reloadNonce 换 iframe 元素身份重新载入页面
- 用例:新增 workbenchToolbarLayout / runPreviewRefresh;改写 appSurface 顶栏与播放/刷新断言、版本标识断言与顶栏样式断言
This commit is contained in:
2026-10-04 13:40:25 +08:00
parent 81cfbae207
commit b8849b8c01
13 changed files with 819 additions and 98 deletions
@@ -272,6 +272,7 @@ export function LocalGamePreviewFrame({
title,
className,
inspectMode = false,
reloadNonce = 0,
onInspectSelection,
onInspectExit,
}: {
@@ -279,6 +280,14 @@ export function LocalGamePreviewFrame({
title: string;
className?: string;
inspectMode?: boolean;
/**
* 换一个值就把 iframe 重新挂载一次=重新载入运行页面(使用方的「刷新运行画面」)。
*
* 游戏预览不是 vite dev 的实时刷新:改完代码要看到新一版只有重载页面这一条路。`src` 不变,
* 靠 React 的 `key` 换元素身份——跨域 iframe 里 `contentWindow.location.reload()` 会被
* 浏览器挡掉(运行页面在另一个端口上)。
*/
reloadNonce?: number;
onInspectSelection?: (selection: LocalGamePreviewInspectSelection) => void;
onInspectExit?: () => void;
}) {
@@ -425,6 +434,7 @@ export function LocalGamePreviewFrame({
<div ref={containerRef} className="local-game-preview-frame">
<iframe
ref={iframeRef}
key={reloadNonce}
className={className}
title={title}
src={embeddedUrl}
@@ -3,7 +3,11 @@ import { createPortal } from 'react-dom';
import type { GameIterationVersion } from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { useImageCanvasFloatingOptionDismiss } from '../../../../../src/components/image-editor/useImageCanvasFloatingOptionDismiss';
import { formatIterationVersionLabel } from './resourceCanvasVersionBindingModel';
import {
formatIterationVersionDetail,
formatIterationVersionLabel,
formatIterationVersionTitle,
} from './resourceCanvasVersionBindingModel';
type GameRunVersionPickerProps = {
versions: readonly GameIterationVersion[];
@@ -64,6 +68,12 @@ export function GameRunVersionPicker({
if (!currentVersion) {
return null;
}
const currentVersionIndex = Math.max(
0,
versions.findIndex(
(version) => version.versionId === currentVersion.versionId,
),
);
return (
<div ref={rootRef} className="game-run-version-picker">
@@ -72,16 +82,25 @@ export function GameRunVersionPicker({
className="game-run-version-trigger"
aria-haspopup="listbox"
aria-expanded={open}
aria-label={`当前版本:${formatIterationVersionLabel(currentVersion)}`}
aria-label={`当前版本:${formatIterationVersionLabel(
currentVersion,
currentVersionIndex,
)}`}
onClick={() => setOpen((current) => !current)}
>
{/*
版本名可能很长(`初始版本 · 2026/9/19 02:10:03`)。按钮是 flex 容器,直接放文本节点
时 `text-overflow: ellipsis` 不生效(匿名 flex item 不参与父级省略),所以套一层
span 由它省略(见样式里的 `.game-run-version-trigger-label`)。
按钮是 flex 容器,直接放文本节点时 `text-overflow: ellipsis` 不生效(匿名 flex item
不参与父级省略),所以序号与括号里那一截各套一层 span(见样式里的
`.game-run-version-trigger-label`)。分两层还让「空间不够只留 `版本 1`」成为一次
`display: none`——顶栏窄到放不下完整标识时收掉的是括号那一层,不是整枚入口。
*/}
<span className="game-run-version-trigger-label">
{formatIterationVersionLabel(currentVersion)}
<span className="game-run-version-trigger-title">
{formatIterationVersionTitle(currentVersionIndex)}
</span>
<span className="game-run-version-trigger-detail">
({formatIterationVersionDetail(currentVersion)})
</span>
</span>
</button>
{open
@@ -106,7 +125,7 @@ export function GameRunVersionPicker({
: '0',
}}
>
{versions.map((version) => {
{versions.map((version, index) => {
const selected = version.versionId === currentVersion.versionId;
return (
<button
@@ -114,7 +133,7 @@ export function GameRunVersionPicker({
type="button"
role="option"
aria-selected={selected}
aria-label={`切换到${formatIterationVersionLabel(version)}`}
aria-label={`切换到${formatIterationVersionLabel(version, index)}`}
className={selected ? 'is-selected' : undefined}
onClick={() => {
setOpen(false);
@@ -122,7 +141,7 @@ export function GameRunVersionPicker({
onSelectVersion(version.versionId);
}}
>
<span>{formatIterationVersionLabel(version)}</span>
<span>{formatIterationVersionLabel(version, index)}</span>
<small>{version.versionId}</small>
</button>
);
@@ -20,7 +20,20 @@ export const ITERATION_VERSION_REASON_LABELS: Record<
};
/**
* 版本在 UI 里的可读标识:创建原因 + 创建时间。
* 版本序号:`manifest.versions[]` 的下标 + 1。
*
* 与资源画布「项目版本」卡片的 `版本 N`(`resourceProjectionModel` 里同一份下标口径)是同一个
* 编号——两处都按 manifest 里版本的落盘顺序数,不要在别处另起一套编号。
*/
export function formatIterationVersionTitle(ordinalIndex: number) {
return `版本 ${ordinalIndex + 1}`;
}
/**
* 版本标识里「括号里那一截」:创建原因 + 创建时间。
*
* 顶栏宽度不够时整截收起、只留 `版本 N`(见 `workbenchToolbarModel` 的档位),所以它是独立
* 一段文本,而不是拼在序号里的后缀。
*
* `GameIterationVersion.createdAt` 的单位是 **Unix 秒**(写入侧为
* `project/manifest.rs` 的 `created_at: unix_timestamp()`,而 `unix_timestamp()` 取
@@ -28,7 +41,7 @@ export const ITERATION_VERSION_REASON_LABELS: Record<
* 渲染成 1970 年(例如 1788075047 会显示成 1970/1/22)。
* 同一约定在 `ResourceAssetDeleteDialog` 与待确认资源编辑的创建时间标签里一致使用。
*/
export function formatIterationVersionLabel(
export function formatIterationVersionDetail(
version: Pick<GameIterationVersion, 'createdReason' | 'createdAt'>,
) {
const reason =
@@ -41,6 +54,21 @@ export function formatIterationVersionLabel(
return `${reason} · ${time}`;
}
/**
* 版本在 UI 里的完整标识:`版本 1(智能体修订 · 2026/10/3 14:21:05)`。
*
* 视觉上空间不够时可以只显示 `formatIterationVersionTitle`,但可访问名、菜单项与排障文案一律
* 用这一条完整的——收起来的是排版,不是信息。
*/
export function formatIterationVersionLabel(
version: Pick<GameIterationVersion, 'createdReason' | 'createdAt'>,
ordinalIndex: number,
) {
return `${formatIterationVersionTitle(ordinalIndex)}(${formatIterationVersionDetail(
version,
)})`;
}
/**
* C5 / C7 卡片边框:一个资源是否正在被「当前版本」使用。
*
+139 -42
View File
@@ -6212,14 +6212,19 @@ iframe.preview-frame {
min-height: 48px;
padding: 8px 12px;
background: transparent;
/* 一行到底:空间不够不再换行,而是按 `data-layout` 降级(顺序与判据见
* `view/project-development/workbenchToolbarModel.ts`)。`overflow: hidden` 有两个用处:
* 溢出的那一截不会画到对话列上;`scrollWidth` 仍然把溢出的内容算进内容宽度,档位判定靠它。 */
flex-wrap: nowrap;
overflow: hidden;
}
/* 左侧控件组:「资源管理 / 运行」分段 + 紧贴其后的「播放」。
/* 左侧控件组:只剩「资源管理 / 运行」分段(播放并进「运行」页签,见 index.tsx)。
* 整组在工具条里左对齐,取代播放按钮原先的居中悬浮(absolute + translateX(-50%))。 */
.game-workbench-view-tabs {
flex: 0 0 auto;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 7px;
}
@@ -6227,10 +6232,16 @@ iframe.preview-frame {
.game-workbench-view-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 7px;
}
/* 动作区:宽度够时按内容排布并贴右,不够时由 `min-width: 0` 让出宽度给降级判定
* (不设它的话 flex 子项不会缩到内容宽度以下,溢出永远检测不到)。 */
.game-workbench-view-actions {
flex: 0 1 auto;
min-width: 0;
}
.game-workbench-tabs {
padding: 3px;
border: 1px solid var(--platform-subpanel-border);
@@ -6239,6 +6250,10 @@ iframe.preview-frame {
}
.game-resource-sort-tabs {
/* 与版本入口同一课:`overflow: hidden` 的 flex 子项可以缩到 0,把溢出「吃掉」,
* 顶栏的档位判定就永远量不到真实溢出(实测窄宽度下「依赖 / 类型」被压成 0 宽)。
* 这一组不参与收缩。 */
flex: 0 0 auto;
flex-wrap: nowrap;
gap: 0;
padding: 0;
@@ -6267,7 +6282,8 @@ iframe.preview-frame {
border-color: transparent;
}
/* 左侧控件组里的按钮(目前只有「播放」)与分段控件、右侧动作区共用同一套基础外观。 */
/* 模式分段(含并进来的「运行 ▶」)、动作区按钮(含「更多」里的条目共用同一套基础外观的入口
* 那一枚)共用同一套基础外观。 */
.game-workbench-tabs button,
.game-workbench-view-tabs button,
.game-workbench-view-actions button {
@@ -6320,21 +6336,84 @@ iframe.preview-frame {
color: var(--platform-button-secondary-text);
}
/* 「播放」与「资源管理 / 运行」同组,外观仍按主按钮走;禁用时退回次级按钮。 */
.game-workbench-view-tabs .game-workbench-play-button {
position: static;
border-color: var(--platform-button-primary-border);
background: var(--platform-button-primary-fill);
color: var(--platform-button-primary-text);
transform: none;
/*
* 顶栏降级档位:`data-layout` 由 `useWorkbenchToolbarLayout` 按实测内容宽度写在工具条上,
* 取 `full` / `compact-version` / `collapsed-actions`,顺序与判据在 `workbenchToolbarModel`。
* 这里只声明每一档「藏什么」——藏起来的是排版,不是信息:可访问名与菜单项都保留完整口径。
*/
.game-workbench-toolbar[data-layout='compact-version']
.game-run-version-trigger-detail {
display: none;
}
.game-workbench-view-tabs .game-workbench-play-button:disabled {
border-color: var(--platform-surface-border);
background: var(--platform-button-secondary-fill);
color: var(--platform-text-muted);
/* 「更多」那一档同样只留 `版本 N`:它比 `compact-version` 更窄,没有理由把括号段放回来。 */
.game-workbench-toolbar[data-layout='collapsed-actions']
.game-run-version-trigger-detail {
display: none;
}
/* `打开项目目录 / 资源面板 / 整理画布`:收进「更多」的那一档才藏,且与菜单互为开关——
* 默认(未量到宽度、'full')保持行内形态,与脚本关闭的环境一致。 */
.game-workbench-toolbar[data-layout='collapsed-actions']
.game-workbench-foldable-action {
display: none;
}
/* 「更多」下拉:只在动作收进菜单的那一档出现。 */
.game-workbench-more-actions {
display: none;
}
.game-workbench-toolbar[data-layout='collapsed-actions']
.game-workbench-more-actions {
display: inline-flex;
align-items: center;
}
/* 最后一档仍放不下(视口远小于 1280 合同宽度)时改成右对齐:溢出的那一截甩到左边被裁掉,
* 而不是把钉在最右的版本入口一起裁没。放得下时这条与 `margin-left: auto` 结果相同。 */
.game-workbench-toolbar[data-layout='collapsed-actions']
.game-workbench-view-actions {
justify-content: flex-end;
}
.game-workbench-more-menu {
display: grid;
gap: 4px;
min-width: 11rem;
padding: 6px;
border: 1px solid var(--platform-surface-border);
border-radius: 10px;
background: var(--platform-subpanel-fill);
box-shadow: 0 16px 34px rgb(75 47 32 / 18%);
z-index: 80;
}
.game-workbench-more-menu button {
display: flex;
align-items: center;
justify-content: flex-start;
min-height: 34px;
gap: 8px;
padding: 0 9px;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--platform-text-strong);
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.game-workbench-more-menu button:hover,
.game-workbench-more-menu button:focus-visible {
background: var(--platform-nav-item-hover-fill);
outline: 0;
}
.game-workbench-more-menu button:disabled {
cursor: not-allowed;
opacity: 0.6;
opacity: 0.45;
}
.game-workbench-view-actions .game-workbench-inspect-button.is-active {
@@ -8679,18 +8758,46 @@ iframe.preview-frame {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
/* 固定在最右:它是动作区的最后一枚,且行内空间富余时贴右缘(`margin-left: auto` 吃掉
* 左侧所有剩余空间)。空间不够时它自己先收成 `版本 N`(`data-layout` 档位),
* 而不是被换行甩到第二行。 */
margin-left: auto;
}
.game-run-version-trigger {
max-width: min(18rem, 60vw);
/* 默认**不参与收缩**(`max-content`):收缩 + 省略号会把溢出「吃掉」,让顶栏的档位判定
* (`workbenchToolbarModel`)永远量不到真实溢出——窄到该降档时它却原地把版本名截断。
* 只有降到最后一档、动作已经全收进「更多」时才允许它省略兜底(见下面那条选择器)。 */
min-width: max-content;
}
.game-workbench-toolbar[data-layout='collapsed-actions']
.game-run-version-trigger {
min-width: 0;
}
/*
* 版本名单独一层才省得掉:按钮是 flex 容器,文本直接挂在按钮上时 `text-overflow`
* 落在匿名 flex item 上、不生效(见 `GameRunVersionPicker` 里的注释)。
* 层里再分「序号」与「括号那一截」两层:窄宽度收掉的是后者(`display: none`),
* 序号永远留着。
*/
.game-run-version-trigger-label {
display: inline-flex;
align-items: baseline;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.game-run-version-trigger-title {
flex: 0 0 auto;
}
.game-run-version-trigger-detail {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
@@ -8757,13 +8864,13 @@ iframe.preview-frame {
}
/*
* 画面右下角的「全屏预览」。压在游戏画面上,所以用深色半透明底 + 白图标:任何游戏配色下都
* 看得清,也不在画面中间抢位置。全屏那一格还是它自己(`:fullscreen` 铺满屏幕),所以这枚按钮
* 在全屏里照旧可用,用户点它就能退出来。
* 画面右下角两枚浮起控件:「刷新运行画面」(左)与「全屏预览」(右)。压在游戏画面上,所以用
* 深色半透明底 + 白图标:任何游戏配色下都看得清,也不在画面中间抢位置。全屏那一格还是它自己
* (`:fullscreen` 铺满屏幕),所以两枚按钮在全屏里照旧可用——刷新照旧刷画面,点全屏那枚就退出。
*/
.game-run-preview-fullscreen {
.game-run-preview-fullscreen,
.game-run-preview-refresh {
position: absolute;
right: 10px;
bottom: 10px;
z-index: 2;
display: inline-flex;
@@ -8779,8 +8886,18 @@ iframe.preview-frame {
backdrop-filter: blur(6px);
}
.game-run-preview-refresh {
right: 50px;
}
.game-run-preview-fullscreen {
right: 10px;
}
.game-run-preview-fullscreen:hover,
.game-run-preview-fullscreen:focus-visible {
.game-run-preview-fullscreen:focus-visible,
.game-run-preview-refresh:hover,
.game-run-preview-refresh:focus-visible {
background: rgb(12 14 20 / 84%);
outline: 0;
}
@@ -10209,15 +10326,6 @@ iframe.preview-frame {
.game-workbench-layout {
grid-template-columns: minmax(0, 1fr) minmax(280px, 0.42fr);
}
.game-workbench-toolbar {
align-items: stretch;
flex-direction: column;
}
.game-workbench-view-actions {
justify-content: flex-end;
}
}
@media (max-width: 760px) {
@@ -10242,17 +10350,6 @@ iframe.preview-frame {
border-left: 0;
}
.game-workbench-toolbar,
.game-workbench-view-actions {
align-items: stretch;
flex-direction: column;
}
.game-workbench-tabs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.game-resource-section {
min-width: 460px;
}
@@ -0,0 +1,111 @@
import { MoreHorizontal } from 'lucide-react';
import { type ReactNode, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useImageCanvasFloatingOptionDismiss } from '../../../../../src/components/image-editor/useImageCanvasFloatingOptionDismiss';
export type WorkbenchMoreAction = {
key: string;
label: string;
/** 与内联形态同一枚图标:菜单里逐条重画,不新造一套语义。 */
icon: ReactNode;
disabled?: boolean;
onSelect: () => void;
};
/**
* 顶栏动作的「更多」下拉:窄宽度下 `打开项目目录 / 资源面板 / 整理画布` 的收容处。
*
* 只在顶栏落到 `collapsed-actions` 档时由 CSS 显示(见 `styles.css` 里的 `[data-layout]`
* 选择器),档位判据在 `workbenchToolbarModel`。菜单 portal 到 `document.body` 并每次渲染
* 按触发钮的实时 rect 定位:顶栏会随对话列收放横移,菜单挂在按钮旁边而不是挂在某个坐标上。
*/
export function WorkbenchMoreActionsMenu({
actions,
}: {
readonly actions: readonly WorkbenchMoreAction[];
}) {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
// 定位每次渲染都按触发钮实时 rect 重算;滚动 / 缩放后必须重算,否则 fixed 定位的菜单会漂在
// 旧位置上。用一个 tick 驱动重算,「打开当帧就有菜单」的时序不变。
const [, setMenuAnchorTick] = useState(0);
useEffect(() => {
if (!open) return undefined;
const refreshMenuAnchor = () => setMenuAnchorTick((tick) => tick + 1);
window.addEventListener('scroll', refreshMenuAnchor, true);
window.addEventListener('resize', refreshMenuAnchor);
return () => {
window.removeEventListener('scroll', refreshMenuAnchor, true);
window.removeEventListener('resize', refreshMenuAnchor);
};
}, [open]);
// 菜单在 `rootRef` 子树外的 portal 里,必须一起登记成「点这里不算外部」,否则落在菜单项上的
// 那一下会被当成点外部,菜单在选项收到 click 之前就被收掉。
useImageCanvasFloatingOptionDismiss({
isOpen: open,
boundaryRefs: [rootRef, menuRef],
onDismiss: () => setOpen(false),
});
if (actions.length === 0) {
return null;
}
return (
<div ref={rootRef} className="game-workbench-more-actions">
<button
type="button"
className="game-workbench-more-trigger"
aria-haspopup="menu"
aria-expanded={open}
aria-label="更多项目操作"
onClick={() => setOpen((current) => !current)}
>
<MoreHorizontal size={15} aria-hidden="true" />
更多
</button>
{open
? createPortal(
<div
ref={menuRef}
className="game-workbench-more-menu"
role="menu"
aria-label="更多项目操作"
style={{
position: 'fixed',
top: rootRef.current
? `${rootRef.current.getBoundingClientRect().bottom + 6}px`
: '0',
left: rootRef.current
? `${Math.max(
8,
rootRef.current.getBoundingClientRect().left,
)}px`
: '0',
}}
>
{actions.map((action) => (
<button
key={action.key}
type="button"
role="menuitem"
disabled={action.disabled}
onClick={() => {
setOpen(false);
action.onSelect();
}}
>
{action.icon}
{action.label}
</button>
))}
</div>,
document.body,
)
: null}
</div>
);
}
@@ -46,6 +46,7 @@ import {
Redo2,
Replace,
RotateCcw,
RotateCw,
Search,
Shapes,
SlidersHorizontal,
@@ -443,6 +444,11 @@ import {
import { useProjectResourceCardPreviews } from './useProjectResourceCardPreviews';
import { useProjectResourceSectionHeights } from './useProjectResourceSectionHeights';
import { useResourceAssetDeleteFlow } from './useResourceAssetDeleteFlow';
import { useWorkbenchToolbarLayout } from './useWorkbenchToolbarLayout';
import {
type WorkbenchMoreAction,
WorkbenchMoreActionsMenu,
} from './WorkbenchMoreActionsMenu';
export type {
ProjectAgentResultSummary,
@@ -1831,6 +1837,12 @@ export default function ProjectDevelopmentView({
// 运行画面的「全屏预览」:全屏的是画面那一格(`.game-run-preview`),不是整块工作台,
// 所以按钮与 ref 都归运行表现层自己持有。
const runPreviewFullscreen = useElementFullscreen<HTMLDivElement>();
// 「刷新运行画面」的载入次数:递增一次=换 iframe 的元素身份=重新载入运行页面。
const [runPreviewReloadNonce, setRunPreviewReloadNonce] = useState(0);
// 顶栏按实测宽度自己在三档之间退(见 `workbenchToolbarModel`):档位只写在 DOM 属性上,
// 不参与 React 状态。
const workbenchToolbarRef = useRef<HTMLDivElement>(null);
useWorkbenchToolbarLayout(workbenchToolbarRef);
const [resourceBookState, dispatchResourceBook] = useReducer(
resourceBookReducer,
initialResourceBookState,
@@ -2783,6 +2795,17 @@ export default function ProjectDevelopmentView({
});
}
}, [embeddedPreviewUrl, onNotice]);
/**
* 「刷新运行画面」:先把预览确认成活的(`onPlay` 命中活体预览只切视图、不重启服务),再换一次
* iframe 的元素身份把页面重新载入。
*
* 游戏预览不是 vite dev 的实时刷新,改完代码只有重载页面才看得到新一版;以前只能靠切到资源
* 管理再切回来,这一枚把同一个动作放回运行画面本身。
*/
const refreshRunPreview = useCallback(() => {
onPlay?.();
setRunPreviewReloadNonce((current) => current + 1);
}, [onPlay]);
const runAvailable =
embeddedPreviewUrl !== null ||
manifest.tasks.some(
@@ -7341,6 +7364,14 @@ export default function ProjectDevelopmentView({
uiEditorRoute,
]);
/**
* 切到运行视图**并载入游戏**:原来的「播放」按钮合并进「运行」页签,两者本来就是同一件事
* (切视图 + 启动本地预览)。页签因此在不可运行时不置灰、只给提示(见 `run-unavailable-hint`),
* 点了不切视图也不发播放请求。
*
* 自动切运行那两条路径(会话内已确认的预览、播放请求)不走这里,它们只是**呈现**运行结果,
* 不该再发起一次播放。
*/
function showRunView() {
if (!runAvailable || uiEditorRoute) {
return;
@@ -7354,6 +7385,7 @@ export default function ProjectDevelopmentView({
captureResourceSectionScrollPositions();
stopActiveCardMedia();
setMode('run');
onPlay?.();
}
const showRunUnavailableHint = !runAvailable && !uiEditorRoute;
@@ -9582,6 +9614,54 @@ export default function ProjectDevelopmentView({
],
);
/**
* 「整理画布」的可用判据:布局还没读出来,或当前范围里一个素材都没有时不放行。行内按钮与
* 「更多」里的同一条动作共用这一份,别在两处各写一遍。
*/
const resourceCanvasOrganizeDisabled =
!resourceLayoutReady ||
(resourceOrganizeSections !== null &&
resourceOrganizeSections.length === 0);
/**
* 顶栏动作区里可被「更多」下拉收容的三枚动作,与行内形态是**同一批动作**:出现条件与禁用
* 口径必须一起改(行内按钮见 `game-workbench-foldable-action`)。
*/
const workbenchMoreActionCandidates: Array<WorkbenchMoreAction | null> = [
onRevealProjectDirectory
? {
key: 'reveal-project-directory',
label: '打开项目目录',
icon: <FolderOpen size={15} aria-hidden="true" />,
onSelect: () => void onRevealProjectDirectory(),
}
: null,
mode === 'resources' && !uiEditorRoute
? {
key: 'resource-panel',
label: '资源面板',
icon: <FolderTree size={15} aria-hidden="true" />,
disabled: canvasResources.length === 0,
onSelect: () => {
setResourcePanelNotice('');
setResourcePanelOpen((current) => !current);
},
}
: null,
mode === 'resources' && !uiEditorRoute
? {
key: 'organize-canvas',
label: '整理画布',
icon: <LayoutGrid size={15} aria-hidden="true" />,
disabled: resourceCanvasOrganizeDisabled,
onSelect: organizeResourceCanvasLayout,
}
: null,
];
const workbenchMoreActions = workbenchMoreActionCandidates.filter(
(action): action is WorkbenchMoreAction => action !== null,
);
if (planningStartMode) {
return (
<section
@@ -9638,11 +9718,11 @@ export default function ProjectDevelopmentView({
: undefined
}
>
<div className="game-workbench-toolbar">
<div className="game-workbench-toolbar" ref={workbenchToolbarRef}>
{/*
「资源管理 / 运行」与「播放」是同一组左侧控件:播放紧贴模式切换之后、整组左对齐,
不再居中悬浮。**两个页签下都常驻**——运行视图空态与预览失败态都要靠它重跑
(空态文案就是「点击顶部播放按钮后将在这里直接运行游戏」)。
左侧只剩模式切换:「资源管理 / 运行」。**运行页签本身就是播放入口**(前面带 ▶):
切过去即按「播放」载入游戏资源,不再单开一枚「播放」按钮——两枚入口说的是同一件事,
并排只会把右侧动作区挤到第二行。运行视图空态与预览失败态都靠再点一次这枚页签重跑。
*/}
<div className="game-workbench-view-tabs">
<div className="game-workbench-tabs" role="tablist">
@@ -9670,30 +9750,22 @@ export default function ProjectDevelopmentView({
disabled={uiEditorRoute !== null}
onClick={showRunView}
>
<Play size={14} aria-hidden="true" />
运行
</button>
</div>
<button
type="button"
className="game-workbench-play-button"
disabled={!runAvailable || !onPlay || uiEditorRoute !== null}
onClick={() => {
showRunView();
onPlay?.();
}}
title={
runAvailable ? '运行当前游戏' : '首个可运行原型完成后才能播放'
}
>
<Play size={15} aria-hidden="true" />
播放
</button>
</div>
<div className="game-workbench-view-actions">
{/*
窄宽度收容:`打开项目目录 / 资源面板 / 整理画布` 在顶栏掉到 `collapsed-actions`
档时改从这里进(宽的时候仍是行内按钮)。档位顺序与判据在
`workbenchToolbarModel`;两处渲染的是同一批动作,出现条件与禁用口径要一起改。
*/}
<WorkbenchMoreActionsMenu actions={workbenchMoreActions} />
{onRevealProjectDirectory ? (
<button
type="button"
className="game-workbench-resource-panel-button"
className="game-workbench-resource-panel-button game-workbench-foldable-action"
aria-label="打开项目目录"
onClick={() => void onRevealProjectDirectory()}
>
@@ -9739,7 +9811,7 @@ export default function ProjectDevelopmentView({
<>
<button
type="button"
className="game-workbench-resource-panel-button"
className="game-workbench-resource-panel-button game-workbench-foldable-action"
aria-expanded={resourcePanelOpen}
disabled={canvasResources.length === 0}
onClick={() => {
@@ -9764,12 +9836,9 @@ export default function ProjectDevelopmentView({
*/}
<button
type="button"
className="game-workbench-foldable-action"
aria-label="整理画布"
disabled={
!resourceLayoutReady ||
(resourceOrganizeSections !== null &&
resourceOrganizeSections.length === 0)
}
disabled={resourceCanvasOrganizeDisabled}
aria-busy={resourceLayoutSaving}
onClick={organizeResourceCanvasLayout}
>
@@ -11023,6 +11092,7 @@ export default function ProjectDevelopmentView({
title={`${projectName} 游戏运行画面`}
preview={preview}
inspectMode={runtimeInspectMode}
reloadNonce={runPreviewReloadNonce}
onInspectSelection={handleRuntimeInspectSelection}
onInspectExit={() => setRuntimeInspectMode(false)}
/>
@@ -11030,9 +11100,25 @@ export default function ProjectDevelopmentView({
<div className="game-run-preview-empty">
<Sparkles size={28} aria-hidden="true" />
<strong>客户端运行画面尚未载入</strong>
<span>点击顶部播放按钮后将在这里直接运行游戏</span>
<span>点击顶部「运行」页签后将在这里直接运行游戏</span>
</div>
)}
{/*
刷新运行画面:贴在画面右下角、全屏那一枚的左边。游戏预览不是 vite dev 的实时
刷新,改完代码要看到新一版只有重载页面这一条路(以前得切到资源管理再切回来)。
没有活预览时不渲染——那时该走的是顶栏「运行」页签。
*/}
{embeddedPreviewUrl ? (
<button
type="button"
className="game-run-preview-refresh"
aria-label="刷新运行画面"
title="重新载入运行画面(游戏不是实时刷新,改完代码用这一枚重载)"
onClick={refreshRunPreview}
>
<RotateCw size={16} aria-hidden="true" />
</button>
) : null}
{/*
全屏预览:贴在画面右下角。**只有画面这一格进全屏**——顶部页签、右侧对话与
底部信息栏都不跟着放大,符合「预览画面」而不是「全屏应用」。没有活预览时不渲染,
@@ -0,0 +1,87 @@
import { type RefObject, useLayoutEffect } from 'react';
import {
resolveWorkbenchToolbarLayout,
type WorkbenchToolbarLayout,
} from './workbenchToolbarModel';
/**
* 顶栏当前档位挂在 DOM 属性上(`data-layout`),并**只由这个 hook 写**。
*
* 不让 React 渲染这个属性:档位是量出来的,不是 state 推出来的。这条链路上每次量宽都要先把
* 候选档位真的写到 DOM(否则量到的还是当前档位的宽度),React 受控属性会被这类「量一次写一次」
* 的中间值污染,且每次窗口拖动都要触发一轮工作台整树重渲染——顶栏只有这一处样式差异,不值得。
* React 不声明它就不会覆盖它,见 `styles.css` 里的 `[data-layout=...]` 选择器。
*/
export const WORKBENCH_TOOLBAR_LAYOUT_ATTRIBUTE = 'data-layout';
/**
* 把候选档位写到 DOM 上量一次内容宽度。
*
* 顶栏是 `flex-wrap: nowrap` + `overflow: hidden`:放不下时溢出的那部分仍计入 `scrollWidth`,
* 所以「量得的内容宽度 > clientWidth」就是这一档放不下。
*/
function measureToolbarContentWidth(
element: HTMLElement,
candidate: WorkbenchToolbarLayout,
): number {
element.dataset.layout = candidate;
return element.scrollWidth;
}
/** 重新爬一次档位阶梯并落定;返回落定后的档位(测试与排障用)。 */
export function applyWorkbenchToolbarLayout(
element: HTMLElement,
): WorkbenchToolbarLayout {
const layout = resolveWorkbenchToolbarLayout(
element.clientWidth,
(candidate) => measureToolbarContentWidth(element, candidate),
);
element.dataset.layout = layout;
return layout;
}
/**
* 让顶栏按实际可用宽度自己选档位(`workbenchToolbarModel` 给了顺序与判据)。
*
* 两个信号都要:
* - `ResizeObserver`:可用宽度变了(窗口拖动、对话列收放);
* - `MutationObserver`:宽度不变但**内容**变了(切运行页、出现「恢复草稿」、面板开合)。
* 这种变化不改顶栏宽度,只看尺寸会停在旧档位上——动作变少了却还收在「更多」里。
*
* 观测本身不引起递归:写的是被观测节点自己的属性,而属性不在观测范围内(`childList` /
* `characterData` / `subtree` 都不含根节点自身属性),且档位变化不改顶栏尺寸。
* 没有 `ResizeObserver` 的环境(jsdom)只按内容变化重算,量到 0 宽就停在默认的 `full`——
* 与关闭脚本前的默认形态一致。
*/
export function useWorkbenchToolbarLayout(
toolbarRef: RefObject<HTMLElement | null>,
): void {
useLayoutEffect(() => {
const element = toolbarRef.current;
if (!element) {
return undefined;
}
applyWorkbenchToolbarLayout(element);
const resizeObserver =
typeof ResizeObserver === 'function'
? new ResizeObserver(() => applyWorkbenchToolbarLayout(element))
: null;
resizeObserver?.observe(element);
const mutationObserver =
typeof MutationObserver === 'function'
? new MutationObserver(() => applyWorkbenchToolbarLayout(element))
: null;
mutationObserver?.observe(element, {
childList: true,
characterData: true,
subtree: true,
});
return () => {
resizeObserver?.disconnect();
mutationObserver?.disconnect();
};
}, [toolbarRef]);
}
@@ -0,0 +1,49 @@
/**
* 工作台顶栏在横向空间不足时的降级档位。
*
* 顶栏是**一行到底**的 flex 行:左侧「资源管理 / 运行」,右侧动作区 + 版本入口。以前两侧都可以
* `flex-wrap: wrap`,窄一点就把「播放」和版本入口甩到第二行——同一行控件分裂成两行,用户明确
* 否掉了这种换行。现在不换行,空间不够按固定顺序退:
*
* 1. `full`:动作全部内联,版本入口显示 `版本 1(智能体修订 · 时间)`;
* 2. `compact-version`:版本入口只留 `版本 1`(括号里那截原因与时间整体收起);
* 3. `collapsed-actions`:`打开项目目录 / 资源面板 / 整理画布` 收进「更多」下拉;
* 4. 再不够就停在 `collapsed-actions`:版本名由省略号兜底,动作区不再继续牺牲。
*
* 档位只决定「隐藏什么」,具体隐藏由 `styles.css` 里 `[data-layout=...]` 的选择器完成;
* 每一档到底占多宽由真实 DOM 量(`useWorkbenchToolbarLayout`),这里只留纯判据,便于单测。
*/
export const WORKBENCH_TOOLBAR_LAYOUTS = [
'full',
'compact-version',
'collapsed-actions',
] as const;
export type WorkbenchToolbarLayout = (typeof WORKBENCH_TOOLBAR_LAYOUTS)[number];
/**
* 「这一档放不下」的余量。
*
* 判定用 `scrollWidth > clientWidth`,两者都取整到整数像素,且 125% 缩放下同一份内容在不同
* 设备像素比下会差 1px 上下;不给余量就会在临界宽度上反复在相邻两档之间抖。
*/
const OVERFLOW_TOLERANCE = 1;
/**
* 按档位顺序取第一个放得下的档位。
*
* `measureContentWidth(candidate)` 由调用方提供:它把候选档位真的写到 DOM 上再量一次内容宽度
* (见 `useWorkbenchToolbarLayout`),所以这里不缓存任何宽度、也不做增量推算——每一档的宽度
* 都随模式(资源页 / 运行页)、动作出现与否(恢复草稿、点选素材)变化。
*/
export function resolveWorkbenchToolbarLayout(
availableWidth: number,
measureContentWidth: (candidate: WorkbenchToolbarLayout) => number,
): WorkbenchToolbarLayout {
for (const candidate of WORKBENCH_TOOLBAR_LAYOUTS) {
if (measureContentWidth(candidate) - availableWidth <= OVERFLOW_TOLERANCE) {
return candidate;
}
}
return 'collapsed-actions';
}
@@ -461,6 +461,9 @@ export function registerProjectWorkbenchFoundationTests() {
],
});
// 「运行」页签就是播放入口(原「播放」按钮并进来):这枚 spy 用来证明不可运行时
// 点了它既不切视图、也不向宿主发播放请求。
const onPlay = vi.fn();
render(
React.createElement(ProjectDevelopmentView, {
projectName: '陶泥儿工作台测试',
@@ -483,6 +486,7 @@ export function registerProjectWorkbenchFoundationTests() {
),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
onPlay,
}),
);
@@ -500,14 +504,13 @@ export function registerProjectWorkbenchFoundationTests() {
const runTab = screen.getByRole('tab', {
name: '运行',
}) as HTMLButtonElement;
// 「运行」页签就是播放入口(原「播放」按钮并进来):不可运行时不置灰、只给提示,
// 点了既不切视图、也不向宿主发播放请求。
expect(runTab.disabled).toBe(false);
expect(runTab.getAttribute('data-unavailable')).toBe('true');
const playButton = screen.getByRole('button', {
name: '播放',
}) as HTMLButtonElement;
expect(playButton.disabled).toBe(true);
fireEvent.click(runTab);
expect(runTab.getAttribute('aria-selected')).toBe('false');
expect(onPlay).not.toHaveBeenCalled();
expect(
screen.getByText('首个可运行原型尚未完成,运行视图暂不可用'),
).not.toBeNull();
@@ -5895,15 +5898,25 @@ export function registerProjectWorkbenchFoundationTests() {
expect(styles).toMatch(
/\.game-workbench-view-tabs\s*\{[^}]*display:\s*flex[^}]*align-items:\s*center/s,
);
// 播放按钮跟着「资源管理 / 运行」左对齐,不再居中悬浮。
// 播放并进「运行」页签:顶栏左侧只剩模式分段,「播放」按钮与它的整套外观规则一起退役。
expect(styles).not.toMatch(/game-workbench-play-button/);
// 顶栏一行到底:空间不够走 `data-layout` 降级,不再换行(换行会把版本入口甩到第二行)。
expect(styles).toMatch(
/\.game-workbench-view-tabs \.game-workbench-play-button\s*\{[^}]*position:\s*static[^}]*transform:\s*none/s,
/\.game-workbench-toolbar\s*\{[^}]*flex-wrap:\s*nowrap[^}]*overflow:\s*hidden/s,
);
expect(styles).not.toMatch(
/\.game-workbench-play-button\s*\{[^}]*position:\s*absolute/s,
// 两档降级:先收版本名的括号段,再收「打开项目目录 / 资源面板 / 整理画布」进「更多」,
// 且版本入口钉在动作区最右。
expect(styles).toMatch(
/\.game-workbench-toolbar\[data-layout='compact-version'\]\s*\.game-run-version-trigger-detail\s*\{[^}]*display:\s*none/s,
);
// 新位置必须并进按钮基础外观与焦点环的规则列表:不然播放按钮会掉成零圆角、零内边距、
// 无边框、默认字号的裸按钮,而颜色规则看起来仍然生效。
expect(styles).toMatch(
/\.game-workbench-toolbar\[data-layout='collapsed-actions'\]\s*\.game-workbench-foldable-action\s*\{[^}]*display:\s*none/s,
);
expect(styles).toMatch(
/\.game-run-version-picker\s*\{[^}]*margin-left:\s*auto/s,
);
// 模式分段与动作区按钮必须共用同一条基础外观规则与焦点环:不然新进来的「运行 ▶」页签会掉成
// 零圆角、零内边距、无边框、默认字号的裸按钮,而颜色规则看起来仍然生效。
expect(styles).toMatch(
/\.game-workbench-tabs button,\s*\.game-workbench-view-tabs button,\s*\.game-workbench-view-actions button\s*\{[^}]*border-radius:\s*999px/s,
);
@@ -6513,7 +6526,8 @@ export function registerProjectWorkbenchFoundationTests() {
}),
);
fireEvent.click(screen.getByRole('button', { name: '播放' }));
// 「运行」页签就是播放入口:切过去即向宿主发一次播放请求,并落到运行表现层。
fireEvent.click(screen.getByRole('tab', { name: '运行' }));
expect(onPlay).toHaveBeenCalledTimes(1);
expect(screen.getByLabelText('运行表现层')).not.toBeNull();
@@ -6521,7 +6535,9 @@ export function registerProjectWorkbenchFoundationTests() {
name: '运行',
}) as HTMLButtonElement;
expect(runTab.disabled).toBe(false);
// 再点一次同一枚页签=重跑:空态与预览失败态都靠它。
fireEvent.click(runTab);
expect(onPlay).toHaveBeenCalledTimes(2);
expect(screen.getByLabelText('运行表现层')).not.toBeNull();
const previewFrame = screen.getByTitle(
'可运行工作台 游戏运行画面',
@@ -6747,7 +6763,7 @@ export function registerProjectWorkbenchFoundationTests() {
expect(screen.queryByTitle('远程预览拒绝测试 游戏运行画面')).toBeNull();
expect(screen.getByText('客户端运行画面尚未载入')).not.toBeNull();
expect(
screen.getByText('点击顶部播放按钮后将在这里直接运行游戏'),
screen.getByText('点击顶部「运行」页签后将在这里直接运行游戏'),
).not.toBeNull();
expect(screen.queryByText(/\/run|\/preview/)).toBeNull();
});
@@ -56,12 +56,25 @@ describe('运行页顶栏动作区样式', () => {
expect(trigger.has('border')).toBe(false);
expect(trigger.has('background')).toBe(false);
expect(trigger.has('color')).toBe(false);
expect(declaration(trigger, 'min-width')).toBe('0');
// 入口默认不参与收缩(`max-content`):收缩 + 省略号会把溢出「吃掉」,顶栏的档位判定
// 就永远量不到真实溢出。只有降到最后一档(动作已全收进「更多」)才允许它省略兜底。
expect(declaration(trigger, 'min-width')).toBe('max-content');
expect(
declaration(
resolved([
".game-workbench-toolbar[data-layout='collapsed-actions'] .game-run-version-trigger",
]),
'min-width',
),
).toBe('0');
// 省略号要真的生效:按钮是 flex 容器,文本必须挂在自带 overflow 的 span 上。
const label = resolved(['.game-run-version-trigger-label']);
expect(declaration(label, 'overflow')).toBe('hidden');
expect(declaration(label, 'text-overflow')).toBe('ellipsis');
expect(declaration(label, 'white-space')).toBe('nowrap');
// 括号里那一截(原因 + 时间)单独一层,窄档位收掉的就是它,序号永远留着。
expect(hasRule('.game-run-version-trigger-detail')).toBe(true);
expect(hasRule('.game-run-version-trigger-title')).toBe(true);
expect(hasRule('.game-run-version-trigger:hover')).toBe(false);
expect(hasRule('.game-run-version-trigger:focus-visible')).toBe(false);
@@ -2,7 +2,9 @@ import { describe, expect, test } from 'vitest';
import {
currentVersionResourceBindingIds,
formatIterationVersionDetail,
formatIterationVersionLabel,
formatIterationVersionTitle,
isResourceUsedByCurrentVersion,
} from '../src/features/resource-canvas/resourceCanvasVersionBindingModel';
@@ -86,10 +88,18 @@ describe('运行模块版本绑定判定', () => {
});
test('labels a version with its Chinese created reason and creation time', () => {
expect(formatIterationVersionLabel(versions[0]!)).toBe(
`初始版本 · ${new Date(1_788_075_047 * 1000).toLocaleString('zh-CN')}`,
// 完整标识=`版本 N(原因 · 时间)`;`N` 取 manifest 里的落盘顺序(与资源画布
// 「项目卡片」上的 `版本 N` 同一编号)。
expect(formatIterationVersionLabel(versions[0]!, 0)).toBe(
`版本 1(初始版本 · ${new Date(1_788_075_047 * 1000).toLocaleString('zh-CN')})`,
);
expect(formatIterationVersionLabel(versions[1]!)).toBe(
expect(formatIterationVersionLabel(versions[1]!, 1)).toBe(
`版本 2(智能体修订 · ${new Date(1_788_075_104 * 1000).toLocaleString('zh-CN')})`,
);
// 顶栏窄到只放得下序号时显示的就是 `formatIterationVersionTitle`,括号里那一截整段收起
// (样式按 `data-layout` 隐藏 `.game-run-version-trigger-detail`)。两半各自是独立口径。
expect(formatIterationVersionTitle(1)).toBe('版本 2');
expect(formatIterationVersionDetail(versions[1]!)).toBe(
`智能体修订 · ${new Date(1_788_075_104 * 1000).toLocaleString('zh-CN')}`,
);
});
@@ -100,8 +110,8 @@ describe('运行模块版本绑定判定', () => {
const createdAtLocal = new Date(1_788_075_047 * 1000).toLocaleString(
'zh-CN',
);
const label = formatIterationVersionLabel(versions[0]!);
expect(label).toBe(`初始版本 · ${createdAtLocal}`);
const label = formatIterationVersionLabel(versions[0]!, 0);
expect(label).toBe(`版本 1(初始版本 · ${createdAtLocal})`);
// 年 / 月 / 日必须来自真实时刻,而不是 1970。这里用显式数值选项断言,
// 不依赖 `toLocaleString` 的具体排版(不同 ICU 版本会给出不同分隔符)。
@@ -0,0 +1,112 @@
/** @vitest-environment jsdom */
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
import ProjectDevelopmentView from '../src/view/project-development';
import { installTauriRuntime } from './tauriRuntimeFake';
const PREVIEW_URL = 'http://127.0.0.1:4173/';
function installInvoke() {
installTauriRuntime({
core: {
invoke: vi.fn(async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return {
resourceIds: [],
referenceEdges: [],
taskFlows: [],
categories: [],
diagnostics: [],
};
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: args?.expectedProjectId,
mode: args?.mode,
revision: 0,
positions: [],
updatedAt: 0,
};
}
throw new Error(`unexpected invoke ${command}`);
}),
},
});
}
function renderRunView(
preview: { status: string; url: string | null; port: number | null } = {
status: 'running',
url: PREVIEW_URL,
port: 4173,
},
) {
installInvoke();
const onPlay = vi.fn();
const manifest = createGameCreationAppManifest(
'run-preview-refresh',
'运行页刷新画面',
);
manifest.tasks = manifest.tasks.map((task) =>
task.id === 'code-prototype' ? { ...task, status: 'completed' } : task,
);
render(
<ProjectDevelopmentView
projectName={manifest.name}
projectPath="/tmp/run-preview-refresh"
manifest={manifest}
attachments={[]}
recentRunStatus="completed"
recentRunStopReason={null}
preview={preview}
chat={null}
onHomeOpen={vi.fn()}
onProjectsOpen={vi.fn()}
onNotice={vi.fn()}
onPlay={onPlay}
/>,
);
return { onPlay };
}
afterEach(() => {
cleanup();
vi.clearAllMocks();
vi.restoreAllMocks();
});
describe('运行页「刷新运行画面」', () => {
it('点一下就重新载入画面:iframe 换新节点、地址不变,并顺带把预览确认成活体', async () => {
const { onPlay } = renderRunView();
await screen.findByTitle('运行页刷新画面 游戏运行画面');
const before = document.querySelector('iframe');
expect(before).not.toBeNull();
expect(before?.getAttribute('src')).toBe(PREVIEW_URL);
fireEvent.click(screen.getByRole('button', { name: '刷新运行画面' }));
const after = document.querySelector('iframe');
// 换元素身份=浏览器重新载入这一页:游戏预览不是 vite dev 的实时刷新,只有这一条路。
expect(after).not.toBe(before);
expect(after?.getAttribute('src')).toBe(PREVIEW_URL);
expect(onPlay).toHaveBeenCalledTimes(1);
});
it('没有活预览时不渲染刷新,而不是留一个点了没反应的入口', async () => {
// 非 loopback 地址会被 `resolveEmbeddedPreviewUrl` 拒掉。此时切到运行页只有空态,
// 该走的是顶栏「运行」页签——刷新入口不该在没画面可刷的时候出现。
renderRunView({
status: 'running',
url: 'https://example.com/game',
port: 443,
});
fireEvent.click(screen.getByRole('tab', { name: '运行' }));
await screen.findByText('客户端运行画面尚未载入');
expect(screen.queryByRole('button', { name: '刷新运行画面' })).toBeNull();
});
});
@@ -0,0 +1,83 @@
import { describe, expect, test } from 'vitest';
import {
resolveWorkbenchToolbarLayout,
type WorkbenchToolbarLayout,
} from '../src/view/project-development/workbenchToolbarModel';
/**
* 工作台顶栏降级阶梯的判据:顺序、余量与「都放不下」的兜底。
*
* 每一档的真实宽度由 DOM 量(`useWorkbenchToolbarLayout` 把候选档位写上去再读 `scrollWidth`),
* 所以这里只喂一个假的量宽函数;真机几何由浏览器实测确认,这一条钉的是**顺序**。
*/
function ladder(
availableWidth: number,
widths: Record<WorkbenchToolbarLayout, number>,
) {
const measured: WorkbenchToolbarLayout[] = [];
const layout = resolveWorkbenchToolbarLayout(availableWidth, (candidate) => {
measured.push(candidate);
return widths[candidate];
});
return { layout, measured };
}
describe('工作台顶栏降级阶梯', () => {
test('放得下就停在 full,不去量后面的档位', () => {
const { layout, measured } = ladder(1000, {
full: 900,
'compact-version': 800,
'collapsed-actions': 400,
});
expect(layout).toBe('full');
expect(measured).toEqual(['full']);
});
test('full 放不下先收版本名的括号段,动作一枚都不动', () => {
const { layout, measured } = ladder(1000, {
full: 1100,
'compact-version': 900,
'collapsed-actions': 500,
});
expect(layout).toBe('compact-version');
expect(measured).toEqual(['full', 'compact-version']);
});
test('收掉括号段还不够,才把「打开项目目录 / 资源面板 / 整理画布」收进「更多」', () => {
const { layout, measured } = ladder(1000, {
full: 1400,
'compact-version': 1100,
'collapsed-actions': 700,
});
expect(layout).toBe('collapsed-actions');
expect(measured).toEqual(['full', 'compact-version', 'collapsed-actions']);
});
test('三档都放不下时停在最后一档,不回到前面已经试过的档位', () => {
const { layout, measured } = ladder(500, {
full: 1400,
'compact-version': 1100,
'collapsed-actions': 700,
});
expect(layout).toBe('collapsed-actions');
expect(measured).toEqual(['full', 'compact-version', 'collapsed-actions']);
});
test('临界宽度留 1px 余量:正好贴边算放得下,多出 2px 才降档', () => {
expect(
ladder(1000, {
full: 1001,
'compact-version': 900,
'collapsed-actions': 500,
}).layout,
).toBe('full');
expect(
ladder(1000, {
full: 1002,
'compact-version': 900,
'collapsed-actions': 500,
}).layout,
).toBe('compact-version');
});
});