运行页签补齐全屏预览与可收起的信息栏
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m52s
Project CI / Backend tests (pull_request) Failing after 13s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m5s
Project CI / AI game creator shell Rust lane 2/2 (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 / Frontend tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled

- 运行画面右下角新增「全屏预览」:新增 `useElementFullscreen`,只把画面那一格(`.game-run-preview`)送进全屏,按钮态只认 `fullscreenchange`,宿主没有 Fullscreen API 时整枚入口不渲染。
- 底部信息栏改为「有内容才存在」:运行页选中资源时自动展开,可手动收起到只剩一行开合按钮,清空选中后整栏不渲染;只有一栏内容时卡片铺满整行。
- 「数值微调」在没有登记表前不渲染区域标题与卡片,随这一栏删除其 label / input 样式声明。
- 新增 `tests/runPreviewFullscreen.test.tsx`;AGC 子集补信息栏开合用例,并把「没有内容仍渲染两张空卡片」的旧断言改成整栏不渲染。
- 同步 PRD §3.4、技术方案运行表现层条目与 project-memory 决策记录。
This commit is contained in:
2026-09-23 17:26:53 +08:00
parent 016356e509
commit 09fb8bc076
8 changed files with 420 additions and 43 deletions
@@ -0,0 +1,75 @@
import {
type RefObject,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
export type ElementFullscreenController<T extends HTMLElement> = {
/** 要全屏的那一格;挂在它的 `ref` 上。 */
ref: RefObject<T | null>;
isFullscreen: boolean;
isSupported: boolean;
toggleFullscreen: () => void;
};
/**
* 运行画面「全屏预览」用的**元素级**全屏。
*
* 只走标准 Fullscreen API:宿主是浏览器还是客户端 WebView 都是同一份实现。**不接 Tauri 的
* 窗口级全屏**——那是把整块工作台(连对话栏)放大,语义是「全屏应用」,不是「全屏预览画面」。
*
* 支持判据是两层:`requestFullscreen` 真的在,且没有被宿主显式关掉
* (`fullscreenEnabled === false`,例如被权限策略挡住)。任一不成立就不渲染这枚按钮——
* 一枚点了没反应的全屏按钮比没有按钮更糟。
*
* 全屏元素被移除时浏览器按规范自己退出全屏(切回资源页签、切项目都走这条),所以这里不为
* 卸载补退出逻辑;按钮态只认 `fullscreenchange`,Esc 与宿主自己退出都会回落。
*/
export function useElementFullscreen<
T extends HTMLElement,
>(): ElementFullscreenController<T> {
const elementRef = useRef<T | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const isSupported =
typeof document !== 'undefined' &&
document.fullscreenEnabled !== false &&
typeof document.documentElement?.requestFullscreen === 'function';
useEffect(() => {
if (typeof document === 'undefined') {
return undefined;
}
// 事件挂在全局 `document`、在回调里读 ref:运行画面是条件挂载的,按 ref 订监听会在
// 「进运行页签之前」就订不上,退出全屏(Esc、F11、宿主)再也收不回来。
const sync = () => {
const element = elementRef.current;
setIsFullscreen(
element !== null && document.fullscreenElement === element,
);
};
document.addEventListener('fullscreenchange', sync);
sync();
return () => document.removeEventListener('fullscreenchange', sync);
}, []);
const toggleFullscreen = useCallback(() => {
const element = elementRef.current;
if (!element) {
return;
}
const ownerDocument = element.ownerDocument;
// 已经有人在全屏(本元素,或页面里别的东西)时这一步只负责退出:退出本身幂等,
// 不需要先判断当前全屏的是不是自己。
if (ownerDocument.fullscreenElement) {
void ownerDocument.exitFullscreen?.()?.catch(() => {});
return;
}
// 失败(用户手势丢失、权限策略拒绝)不改按钮状态,界面回到「还是没全屏」的原样;
// 拒绝的 Promise 必须接住,否则会冒成未处理拒绝。
void element.requestFullscreen?.()?.catch(() => {});
}, []);
return { ref: elementRef, isFullscreen, isSupported, toggleFullscreen };
}
+88 -22
View File
@@ -8566,6 +8566,43 @@ iframe.preview-frame {
min-height: 0;
}
/*
* 画面右下角的「全屏预览」。压在游戏画面上,所以用深色半透明底 + 白图标:任何游戏配色下都
* 看得清,也不在画面中间抢位置。全屏那一格还是它自己(`:fullscreen` 铺满屏幕),所以这枚按钮
* 在全屏里照旧可用,用户点它就能退出来。
*/
.game-run-preview-fullscreen {
position: absolute;
right: 10px;
bottom: 10px;
z-index: 2;
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1px solid rgb(255 255 255 / 28%);
border-radius: 10px;
background: rgb(12 14 20 / 62%);
color: #fff;
cursor: pointer;
backdrop-filter: blur(6px);
}
.game-run-preview-fullscreen:hover,
.game-run-preview-fullscreen:focus-visible {
background: rgb(12 14 20 / 84%);
outline: 0;
}
/* 全屏里这一格就是整块屏幕:舞台自己的虚线边框与圆角让位给画面。 */
.game-run-preview:fullscreen {
min-height: 0;
border: 0;
border-radius: 0;
background: #05070b;
}
.game-run-preview-empty {
display: grid;
align-content: center;
@@ -8588,13 +8625,61 @@ iframe.preview-frame {
overflow-wrap: anywhere;
}
/*
* 运行页签的底部信息栏。没有内容时整栏不渲染(判据在 `project-development/index.tsx`),
* 收起态只剩上面那一行开合按钮——条目卡片的 156px 最小高度不会再变成一片空白色块。
*
* 卡片只在**有内容**时渲染:一栏也铺满整行,不留半张空位。原先「数值微调」那一栏没有登记表
* (前端没有数据源),按用户口径没有功能就先不渲染,它的字段样式(label / input)随这一栏一起
* 删掉;登记表接进来时样式与卡片一起回来。
*/
.game-run-panels {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.game-run-panels-controls {
display: flex;
justify-content: flex-end;
}
.game-run-panels-toggle {
display: inline-flex;
align-items: center;
gap: 5px;
min-height: 26px;
padding: 0 10px;
border: 1px solid #eadbd4;
border-radius: 999px;
background: #fff;
color: #62483d;
cursor: pointer;
font-size: 11px;
font-weight: 700;
}
.game-run-panels-toggle:hover,
.game-run-panels-toggle:focus-visible {
border-color: #dfb59f;
background: #fdf1ea;
outline: 0;
}
.game-run-panels-toggle-chevron {
transition: transform 120ms ease;
}
.game-run-panels-toggle-chevron.is-collapsed {
transform: rotate(-90deg);
}
.game-run-panels-body {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr));
gap: 10px;
}
.game-run-panels > section {
.game-run-panels-body > section {
display: grid;
align-content: start;
gap: 8px;
@@ -8616,7 +8701,6 @@ iframe.preview-frame {
}
.game-run-panels p,
.game-run-panels label,
.game-run-panels dt,
.game-run-panels dd {
margin: 0;
@@ -8644,24 +8728,6 @@ iframe.preview-frame {
overflow-wrap: anywhere;
}
.game-run-panels label {
display: grid;
grid-template-columns: minmax(0, 1fr) 88px;
align-items: center;
gap: 8px;
}
.game-run-panels input {
min-width: 0;
height: 30px;
padding: 0 8px;
border: 1px solid #eadbd4;
border-radius: 8px;
background: #faf7f5;
color: #8f7d75;
font-size: 10px;
}
.game-workbench-chat {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
@@ -10006,7 +10072,7 @@ iframe.preview-frame {
min-width: 460px;
}
.game-run-panels {
.game-run-panels-body {
grid-template-columns: 1fr;
}
@@ -20,6 +20,7 @@ import { openUrl } from '@tauri-apps/plugin-opener';
import {
AtSign,
Box,
ChevronDown,
Crosshair,
ExternalLink,
Eye,
@@ -34,6 +35,7 @@ import {
LayoutGrid,
ListFilter,
Maximize2,
Minimize2,
Minus,
Music2,
PackageOpen,
@@ -141,6 +143,7 @@ import {
resourceLabelResolver,
resourceReferenceCategoryLabel,
} from '../../features/project-workspace/resourceReferences';
import { useElementFullscreen } from '../../features/project-workspace/useElementFullscreen';
import { GameRunVersionPicker } from '../../features/resource-canvas/GameRunVersionPicker';
import {
type ResourceCanvasAssetGenerationPanelDraft,
@@ -1818,6 +1821,9 @@ export default function ProjectDevelopmentView({
}: ProjectDevelopmentViewProps) {
const [mode, setMode] = useState<WorkbenchMode>('resources');
const [runtimeInspectMode, setRuntimeInspectMode] = useState(false);
// 运行画面的「全屏预览」:全屏的是画面那一格(`.game-run-preview`),不是整块工作台,
// 所以按钮与 ref 都归运行表现层自己持有。
const runPreviewFullscreen = useElementFullscreen<HTMLDivElement>();
const [resourceBookState, dispatchResourceBook] = useReducer(
resourceBookReducer,
initialResourceBookState,
@@ -3000,6 +3006,23 @@ export default function ProjectDevelopmentView({
const selectedResource =
canvasResources.find((resource) => resource.id === selectedResourceId) ??
null;
/**
* 运行页签底部信息栏(`.game-run-panels`)的开合。
*
* 只有一条判据:**有没有内容**——当前是「运行页里选中了一张资源」,「信息展示」渲染的是它
* 的只读字段。内容从无到有 / 从有到无都自动跟上(没有内容就自动收起),用户在同一段内容里
* 手动收 / 展则一直有效,不会被别的渲染重开。
*
* 「数值微调」暂时没有登记表(前端没有数据源),按用户口径没有功能就先不渲染这一栏;等
* 后端编辑态登记表接进来后,它与它的内容一起进这个判据。
*/
const runPanelsHaveContent = selectedResource !== null;
const [runPanelsExpanded, setRunPanelsExpanded] =
useState(runPanelsHaveContent);
const runPanelsBodyId = useId();
useEffect(() => {
setRunPanelsExpanded(runPanelsHaveContent);
}, [runPanelsHaveContent]);
/**
* 画布选中工具栏「编辑标签」的入口判定:
* - 选中 1 项:既有的单素材标签编辑(增删标签行为不变);
@@ -11002,7 +11025,7 @@ export default function ProjectDevelopmentView({
</>
) : (
<section className="game-run-surface" aria-label="运行表现层">
<div className="game-run-preview">
<div className="game-run-preview" ref={runPreviewFullscreen.ref}>
{embeddedPreviewUrl ? (
<LocalGamePreviewFrame
title={`${projectName} 游戏运行画面`}
@@ -11018,24 +11041,78 @@ export default function ProjectDevelopmentView({
<span>点击顶部播放按钮后将在这里直接运行游戏</span>
</div>
)}
{/*
全屏预览:贴在画面右下角。**只有画面这一格进全屏**——顶部页签、右侧对话与
底部信息栏都不跟着放大,符合「预览画面」而不是「全屏应用」。没有活预览时不渲染,
宿主没有 Fullscreen API 时也不渲染(见 `useElementFullscreen`)。
*/}
{embeddedPreviewUrl && runPreviewFullscreen.isSupported ? (
<button
type="button"
className="game-run-preview-fullscreen"
aria-label={
runPreviewFullscreen.isFullscreen
? '退出全屏预览'
: '全屏预览'
}
aria-pressed={runPreviewFullscreen.isFullscreen}
title={
runPreviewFullscreen.isFullscreen
? '退出全屏预览'
: '全屏预览游戏画面'
}
onClick={runPreviewFullscreen.toggleFullscreen}
>
{runPreviewFullscreen.isFullscreen ? (
<Minimize2 size={16} aria-hidden="true" />
) : (
<Maximize2 size={16} aria-hidden="true" />
)}
</button>
) : null}
</div>
<div className="game-run-panels">
<section aria-label="资源信息面板">
<header>
<Info size={16} aria-hidden="true" />
信息展示
</header>
{selectedResource ? (
<ResourceInfoFieldsView resource={selectedResource} />
{/*
底部信息栏:有内容才存在,没有内容就自动收起(整栏不渲染)。
收起态只留下这一行开合按钮,展开态才渲染条目卡片——卡片的 156px 最小高度
因此不会再变成一片空白色块。
*/}
{runPanelsHaveContent ? (
<div className="game-run-panels">
<div className="game-run-panels-controls">
<button
type="button"
className="game-run-panels-toggle"
aria-expanded={runPanelsExpanded}
aria-controls={runPanelsBodyId}
onClick={() =>
setRunPanelsExpanded((current) => !current)
}
>
<ChevronDown
size={14}
aria-hidden="true"
className={`game-run-panels-toggle-chevron${
runPanelsExpanded ? '' : ' is-collapsed'
}`}
/>
{runPanelsExpanded ? '收起信息栏' : '展开信息栏'}
</button>
</div>
{runPanelsExpanded ? (
<div id={runPanelsBodyId} className="game-run-panels-body">
<section aria-label="资源信息面板">
<header>
<Info size={16} aria-hidden="true" />
信息展示
</header>
{selectedResource ? (
<ResourceInfoFieldsView resource={selectedResource} />
) : null}
</section>
</div>
) : null}
</section>
<section aria-label="数值微调面板">
<header>
<SlidersHorizontal size={16} aria-hidden="true" />
数值微调
</header>
</section>
</div>
</div>
) : null}
</section>
)}
{/*
@@ -3886,6 +3886,11 @@ export function registerProjectWorkbenchFoundationTests() {
fireEvent.click(runTab);
const runInfoPanel = screen.getByLabelText('资源信息面板');
expect(resourceInfoFieldRows(runInfoPanel)).toEqual(expectedRows);
// 有内容就自动展开;手动收起后条目卡片整体让位,只剩那一行开合按钮。
fireEvent.click(screen.getByRole('button', { name: '收起信息栏' }));
expect(screen.queryByLabelText('资源信息面板')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '展开信息栏' }));
expect(screen.getByLabelText('资源信息面板')).not.toBeNull();
// 画布浮层只属于画布:切到运行视图后不再渲染。
expect(screen.queryByRole('dialog', { name: '资源信息' })).toBeNull();
});
@@ -6456,8 +6461,11 @@ export function registerProjectWorkbenchFoundationTests() {
'allow-scripts allow-same-origin allow-forms allow-pointer-lock',
);
expect(screen.queryByLabelText('测试切片控件')).toBeNull();
expect(screen.getByLabelText('资源信息面板')).not.toBeNull();
expect(screen.getByLabelText('数值微调面板')).not.toBeNull();
// 底部信息栏没有内容就整栏不渲染:此时既没有选中资源(信息展示没有字段),也没有
// 已登记的微调项。留着两张空卡片正是验收现场那半条「空信息栏白占一块高度」。
expect(screen.queryByLabelText('资源信息面板')).toBeNull();
expect(screen.queryByLabelText('数值微调面板')).toBeNull();
expect(screen.queryByRole('button', { name: '展开信息栏' })).toBeNull();
fireEvent.click(screen.getByRole('tab', { name: '资源管理' }));
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
@@ -0,0 +1,142 @@
/** @vitest-environment jsdom */
import { 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';
const PREVIEW_URL = 'http://127.0.0.1:4173/';
function installInvoke() {
window.__TAURI__ = {
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}`);
}),
},
} as unknown as typeof window.__TAURI__;
}
function renderRunView() {
installInvoke();
const manifest = createGameCreationAppManifest(
'run-preview-fullscreen',
'运行页全屏预览',
);
render(
<ProjectDevelopmentView
projectName={manifest.name}
projectPath="/tmp/run-preview-fullscreen"
manifest={manifest}
attachments={[]}
recentRunStatus={null}
recentRunStopReason={null}
preview={{ status: 'running', url: PREVIEW_URL, port: 4173 }}
supervisor={<div>项目总控</div>}
onHomeOpen={vi.fn()}
onProjectsOpen={vi.fn()}
onNotice={vi.fn()}
/>,
);
}
/**
* jsdom 完全没有 Fullscreen API(`document.fullscreenEnabled` / `requestFullscreen` /
* `fullscreenElement` 都是 undefined),所以这一组用例必须自己把宿主那一份补出来:
* 全屏状态、元素身份与 `fullscreenchange` 都按规范最小实现,只用于验按钮的真实行为。
*/
function installFullscreenHost() {
let fullscreenElement: Element | null = null;
const requestFullscreen = vi.fn(function (this: Element) {
// 这枚桩就是要记录调用方传进来的 `this`:全屏元素身份正是本用例的断言对象(画面那一格)。
// eslint-disable-next-line @typescript-eslint/no-this-alias
fullscreenElement = this;
document.dispatchEvent(new Event('fullscreenchange'));
return Promise.resolve();
});
const exitFullscreen = vi.fn(() => {
fullscreenElement = null;
document.dispatchEvent(new Event('fullscreenchange'));
return Promise.resolve();
});
Object.defineProperty(document, 'fullscreenElement', {
configurable: true,
get: () => fullscreenElement,
});
Object.defineProperty(document, 'exitFullscreen', {
configurable: true,
value: exitFullscreen,
});
Object.defineProperty(Element.prototype, 'requestFullscreen', {
configurable: true,
writable: true,
value: requestFullscreen,
});
return { requestFullscreen, exitFullscreen };
}
afterEach(() => {
document.body.innerHTML = '';
Reflect.deleteProperty(document, 'fullscreenElement');
Reflect.deleteProperty(document, 'exitFullscreen');
Reflect.deleteProperty(Element.prototype, 'requestFullscreen');
vi.clearAllMocks();
vi.restoreAllMocks();
});
describe('运行页「全屏预览」', () => {
it('点画面右下角那枚按钮就把画面那一格送进全屏,再点退出', async () => {
const { requestFullscreen, exitFullscreen } = installFullscreenHost();
renderRunView();
const button = await screen.findByRole('button', { name: '全屏预览' });
// 按钮住在画面那一格里(右下角由样式给),全屏的也是那一格——不是整块工作台。
const stage = document.querySelector('.game-run-preview');
expect(stage).not.toBeNull();
expect(button.closest('.game-run-preview')).toBe(stage);
expect(button.getAttribute('aria-pressed')).toBe('false');
fireEvent.click(button);
expect(requestFullscreen).toHaveBeenCalledTimes(1);
// 全屏元素就是画面那一格:按钮自己也算得出来(状态来自 fullscreenchange,不是乐观值)。
expect(document.fullscreenElement).toBe(stage);
const exitButton = await screen.findByRole('button', {
name: '退出全屏预览',
});
expect(exitButton.getAttribute('aria-pressed')).toBe('true');
fireEvent.click(exitButton);
expect(exitFullscreen).toHaveBeenCalledTimes(1);
expect(
(await screen.findByRole('button', { name: '全屏预览' })).getAttribute(
'aria-pressed',
),
).toBe('false');
});
it('宿主没有 Fullscreen API 时不渲染这枚按钮,而不是留一个点了没反应的入口', async () => {
renderRunView();
await screen.findByTitle('运行页全屏预览 游戏运行画面');
expect(screen.queryByRole('button', { name: '全屏预览' })).toBeNull();
});
});