Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 38928167f4 | |||
| d0b15dbc54 | |||
| 96971403ee | |||
| 45ccc58969 | |||
| 9e709ce727 | |||
| 96ae3db1df |
+81
-51
@@ -1,7 +1,7 @@
|
||||
import './resourceCanvasAssetGenerationTasksSidebar.css';
|
||||
|
||||
import { ChevronLeft, ChevronRight, ListChecks, X } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ListChecks, X } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
RESOURCE_CANVAS_ASSET_GENERATION_STATUS_LABELS,
|
||||
@@ -16,6 +16,12 @@ import {
|
||||
/** 「已完成」分栏的展示上限:触顶后只提示还有多少条,不无限拉长侧栏。 */
|
||||
export const RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT = 20;
|
||||
|
||||
/**
|
||||
* 收起动画时长,必须与 `resourceCanvasAssetGenerationTasksSidebar.css` 里的
|
||||
* `game-resource-generation-tasks-leave` 一致:收起时侧栏要先播完这一段再卸载。
|
||||
*/
|
||||
export const RESOURCE_CANVAS_ASSET_GENERATION_TASKS_LEAVE_MILLIS = 160;
|
||||
|
||||
export type ResourceCanvasAssetGenerationTasksPanelViewProps = {
|
||||
tasks: readonly ResourceCanvasAssetGenerationTask[];
|
||||
/** 侧栏是否展开;折叠时只留贴边把手。 */
|
||||
@@ -102,6 +108,13 @@ export function ResourceCanvasAssetGenerationTasksPanelView({
|
||||
onFocusTask,
|
||||
}: ResourceCanvasAssetGenerationTasksPanelViewProps) {
|
||||
const [nowMillis, setNowMillis] = useState(() => Date.now());
|
||||
/**
|
||||
* 收起动画期:`open` 已经变 false,但侧栏还要留在 DOM 里把 `…-leave` 播完
|
||||
* (`entering` 是刚打开时给根节点挂进场动画的那一档)。
|
||||
*/
|
||||
const [phase, setPhase] = useState<'idle' | 'entering' | 'leaving'>(
|
||||
open ? 'entering' : 'idle',
|
||||
);
|
||||
const inFlightCount = tasks.filter(
|
||||
(task) => !resourceCanvasAssetGenerationTaskIsTerminal(task),
|
||||
).length;
|
||||
@@ -120,6 +133,29 @@ export function ResourceCanvasAssetGenerationTasksPanelView({
|
||||
0,
|
||||
RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT,
|
||||
);
|
||||
/**
|
||||
* 收起动画期间必须沿用最后一份列表:宿主会在同一帧里收起侧栏并把「在途」收口成
|
||||
* 已完成,直接吃新 props 会让退出动画里的内容跳一下。
|
||||
*/
|
||||
const lastRenderedRef = useRef({
|
||||
inFlightCount,
|
||||
ordered,
|
||||
active,
|
||||
visibleDone,
|
||||
done,
|
||||
});
|
||||
if (open) {
|
||||
lastRenderedRef.current = {
|
||||
inFlightCount,
|
||||
ordered,
|
||||
active,
|
||||
visibleDone,
|
||||
done,
|
||||
};
|
||||
}
|
||||
const rendered = open
|
||||
? { inFlightCount, ordered, active, visibleDone, done }
|
||||
: lastRenderedRef.current;
|
||||
|
||||
// 已耗时是前端计时(后端只给时间戳):只在还有未终态任务时走秒表,全部收口后停掉。
|
||||
useEffect(() => {
|
||||
@@ -130,39 +166,41 @@ export function ResourceCanvasAssetGenerationTasksPanelView({
|
||||
return () => clearInterval(timer);
|
||||
}, [hasLiveTask]);
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="platform-theme platform-theme--light game-resource-generation-tasks-handle"
|
||||
aria-label="展开生成任务"
|
||||
aria-expanded={false}
|
||||
data-resource-generation-task-count={inFlightCount}
|
||||
onClick={onToggleOpen}
|
||||
>
|
||||
<ListChecks size={15} aria-hidden="true" />
|
||||
<span className="game-resource-generation-tasks-handle-label">
|
||||
生成任务
|
||||
</span>
|
||||
{inFlightCount > 0 ? (
|
||||
<span
|
||||
className="game-resource-generation-tasks-handle-badge"
|
||||
aria-label={`在途生成任务 ${inFlightCount}`}
|
||||
>
|
||||
{inFlightCount}
|
||||
</span>
|
||||
) : null}
|
||||
<ChevronRight size={13} aria-hidden="true" />
|
||||
</button>
|
||||
/**
|
||||
* open → 进场,close → 先留一帧播 `…-leave` 再卸载。
|
||||
*
|
||||
* 减少动效偏好下这一段动画在 CSS 里被关掉,所以那次收起要**立即**卸载,不能白等 160ms。
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setPhase('entering');
|
||||
return undefined;
|
||||
}
|
||||
setPhase((current) => (current === 'idle' ? 'idle' : 'leaving'));
|
||||
const reducedMotion =
|
||||
typeof window.matchMedia === 'function' &&
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
const timer = window.setTimeout(
|
||||
() => setPhase('idle'),
|
||||
reducedMotion ? 0 : RESOURCE_CANVAS_ASSET_GENERATION_TASKS_LEAVE_MILLIS,
|
||||
);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [open]);
|
||||
|
||||
// 折叠态不再在画布左侧留贴边把手:开合口只剩工具条上那一枚「生成任务」按钮
|
||||
// (带在途计数),收起时侧栏完全让出画布(播完收起动画之后)。
|
||||
if (!open && phase === 'idle') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="platform-theme platform-theme--light game-resource-generation-tasks-sidebar"
|
||||
className={`platform-theme platform-theme--light game-resource-generation-tasks-sidebar${
|
||||
open ? '' : ' is-leaving'
|
||||
}`}
|
||||
role="region"
|
||||
aria-label="生成任务"
|
||||
data-resource-generation-task-count={inFlightCount}
|
||||
data-resource-generation-task-count={rendered.inFlightCount}
|
||||
>
|
||||
<header className="game-resource-generation-tasks-sidebar-header">
|
||||
<h2 className="game-resource-generation-tasks-sidebar-title">
|
||||
@@ -170,25 +208,25 @@ export function ResourceCanvasAssetGenerationTasksPanelView({
|
||||
生成任务
|
||||
<span
|
||||
className="game-resource-generation-tasks-sidebar-count"
|
||||
aria-label={`在途生成任务 ${inFlightCount}`}
|
||||
aria-label={`在途生成任务 ${rendered.inFlightCount}`}
|
||||
>
|
||||
{inFlightCount}
|
||||
{rendered.inFlightCount}
|
||||
</span>
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="game-resource-generation-tasks-sidebar-icon-button"
|
||||
aria-label="收起生成任务"
|
||||
aria-label="关闭生成任务"
|
||||
onClick={onToggleOpen}
|
||||
>
|
||||
<ChevronLeft size={16} aria-hidden="true" />
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
<div
|
||||
className="game-resource-generation-tasks-scroll"
|
||||
data-resource-generation-task-scroll=""
|
||||
>
|
||||
{ordered.length === 0 ? (
|
||||
{rendered.ordered.length === 0 ? (
|
||||
<p className="game-resource-generation-tasks-empty" role="status">
|
||||
还没有生成任务
|
||||
</p>
|
||||
@@ -201,16 +239,18 @@ export function ResourceCanvasAssetGenerationTasksPanelView({
|
||||
<h3 className="game-resource-generation-tasks-section-title">
|
||||
<span>排队/生成中</span>
|
||||
<span className="game-resource-generation-tasks-section-title-count">
|
||||
{active.length}
|
||||
{rendered.active.length}
|
||||
</span>
|
||||
</h3>
|
||||
{active.length === 0 ? (
|
||||
{rendered.active.length === 0 ? (
|
||||
<p className="game-resource-generation-tasks-empty">
|
||||
没有进行中的任务
|
||||
</p>
|
||||
) : (
|
||||
<ul className="game-resource-generation-tasks-list">
|
||||
{active.map((task) => taskRow(task, nowMillis, onFocusTask))}
|
||||
{rendered.active.map((task) =>
|
||||
taskRow(task, nowMillis, onFocusTask),
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
@@ -221,24 +261,24 @@ export function ResourceCanvasAssetGenerationTasksPanelView({
|
||||
<h3 className="game-resource-generation-tasks-section-title">
|
||||
<span>已完成</span>
|
||||
<span className="game-resource-generation-tasks-section-title-count">
|
||||
{done.length}
|
||||
{rendered.done.length}
|
||||
</span>
|
||||
</h3>
|
||||
{done.length === 0 ? (
|
||||
{rendered.done.length === 0 ? (
|
||||
<p className="game-resource-generation-tasks-empty">
|
||||
还没有完成的任务
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<ul className="game-resource-generation-tasks-list">
|
||||
{visibleDone.map((task) =>
|
||||
{rendered.visibleDone.map((task) =>
|
||||
taskRow(task, nowMillis, onFocusTask),
|
||||
)}
|
||||
</ul>
|
||||
{done.length > visibleDone.length ? (
|
||||
{rendered.done.length > rendered.visibleDone.length ? (
|
||||
<p className="game-resource-generation-tasks-empty">
|
||||
{`仅显示最近 ${RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT} 条,另有 ${
|
||||
done.length - visibleDone.length
|
||||
rendered.done.length - rendered.visibleDone.length
|
||||
} 条较早记录`}
|
||||
</p>
|
||||
) : null}
|
||||
@@ -248,16 +288,6 @@ export function ResourceCanvasAssetGenerationTasksPanelView({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<footer className="game-resource-generation-tasks-sidebar-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="game-resource-generation-tasks-sidebar-icon-button"
|
||||
aria-label="关闭生成任务"
|
||||
onClick={onToggleOpen}
|
||||
>
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</footer>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
+24
-82
@@ -40,6 +40,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* 收起:与进场同向反向播一遍,播完由组件卸载(时长见组件里的 `…_LEAVE_MILLIS`,两处必须一致)。 */
|
||||
.game-resource-generation-tasks-sidebar.is-leaving {
|
||||
animation: game-resource-generation-tasks-leave 160ms ease-in forwards;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes game-resource-generation-tasks-leave {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(-0.5rem);
|
||||
}
|
||||
}
|
||||
|
||||
.game-resource-generation-tasks-sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -94,7 +112,6 @@
|
||||
}
|
||||
|
||||
.game-resource-generation-tasks-sidebar-icon-button:focus-visible,
|
||||
.game-resource-generation-tasks-handle:focus-visible,
|
||||
.game-resource-generation-task-locate:focus-visible {
|
||||
outline: 2px solid var(--platform-accent);
|
||||
outline-offset: 2px;
|
||||
@@ -326,78 +343,7 @@
|
||||
color: var(--platform-text-strong);
|
||||
}
|
||||
|
||||
.game-resource-generation-tasks-sidebar-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
border-top: 1px solid var(--platform-line-soft);
|
||||
padding: 0.4rem 0.6rem;
|
||||
}
|
||||
|
||||
/* 折叠态:贴边竖向把手 + 在途数量角标。 */
|
||||
.game-resource-generation-tasks-handle {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
border: 1px solid var(--platform-subpanel-border);
|
||||
border-left: 0;
|
||||
border-radius: 0 0.75rem 0.75rem 0;
|
||||
background: var(--platform-subpanel-fill);
|
||||
color: var(--platform-text-strong);
|
||||
padding: 0.6rem 0.3rem;
|
||||
box-shadow: var(--platform-panel-shadow);
|
||||
transform: translateY(-50%);
|
||||
transition:
|
||||
background 120ms ease,
|
||||
box-shadow 120ms ease,
|
||||
transform 120ms ease;
|
||||
animation: game-resource-generation-tasks-handle-enter 160ms ease-out;
|
||||
}
|
||||
|
||||
.game-resource-generation-tasks-handle:hover {
|
||||
background: var(--platform-nav-item-hover-fill);
|
||||
box-shadow: var(--platform-desktop-hover-shadow);
|
||||
transform: translateY(-50%) translateX(0.1rem);
|
||||
}
|
||||
|
||||
@keyframes game-resource-generation-tasks-handle-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-50%) translateX(-0.5rem);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(-50%) translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.game-resource-generation-tasks-handle-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 850;
|
||||
writing-mode: vertical-rl;
|
||||
}
|
||||
|
||||
.game-resource-generation-tasks-handle-badge {
|
||||
display: inline-flex;
|
||||
min-width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--platform-accent);
|
||||
border-radius: 999px;
|
||||
color: var(--platform-accent);
|
||||
font-size: 0.68rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
/* 窄屏(含 360px):侧栏占满可用宽度,把手只占一条窄边,不挡画布操作。 */
|
||||
/* 窄屏(含 360px):侧栏占满可用宽度,不挡画布操作。 */
|
||||
@media (max-width: 480px) {
|
||||
.game-resource-generation-tasks-sidebar {
|
||||
top: 3.5rem;
|
||||
@@ -406,21 +352,16 @@
|
||||
left: 0.5rem;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.game-resource-generation-tasks-handle {
|
||||
padding: 0.5rem 0.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* 降低动效偏好:进场动画、悬停位移与呼吸全部关掉。 */
|
||||
/* 降低动效偏好:进场 / 收起动画、悬停位移与呼吸全部关掉。 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.game-resource-generation-tasks-sidebar,
|
||||
.game-resource-generation-tasks-handle {
|
||||
.game-resource-generation-tasks-sidebar.is-leaving {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.game-resource-generation-task-card,
|
||||
.game-resource-generation-tasks-handle,
|
||||
.game-resource-generation-tasks-sidebar-icon-button,
|
||||
.game-resource-generation-task-badge::before,
|
||||
.game-resource-generation-task-locate {
|
||||
@@ -428,8 +369,9 @@
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.game-resource-generation-task-card:hover,
|
||||
.game-resource-generation-tasks-handle:hover {
|
||||
.game-resource-generation-task-card:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -5521,6 +5521,15 @@ iframe.preview-frame {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* 左侧控件组:「资源管理 / 运行」分段 + 紧贴其后的「播放」。
|
||||
* 整组在工具条里左对齐,取代播放按钮原先的居中悬浮(absolute + translateX(-50%))。 */
|
||||
.game-workbench-view-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.game-workbench-tabs,
|
||||
.game-workbench-view-actions {
|
||||
display: flex;
|
||||
@@ -5557,7 +5566,9 @@ iframe.preview-frame {
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
/* 左侧控件组里的按钮(目前只有「播放」)与分段控件、右侧动作区共用同一套基础外观。 */
|
||||
.game-workbench-tabs button,
|
||||
.game-workbench-view-tabs button,
|
||||
.game-workbench-view-actions button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -5576,6 +5587,7 @@ iframe.preview-frame {
|
||||
}
|
||||
|
||||
.game-workbench-tabs button:focus-visible,
|
||||
.game-workbench-view-tabs button:focus-visible,
|
||||
.game-workbench-view-actions button:focus-visible,
|
||||
.game-workbench-approval-trigger:focus-visible,
|
||||
.game-agent-dock-more:focus-visible {
|
||||
@@ -5607,16 +5619,16 @@ iframe.preview-frame {
|
||||
color: var(--platform-button-secondary-text);
|
||||
}
|
||||
|
||||
.game-workbench-view-actions .game-workbench-play-button {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
/* 「播放」与「资源管理 / 运行」同组,外观仍按主按钮走;禁用时退回次级按钮。 */
|
||||
.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: translateX(-50%);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.game-workbench-view-actions .game-workbench-play-button:disabled {
|
||||
.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);
|
||||
@@ -9129,11 +9141,6 @@ iframe.preview-frame {
|
||||
.game-workbench-view-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.game-workbench-view-actions .game-workbench-play-button {
|
||||
position: static;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
|
||||
@@ -4650,6 +4650,58 @@ export default function ProjectDevelopmentView({
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* 把画布视口居中到某张资源卡:定位链在「选中了新资源」之后还必须让用户**看得见**它。
|
||||
*
|
||||
* 这张画布是 transform 平移的,卡不在 DOM 滚动容器里,`scrollIntoView()` 只能碰到既有的
|
||||
* 带滚动条祖先,卡在视口外时它不会把卡带回来——所以视口必须由这里显式改。缩放保持不变
|
||||
* (只平移),与 `ensureResourceBookContentVisible` 的既有口径一致:定位不改用户的缩放预期。
|
||||
*/
|
||||
const centerResourceCanvasOnResource = useCallback(
|
||||
(resourceId: string) => {
|
||||
const category: ResourceBookTarget | null = resourceBookOpensAllResources
|
||||
? RESOURCE_BOOK_ALL_TARGET
|
||||
: activePageCategory;
|
||||
if (!category || resourceBookView !== 'child') {
|
||||
return;
|
||||
}
|
||||
const position = resourcePositionById.get(resourceId);
|
||||
if (!position) {
|
||||
return;
|
||||
}
|
||||
const cardSize = resourceCardSizeByResourceId.get(resourceId);
|
||||
if (!cardSize) {
|
||||
return;
|
||||
}
|
||||
const canvasSize = resourceCanvasElementSize(resourceCanvasRef.current);
|
||||
if (canvasSize.width <= 0 || canvasSize.height <= 0) {
|
||||
return;
|
||||
}
|
||||
const viewport = normalizeResourceBookViewport(
|
||||
category === RESOURCE_BOOK_ALL_TARGET
|
||||
? resourceBookAllViewportRef.current
|
||||
: resourceCanvasViewportRef.current,
|
||||
);
|
||||
setResourceCanvasViewport(category, {
|
||||
scale: viewport.scale,
|
||||
x:
|
||||
canvasSize.width / 2 -
|
||||
(position.x + cardSize.width / 2) * viewport.scale,
|
||||
y:
|
||||
canvasSize.height / 2 -
|
||||
(position.y + cardSize.height / 2) * viewport.scale,
|
||||
});
|
||||
},
|
||||
[
|
||||
activePageCategory,
|
||||
resourceBookOpensAllResources,
|
||||
resourceBookView,
|
||||
resourceCardSizeByResourceId,
|
||||
resourcePositionById,
|
||||
setResourceCanvasViewport,
|
||||
],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.code === 'Space' && !event.repeat) {
|
||||
@@ -5748,10 +5800,14 @@ export default function ProjectDevelopmentView({
|
||||
pendingResourceFocusRef.current = null;
|
||||
setResourceWorkbenchNotice('');
|
||||
setHiddenCommittedResourceId(null);
|
||||
// 既有的 scrollIntoView 只对带滚动条的祖先有效;这张画布靠 transform 平移,视口必须显式居中,
|
||||
// 否则「定位过去了但素材还在屏幕外」——用户看到的仍是没定位。
|
||||
centerResourceCanvasOnResource(intent.resourceId);
|
||||
card.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
|
||||
handleResourceSelect(intent.resourceId);
|
||||
}, [
|
||||
activePageCategory,
|
||||
centerResourceCanvasOnResource,
|
||||
dependencyLayout.layout.positions,
|
||||
dependencyLayout.settled,
|
||||
handleResourceSelect,
|
||||
@@ -5767,6 +5823,39 @@ export default function ProjectDevelopmentView({
|
||||
visibleResourceIds,
|
||||
]);
|
||||
|
||||
/**
|
||||
* 「生成任务」侧栏失去焦点即自动收起:点画布、点别的工具栏按钮、点对话框都算失去焦点。
|
||||
*
|
||||
* 两处必须排除,否则会把用户刚做的动作吃掉:
|
||||
* - 侧栏内部:点任务卡、点「定位到素材」都不能收起侧栏;
|
||||
* - 工具条上那枚开合按钮:它自己负责 toggle,若在这里也被判成「点外部」就会先收起再被 toggle
|
||||
* 打开,表现为按钮失灵。点它时这里的 pointerdown 直接放行,收尾交给它的 click。
|
||||
*
|
||||
* 用 pointerdown 而不是 click:画布空白处的左键 pointerdown 会 `preventDefault()`,掐掉指针的
|
||||
* 兼容鼠标事件,document 上的 click 收不到那一次点击。
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!resourceAssetGenerationTasksPanelOpen) {
|
||||
return undefined;
|
||||
}
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
target.closest('.game-resource-generation-tasks-sidebar') ||
|
||||
target.closest('[data-resource-generation-task-toggle]')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setResourceAssetGenerationTasksPanelOpen(false);
|
||||
};
|
||||
document.addEventListener('pointerdown', handlePointerDown, true);
|
||||
return () =>
|
||||
document.removeEventListener('pointerdown', handlePointerDown, true);
|
||||
}, [resourceAssetGenerationTasksPanelOpen]);
|
||||
|
||||
/**
|
||||
* 切换当前版本只改「哪个版本是当前版本」这条记录层状态,并让运行模块重新加载当前预览。
|
||||
*
|
||||
@@ -7414,6 +7503,31 @@ export default function ProjectDevelopmentView({
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* 「生成任务」开合入口。**两个页签下都常驻**:侧栏本体是无条件渲染的非模态浮层,运行态一样
|
||||
* 可见,入口若只在资源页签,用户切到运行后关掉侧栏就再也打不开了。资源页签里它排在
|
||||
* 「依赖 / 类型」排列方式之前(动作在前、排列方式收在行尾),所以这里做成一个可复用的元素,
|
||||
* 由两处分支各自渲染一次——同一时刻只渲染一处,可访问名唯一。
|
||||
*/
|
||||
const generationTasksEntry = (
|
||||
<button
|
||||
type="button"
|
||||
className="game-workbench-resource-panel-button"
|
||||
aria-label="生成任务"
|
||||
aria-expanded={resourceAssetGenerationTasksPanelOpen}
|
||||
data-resource-generation-task-toggle=""
|
||||
data-resource-generation-task-count={resourceAssetGenerationInFlightCount}
|
||||
onClick={() =>
|
||||
setResourceAssetGenerationTasksPanelOpen((current) => !current)
|
||||
}
|
||||
>
|
||||
<ListChecks size={15} aria-hidden="true" />
|
||||
{resourceAssetGenerationInFlightCount > 0
|
||||
? `生成任务 · ${resourceAssetGenerationInFlightCount}`
|
||||
: '生成任务'}
|
||||
</button>
|
||||
);
|
||||
|
||||
if (planningStartMode) {
|
||||
return (
|
||||
<section
|
||||
@@ -7468,35 +7582,40 @@ export default function ProjectDevelopmentView({
|
||||
}
|
||||
>
|
||||
<div className="game-workbench-toolbar">
|
||||
<div className="game-workbench-tabs" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mode === 'resources'}
|
||||
className={mode === 'resources' ? 'is-active' : ''}
|
||||
onClick={() => {
|
||||
advanceFocusGeneration();
|
||||
setMode('resources');
|
||||
}}
|
||||
>
|
||||
资源管理
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mode === 'run'}
|
||||
aria-describedby={
|
||||
showRunUnavailableHint ? 'run-unavailable-hint' : undefined
|
||||
}
|
||||
data-unavailable={!runAvailable || uiEditorRoute || undefined}
|
||||
className={mode === 'run' ? 'is-active' : ''}
|
||||
disabled={uiEditorRoute !== null}
|
||||
onClick={showRunView}
|
||||
>
|
||||
运行
|
||||
</button>
|
||||
</div>
|
||||
<div className="game-workbench-view-actions">
|
||||
{/*
|
||||
「资源管理 / 运行」与「播放」是同一组左侧控件:播放紧贴模式切换之后、整组左对齐,
|
||||
不再居中悬浮。**两个页签下都常驻**——运行视图空态与预览失败态都要靠它重跑
|
||||
(空态文案就是「点击顶部播放按钮后将在这里直接运行游戏」)。
|
||||
*/}
|
||||
<div className="game-workbench-view-tabs">
|
||||
<div className="game-workbench-tabs" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mode === 'resources'}
|
||||
className={mode === 'resources' ? 'is-active' : ''}
|
||||
onClick={() => {
|
||||
advanceFocusGeneration();
|
||||
setMode('resources');
|
||||
}}
|
||||
>
|
||||
资源管理
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mode === 'run'}
|
||||
aria-describedby={
|
||||
showRunUnavailableHint ? 'run-unavailable-hint' : undefined
|
||||
}
|
||||
data-unavailable={!runAvailable || uiEditorRoute || undefined}
|
||||
className={mode === 'run' ? 'is-active' : ''}
|
||||
disabled={uiEditorRoute !== null}
|
||||
onClick={showRunView}
|
||||
>
|
||||
运行
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="game-workbench-play-button"
|
||||
@@ -7512,6 +7631,8 @@ export default function ProjectDevelopmentView({
|
||||
<Play size={15} aria-hidden="true" />
|
||||
播放
|
||||
</button>
|
||||
</div>
|
||||
<div className="game-workbench-view-actions">
|
||||
{mode === 'run' && embeddedPreviewUrl ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -7584,28 +7705,6 @@ export default function ProjectDevelopmentView({
|
||||
<LayoutGrid size={15} aria-hidden="true" />
|
||||
整理画布
|
||||
</button>
|
||||
{/*
|
||||
「生成任务」入口:常驻可见,开合画布上的任务侧栏;有在途任务时带上数量,
|
||||
用户关掉侧栏后一眼就能看出还有几条在跑。
|
||||
*/}
|
||||
<button
|
||||
type="button"
|
||||
className="game-workbench-resource-panel-button"
|
||||
aria-label="生成任务"
|
||||
aria-expanded={resourceAssetGenerationTasksPanelOpen}
|
||||
data-resource-generation-task-count={
|
||||
resourceAssetGenerationInFlightCount
|
||||
}
|
||||
onClick={() =>
|
||||
setResourceAssetGenerationTasksPanelOpen(
|
||||
(current) => !current,
|
||||
)
|
||||
}
|
||||
>
|
||||
<ListChecks size={15} aria-hidden="true" />
|
||||
{resourceAssetGenerationInFlightCount > 0
|
||||
? `生成任务 · ${resourceAssetGenerationInFlightCount}`
|
||||
: '生成任务'} </button>
|
||||
{pendingResourceEdits.length > 0 ||
|
||||
pendingResourceEditsLoadState === 'failed' ? (
|
||||
<button
|
||||
@@ -7627,6 +7726,12 @@ export default function ProjectDevelopmentView({
|
||||
>
|
||||
{resourceLayoutSaving ? '保存中' : resourceLayoutNotice}
|
||||
</span>
|
||||
{/*
|
||||
「生成任务」入口排在「依赖 / 类型」排列方式之前:一个是开合任务侧栏的动作,
|
||||
一个是画布排列方式——动作在前、排列方式收在行尾。运行页签下由下面同一枚
|
||||
元素补渲染(见 `generationTasksEntry`)。
|
||||
*/}
|
||||
{generationTasksEntry}
|
||||
<div
|
||||
className="game-workbench-tabs game-resource-sort-tabs"
|
||||
role="group"
|
||||
@@ -7655,6 +7760,10 @@ export default function ProjectDevelopmentView({
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{/* 运行页签(或 UI 编辑器)下资源类按钮整组收起,但「生成任务」入口必须留着。 */}
|
||||
{mode === 'resources' && !uiEditorRoute
|
||||
? null
|
||||
: generationTasksEntry}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5803,10 +5803,22 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
/\.game-workbench-toolbar\s*\{[^}]*position:\s*relative[^}]*padding:\s*8px 12px[^}]*background:\s*transparent/s,
|
||||
);
|
||||
expect(styles).toMatch(
|
||||
/\.game-workbench-view-actions \.game-workbench-play-button\s*\{[^}]*position:\s*absolute[^}]*left:\s*50%[^}]*transform:\s*translateX\(-50%\)/s,
|
||||
/\.game-workbench-view-tabs\s*\{[^}]*display:\s*flex[^}]*align-items:\s*center/s,
|
||||
);
|
||||
// 播放按钮跟着「资源管理 / 运行」左对齐,不再居中悬浮。
|
||||
expect(styles).toMatch(
|
||||
/\.game-workbench-view-tabs \.game-workbench-play-button\s*\{[^}]*position:\s*static[^}]*transform:\s*none/s,
|
||||
);
|
||||
expect(styles).not.toMatch(
|
||||
/\.game-workbench-play-button\s*\{[^}]*position:\s*absolute/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,
|
||||
);
|
||||
expect(styles).toMatch(
|
||||
/@media \(max-width: 1000px\)[\s\S]*?\.game-workbench-view-actions \.game-workbench-play-button\s*\{[^}]*position:\s*static[^}]*transform:\s*none/s,
|
||||
/\.game-workbench-view-tabs button:focus-visible,\s*\.game-workbench-view-actions button:focus-visible/s,
|
||||
);
|
||||
expect(styles).toMatch(
|
||||
/@media \(min-width: 761px\)[\s\S]*?\.game-project-workbench\s*\{[^}]*grid-template-rows:\s*minmax\(0, 1fr\) auto[^}]*height:\s*100dvh/,
|
||||
@@ -11102,6 +11114,20 @@ export function registerProjectAgentStatusTests() {
|
||||
return screen.findByRole('region', { name: '生成任务' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 收起「生成任务」侧栏后等它真的走完:收起要先播退场动画(`is-leaving`)再卸载,
|
||||
* 所以点完关闭按钮不能直接断言 DOM 里已经没有它。
|
||||
*/
|
||||
async function waitForTasksSidebarLeaveAnimationToFinish() {
|
||||
expect(
|
||||
document.querySelector('.game-resource-generation-tasks-sidebar')
|
||||
?.className,
|
||||
).toContain('is-leaving');
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('region', { name: '生成任务' })).toBeNull(),
|
||||
);
|
||||
}
|
||||
|
||||
function completedGenerationRecord(input: {
|
||||
taskId: string;
|
||||
assetId: string | null;
|
||||
@@ -11186,6 +11212,91 @@ export function registerProjectAgentStatusTests() {
|
||||
expect(screen.queryByText('正在定位生成的素材…')).toBeNull();
|
||||
}, 20_000);
|
||||
|
||||
it('centers the canvas viewport on the located asset so it is actually visible', async () => {
|
||||
// 「定位到素材」只把卡选中不够:这张画布是 transform 平移的,卡在视口外时
|
||||
// `scrollIntoView()` 碰不到任何滚动祖先,用户看到的就是"定位过去了但依然见不到素材"。
|
||||
const panel = await renderGenerationLocateView({
|
||||
projectId: 'workbench-locate-centers-viewport',
|
||||
projectPath: '/tmp/workbench-locate-centers-viewport',
|
||||
openCategory: '角色与对象',
|
||||
assetId: 'locate-centers-asset',
|
||||
ledgerRecords: [
|
||||
completedGenerationRecord({
|
||||
taskId: 'task-locate-centers',
|
||||
assetId: 'locate-centers-asset',
|
||||
projectId: 'workbench-locate-centers-viewport',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// 先把画布量出尺寸:jsdom 里 clientWidth/Height 恒为 0,不量就会静默走"没尺寸不居中"那条分支。
|
||||
// 居中使用的是栏目页画布(`.game-resource-page-canvas`),不是外层 `[aria-label]` 容器。
|
||||
const canvas = (await screen.findByLabelText(
|
||||
'资源依赖视图',
|
||||
)) as HTMLDivElement;
|
||||
const pageCanvas = canvas.querySelector<HTMLElement>(
|
||||
'.game-resource-page-canvas',
|
||||
);
|
||||
expect(pageCanvas).not.toBeNull();
|
||||
Object.defineProperties(pageCanvas!, {
|
||||
clientWidth: { configurable: true, get: () => 800 },
|
||||
clientHeight: { configurable: true, get: () => 600 },
|
||||
});
|
||||
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', { name: '定位素材 定位目标素材' }),
|
||||
);
|
||||
|
||||
const card = await waitFor(() => {
|
||||
const element = document.querySelector<HTMLElement>(
|
||||
'.game-resource-card[data-resource-card-id="asset:locate-centers-asset"]',
|
||||
);
|
||||
expect(element).not.toBeNull();
|
||||
return element!;
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
document.querySelector(
|
||||
'.game-resource-card-select[data-resource-id="asset:locate-centers-asset"][aria-pressed="true"]',
|
||||
),
|
||||
).not.toBeNull(),
|
||||
);
|
||||
|
||||
const readPixels = (name: string) =>
|
||||
Number(
|
||||
/([\d.-]+)px/u.exec(card.style.getPropertyValue(name))?.[1] ?? 'NaN',
|
||||
);
|
||||
const centerX =
|
||||
readPixels('--resource-x') + readPixels('--resource-card-width') / 2;
|
||||
const centerY =
|
||||
readPixels('--resource-y') + readPixels('--resource-card-height') / 2;
|
||||
expect(Number.isFinite(centerX)).toBe(true);
|
||||
expect(Number.isFinite(centerY)).toBe(true);
|
||||
|
||||
const world = document.querySelector<HTMLElement>(
|
||||
'.game-resource-page-canvas[data-resource-section-scroll="character"] [data-resource-viewport]',
|
||||
);
|
||||
const [viewportX, viewportY, viewportScale] = (
|
||||
world?.getAttribute('data-resource-viewport') ?? ''
|
||||
)
|
||||
.split(',')
|
||||
.map(Number);
|
||||
expect(viewportScale).toBeGreaterThan(0);
|
||||
// 视口平移量必须让卡片中心落在画布中心:`viewportX + centerX × scale = 画布宽 / 2`。
|
||||
const canvasWidth = pageCanvas!.clientWidth;
|
||||
const canvasHeight = pageCanvas!.clientHeight;
|
||||
expect(canvasWidth).toBeGreaterThan(0);
|
||||
expect(canvasHeight).toBeGreaterThan(0);
|
||||
expect(viewportX! + centerX * viewportScale!).toBeCloseTo(
|
||||
canvasWidth / 2,
|
||||
0,
|
||||
);
|
||||
expect(viewportY! + centerY * viewportScale!).toBeCloseTo(
|
||||
canvasHeight / 2,
|
||||
0,
|
||||
);
|
||||
}, 20_000);
|
||||
|
||||
it('surfaces the existing clear-search action when the generated asset is filtered out', async () => {
|
||||
const panel = await renderGenerationLocateView({
|
||||
projectId: 'workbench-locate-hidden',
|
||||
@@ -11257,11 +11368,22 @@ export function registerProjectAgentStatusTests() {
|
||||
assetName: string;
|
||||
}) => Promise<unknown>;
|
||||
listRecords?: (started: Map<string, Record<string, unknown>>) => unknown;
|
||||
/** 把首个原型标成已完成,让「运行」页签可切(`runAvailable` 为真)。 */
|
||||
runnable?: boolean;
|
||||
}) {
|
||||
const manifest = createGameCreationAppManifest(
|
||||
input.projectId,
|
||||
'生成提交面板测试',
|
||||
);
|
||||
if (input.runnable) {
|
||||
const codePrototype = manifest.tasks.find(
|
||||
(task) => task.id === 'code-prototype',
|
||||
);
|
||||
if (!codePrototype) {
|
||||
throw new Error('missing code-prototype seed task');
|
||||
}
|
||||
codePrototype.status = 'completed';
|
||||
}
|
||||
manifest.assets = [
|
||||
{
|
||||
id: 'submit-art-spec',
|
||||
@@ -11446,6 +11568,8 @@ export function registerProjectAgentStatusTests() {
|
||||
const panel = await renderAssetGenerationSubmitView({
|
||||
projectId: 'workbench-sidebar-collapsed',
|
||||
projectPath: '/tmp/workbench-sidebar-collapsed',
|
||||
// 运行页签要真的能切,末尾那条「运行态也能重开侧栏」才不是在资源态自证。
|
||||
runnable: true,
|
||||
startLocalAsset: async ({ taskId }) => ({
|
||||
taskId,
|
||||
projectId: 'workbench-sidebar-collapsed',
|
||||
@@ -11485,24 +11609,58 @@ export function registerProjectAgentStatusTests() {
|
||||
|
||||
// 折叠侧栏:折叠只影响这个视图,任务仍在后台推进。
|
||||
fireEvent.click(
|
||||
within(sidebar).getByRole('button', { name: '收起生成任务' }),
|
||||
within(sidebar).getByRole('button', { name: '关闭生成任务' }),
|
||||
);
|
||||
expect(screen.queryByRole('region', { name: '生成任务' })).toBeNull();
|
||||
const handle = screen.getByRole('button', { name: '展开生成任务' });
|
||||
expect(handle.dataset.resourceGenerationTaskCount).toBe('1');
|
||||
// 收口后折叠把手上的在途计数跟着归零 —— 折叠期间进度照常更新。
|
||||
await waitForTasksSidebarLeaveAnimationToFinish();
|
||||
// 画布上不留折叠把手:开合口只剩工具条那一枚「生成任务」按钮。
|
||||
expect(
|
||||
document.querySelector('.game-resource-generation-tasks-handle'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /^生成任务(?: · \d+)?$/ }),
|
||||
).not.toBeNull();
|
||||
// 收口后工具条按钮上的在途计数跟着归零 —— 折叠期间进度照常更新;
|
||||
// 工具条按钮的 aria-label 恒为「生成任务」,所以计数只能查 data 属性,不能查可访问名。
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(
|
||||
screen.getByRole('button', { name: '展开生成任务' }).dataset
|
||||
.resourceGenerationTaskCount,
|
||||
(
|
||||
screen.getByRole('button', {
|
||||
name: /^生成任务(?: · \d+)?$/,
|
||||
}) as HTMLElement
|
||||
).dataset.resourceGenerationTaskCount,
|
||||
).toBe('0'),
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '展开生成任务' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成任务' }));
|
||||
const reopened = await screen.findByRole('region', { name: '生成任务' });
|
||||
expect(within(reopened).getByText('生成已完成。')).not.toBeNull();
|
||||
// 重开后条目内容与在途计数都跟上了收口结果。
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(
|
||||
within(reopened).getByLabelText('在途生成任务 0').textContent,
|
||||
).toBe('0'),
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
expect(within(reopened).getByText('待提交设计图')).not.toBeNull();
|
||||
|
||||
// 入口在「运行」页签下同样常驻:侧栏本体在运行态可见,入口若只在资源页签就没法再打开。
|
||||
fireEvent.click(
|
||||
within(reopened).getByRole('button', { name: '关闭生成任务' }),
|
||||
);
|
||||
await waitForTasksSidebarLeaveAnimationToFinish();
|
||||
fireEvent.click(screen.getByRole('tab', { name: '运行' }));
|
||||
// 先钉住真的切到了运行页签,否则下面那条断言只是在资源态自证。
|
||||
expect(
|
||||
screen.getByRole('tab', { name: '运行' }).getAttribute('aria-selected'),
|
||||
).toBe('true');
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: /^生成任务(?: · \d+)?$/ }),
|
||||
);
|
||||
expect(
|
||||
await screen.findByRole('region', { name: '生成任务' }),
|
||||
).not.toBeNull();
|
||||
}, 20_000);
|
||||
|
||||
it('routes the audio column entries to the existing audio generation chain', async () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { act } from 'react';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
} from '../src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel';
|
||||
import {
|
||||
RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT,
|
||||
RESOURCE_CANVAS_ASSET_GENERATION_TASKS_LEAVE_MILLIS,
|
||||
ResourceCanvasAssetGenerationTasksPanelView,
|
||||
} from '../src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView';
|
||||
import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel';
|
||||
@@ -114,6 +116,9 @@ describe('「生成任务」侧栏', () => {
|
||||
expect(within(activeSection).getByText('正在处理。')).not.toBeNull();
|
||||
expect(within(activeSection).getByText('生成中')).not.toBeNull();
|
||||
|
||||
// 头部标题里的在途计数:四条任务里两条未终态,徽标必须跟着走。
|
||||
expect(screen.getByLabelText('在途生成任务 2').textContent).toBe('2');
|
||||
|
||||
const doneSection = screen.getByRole('region', { name: '已完成' });
|
||||
expect(within(doneSection).getByText('2')).not.toBeNull();
|
||||
expect(within(doneSection).getByText('生成已完成。')).not.toBeNull();
|
||||
@@ -123,13 +128,18 @@ describe('「生成任务」侧栏', () => {
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('展开状态下收起与关闭都走同一个折叠开关', async () => {
|
||||
test('收起只有一个入口:头部那枚关闭按钮,收起即整块让出画布', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onToggleOpen } = renderSidebar([task({ taskId: 't1' })]);
|
||||
await user.click(screen.getByRole('button', { name: '收起生成任务' }));
|
||||
expect(onToggleOpen).toHaveBeenCalledTimes(1);
|
||||
// 底部那枚重复的「关闭」连同它的分割线已经删掉:展开态只有一个关闭按钮。
|
||||
expect(
|
||||
screen.getAllByRole('button', { name: '关闭生成任务' }),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
document.querySelector('.game-resource-generation-tasks-sidebar-footer'),
|
||||
).toBeNull();
|
||||
await user.click(screen.getByRole('button', { name: '关闭生成任务' }));
|
||||
expect(onToggleOpen).toHaveBeenCalledTimes(2);
|
||||
expect(onToggleOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('侧栏高度有界、列表自己滚动,「已完成」条数封顶', () => {
|
||||
@@ -169,30 +179,61 @@ describe('「生成任务」侧栏', () => {
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
test('折叠时只留贴边把手,把手上的在途计数跟着任务走(0 / 2)', async () => {
|
||||
const user = userEvent.setup();
|
||||
const collapsed = renderSidebar([], { open: false });
|
||||
const handle = screen.getByRole('button', { name: '展开生成任务' });
|
||||
expect(handle.getAttribute('aria-expanded')).toBe('false');
|
||||
expect(handle.dataset.resourceGenerationTaskCount).toBe('0');
|
||||
test('折叠后不再渲染,画布上不留任何常驻入口或把手', async () => {
|
||||
renderSidebar([], { open: false });
|
||||
expect(screen.queryByRole('region', { name: '生成任务' })).toBeNull();
|
||||
await user.click(handle);
|
||||
expect(collapsed.onToggleOpen).toHaveBeenCalledTimes(1);
|
||||
cleanup();
|
||||
expect(screen.queryByRole('button')).toBeNull();
|
||||
expect(
|
||||
document.querySelector('.game-resource-generation-tasks-handle'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
document.querySelector('.game-resource-generation-tasks-sidebar'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
const running = task({
|
||||
taskId: 't-running',
|
||||
dispatched: true,
|
||||
status: 'running',
|
||||
phaseDetail: '正在生成。',
|
||||
});
|
||||
const queued = task({ taskId: 't-queued' });
|
||||
const { container } = renderSidebar([running, queued], { open: false });
|
||||
const withTwo = container.querySelector(
|
||||
'button[data-resource-generation-task-count]',
|
||||
) as HTMLElement;
|
||||
expect(withTwo.dataset.resourceGenerationTaskCount).toBe('2');
|
||||
expect(screen.getByLabelText('在途生成任务 2').textContent).toBe('2');
|
||||
test('收起时先播完收起动画再卸载,动画期间列表内容不跳', () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const runningTask = task({ taskId: 't1', assetName: '主界面设计图' });
|
||||
const { rerender } = render(
|
||||
<ResourceCanvasAssetGenerationTasksPanelView
|
||||
tasks={[runningTask]}
|
||||
open
|
||||
onToggleOpen={vi.fn()}
|
||||
onFocusTask={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByRole('region', { name: '生成任务' })).not.toBeNull();
|
||||
|
||||
// 收起的同时把任务收口成已完成:退出动画必须沿用收起前那一份列表,内容不能跳。
|
||||
rerender(
|
||||
<ResourceCanvasAssetGenerationTasksPanelView
|
||||
tasks={[]}
|
||||
open={false}
|
||||
onToggleOpen={vi.fn()}
|
||||
onFocusTask={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const leaving = document.querySelector<HTMLElement>(
|
||||
'.game-resource-generation-tasks-sidebar',
|
||||
);
|
||||
expect(leaving).not.toBeNull();
|
||||
expect(leaving?.className).toContain('is-leaving');
|
||||
expect(within(leaving!).getByText('主界面设计图')).not.toBeNull();
|
||||
|
||||
// 动画时长走完就卸载,画布上不留东西。
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(
|
||||
RESOURCE_CANVAS_ASSET_GENERATION_TASKS_LEAVE_MILLIS,
|
||||
);
|
||||
});
|
||||
expect(
|
||||
document.querySelector('.game-resource-generation-tasks-sidebar'),
|
||||
).toBeNull();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test('只有已完成且拿到资源 id 的任务能定位到素材卡', async () => {
|
||||
|
||||
+40
-20
@@ -122,7 +122,7 @@ describe('「生成任务」侧栏样式', () => {
|
||||
expect(declaration(locate, 'text-decoration')).toContain('underline');
|
||||
});
|
||||
|
||||
test('展开 / 折叠都有过渡,折叠把手是竖向标签 + 在途角标', () => {
|
||||
test('进场动画留在侧栏上,删除的折叠把手不再有样式残留', () => {
|
||||
const sidebar = resolved(['.game-resource-generation-tasks-sidebar']);
|
||||
expect(declaration(sidebar, 'animation')).toContain(
|
||||
'game-resource-generation-tasks-enter',
|
||||
@@ -130,26 +130,49 @@ describe('「生成任务」侧栏样式', () => {
|
||||
expect(declaration(sidebar, 'border-radius')).toBe('0.75rem');
|
||||
expect(declaration(sidebar, 'backdrop-filter')).toBe('blur(10px)');
|
||||
|
||||
const handle = resolved([
|
||||
// 折叠把手与底部关闭条都已删除:对应类名不得再出现在样式表里。
|
||||
for (const removed of [
|
||||
'.game-resource-generation-tasks-handle',
|
||||
'.game-resource-generation-tasks-handle:hover',
|
||||
]);
|
||||
expect(declaration(handle, 'transition')).toContain('transform');
|
||||
expect(declaration(handle, 'transform')).toBe(
|
||||
'translateY(-50%) translateX(0.1rem)',
|
||||
);
|
||||
|
||||
const handleLabel = resolved([
|
||||
'.game-resource-generation-tasks-handle-label',
|
||||
]);
|
||||
expect(declaration(handleLabel, 'writing-mode')).toBe('vertical-rl');
|
||||
|
||||
const handleBadge = resolved([
|
||||
'.game-resource-generation-tasks-handle-badge',
|
||||
'.game-resource-generation-tasks-sidebar-footer',
|
||||
]) {
|
||||
expect(rules.some((rule) => rule.selectors.includes(removed))).toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('收起与进场同源反向:is-leaving 挂上收起动画,减动效下同样关掉', () => {
|
||||
const leaving = resolved([
|
||||
'.game-resource-generation-tasks-sidebar',
|
||||
'.game-resource-generation-tasks-sidebar.is-leaving',
|
||||
]);
|
||||
expect(declaration(handleBadge, 'font-variant-numeric')).toBe(
|
||||
'tabular-nums',
|
||||
const animation = declaration(leaving, 'animation');
|
||||
expect(animation).toContain('game-resource-generation-tasks-leave');
|
||||
expect(animation).toContain('160ms');
|
||||
// 动画期间不该还能点到里面(此刻它在播退场,点它只会在卸载前留下半截状态)。
|
||||
expect(declaration(leaving, 'pointer-events')).toBe('none');
|
||||
expect(
|
||||
rules.some((rule) =>
|
||||
rule.selectors.includes(
|
||||
'.game-resource-generation-tasks-sidebar.is-leaving',
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
// 减少动效:收起动画同样关掉,组件那边会立即卸载,不白等 160ms。
|
||||
const reduced = rules.filter((rule) =>
|
||||
(rule.media ?? '').includes('prefers-reduced-motion'),
|
||||
);
|
||||
expect(
|
||||
reduced.some(
|
||||
(rule) =>
|
||||
rule.selectors.includes(
|
||||
'.game-resource-generation-tasks-sidebar.is-leaving',
|
||||
) && rule.declarations.get('animation') === 'none',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('列表滚动条是细样式,滚动区域有界', () => {
|
||||
@@ -194,9 +217,6 @@ describe('「生成任务」侧栏样式', () => {
|
||||
expect(
|
||||
motionless('.game-resource-generation-tasks-sidebar', 'animation'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
motionless('.game-resource-generation-tasks-handle', 'animation'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
motionless('.game-resource-generation-task-card', 'transition'),
|
||||
).toBe(true);
|
||||
@@ -205,7 +225,7 @@ describe('「生成任务」侧栏样式', () => {
|
||||
test('焦点环可见(键盘可见焦点用平台 token)', () => {
|
||||
const focusRule = rules.find((rule) =>
|
||||
rule.selectors.includes(
|
||||
'.game-resource-generation-tasks-handle:focus-visible',
|
||||
'.game-resource-generation-tasks-sidebar-icon-button:focus-visible',
|
||||
),
|
||||
);
|
||||
expect(focusRule?.declarations.get('outline')).toBe(
|
||||
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
/** @vitest-environment jsdom */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import ProjectDevelopmentView from '../src/view/project-development';
|
||||
import {
|
||||
createGameCreationAppManifest,
|
||||
fireEvent,
|
||||
React,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from './appSurface/harness';
|
||||
|
||||
/**
|
||||
* 「生成任务」侧栏的两条交互口径:
|
||||
*
|
||||
* 1. **失去焦点即收起**:点画布、点别的工具栏按钮、点侧栏外部都算失去焦点;点侧栏内部
|
||||
* (含任务卡与「定位到素材」)与点那枚开合按钮不算——后者自己负责 toggle,否则会先被
|
||||
* 收起再被 toggle 打开,表现为按钮失灵。
|
||||
* 2. **入口顺序**:「生成任务」排在「依赖 / 类型」排列方式之前(动作在前、排列方式收行尾)。
|
||||
*/
|
||||
|
||||
function resourceGraphFor(resources: unknown) {
|
||||
const resourceIds = Array.isArray(resources)
|
||||
? resources.map(
|
||||
(resource) => (resource as { resourceId?: string }).resourceId ?? '',
|
||||
)
|
||||
: [];
|
||||
return {
|
||||
resourceIds,
|
||||
referenceEdges: [],
|
||||
taskFlows: [],
|
||||
categories: [],
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
|
||||
function installInvoke() {
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'read_local_project_resource_graph') {
|
||||
return resourceGraphFor(args?.resources);
|
||||
}
|
||||
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}`);
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
}
|
||||
|
||||
async function renderWorkbench(projectId: string) {
|
||||
installInvoke();
|
||||
const manifest = createGameCreationAppManifest(
|
||||
projectId,
|
||||
`${projectId} 项目`,
|
||||
);
|
||||
render(
|
||||
React.createElement(ProjectDevelopmentView, {
|
||||
projectName: manifest.name,
|
||||
projectPath: `/tmp/${projectId}`,
|
||||
manifest,
|
||||
attachments: [],
|
||||
recentRunStatus: null,
|
||||
recentRunStopReason: null,
|
||||
supervisor: React.createElement('div', null, '项目总控'),
|
||||
onHomeOpen: vi.fn(),
|
||||
onProjectsOpen: vi.fn(),
|
||||
}),
|
||||
);
|
||||
const entry = await screen.findByRole('button', { name: '生成任务' });
|
||||
fireEvent.click(entry);
|
||||
return screen.findByRole('region', { name: '生成任务' });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('「生成任务」侧栏的开合与入口位置', () => {
|
||||
it('点侧栏外部(画布 / 其他区域)自动收起', async () => {
|
||||
const sidebar = await renderWorkbench('workbench-tasks-dismiss-outside');
|
||||
expect(sidebar).not.toBeNull();
|
||||
|
||||
fireEvent.pointerDown(document.body);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('region', { name: '生成任务' })).toBeNull(),
|
||||
);
|
||||
});
|
||||
|
||||
it('点侧栏内部与开合按钮都不收起:内部保持打开,按钮仍能 toggle 收起来', async () => {
|
||||
const sidebar = await renderWorkbench('workbench-tasks-dismiss-inside');
|
||||
// 侧栏内部点击(任务列表区域)不该被当成点外部。
|
||||
fireEvent.pointerDown(sidebar);
|
||||
expect(screen.queryByRole('region', { name: '生成任务' })).not.toBeNull();
|
||||
|
||||
// 开合按钮自己负责 toggle:点一次必须真的收起(不能先被「点外部」收起再被 toggle 打开)。
|
||||
const entry = screen.getByRole('button', { name: '生成任务' });
|
||||
fireEvent.pointerDown(entry);
|
||||
fireEvent.click(entry);
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('region', { name: '生成任务' })).toBeNull(),
|
||||
);
|
||||
|
||||
// 再点一次要能回来。
|
||||
fireEvent.click(entry);
|
||||
expect(
|
||||
await screen.findByRole('region', { name: '生成任务' }),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('「生成任务」入口排在「依赖 / 类型」排列方式之前', async () => {
|
||||
await renderWorkbench('workbench-tasks-entry-order');
|
||||
const actionsRow = document.querySelector('.game-workbench-view-actions');
|
||||
expect(actionsRow).not.toBeNull();
|
||||
const buttons = Array.from(actionsRow!.querySelectorAll('button'));
|
||||
const tasksIndex = buttons.findIndex(
|
||||
(button) => button.getAttribute('aria-label') === '生成任务',
|
||||
);
|
||||
const sortIndex = buttons.findIndex(
|
||||
(button) => button.getAttribute('aria-label') === '按依赖',
|
||||
);
|
||||
expect(tasksIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(sortIndex).toBeGreaterThanOrEqual(0);
|
||||
// 依赖 / 类型收在行尾,生成任务在它前面。
|
||||
expect(tasksIndex).toBeLessThan(sortIndex);
|
||||
expect(sortIndex).toBe(buttons.length - 2);
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,7 @@
|
||||
- [立项策划 Agent(Fast GDD)](<./technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md>):旧 `project-supervisor-plan` / `project-planning` 历史会话的入口、审批和恢复合同;V2 切换时未完成旧会话强制失败。
|
||||
- [GameAgent 资源自由画板与快速编辑](./technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md)
|
||||
- [AGC 栏目画布底部工具栏入口矩阵](./technical/【AGC】栏目画布底部工具栏入口矩阵-2026-09-13.md):栏目画布底部工具栏的入口矩阵、可用条件与验收口径。
|
||||
- [AGC 资源工作台三处交互收口改动前对照图](./technical/assets/agc-resource-workbench-ui-before-20260914/README.md):任务侧栏两个关闭入口、左侧贴边折叠把手、顶部播放按钮居中悬浮三张改动前截图与问题说明。
|
||||
- [AGC 资源派生与非破坏性编辑合同](./technical/【技术方案】AGC资源派生与非破坏性编辑合同-2026-09-09.md):AGC 全类型现有资源非破坏性编辑的权威合同,约束资源派生、替换与写回边界。
|
||||
- [AGC 聊天素材引用](./【功能说明】AGC聊天素材引用-2026-09-08.md):聊天输入框 @ 引用项目素材的入口、引用模型与「当前版本素材」口径。
|
||||
- [AGC 聊天 AI 润色与发送前提醒](./【功能说明】AGC聊天AI润色与发送前提醒-2026-09-10.md):提示词润色与发送前提醒的交互、失败与取消口径。
|
||||
|
||||
@@ -121,7 +121,7 @@
|
||||
- 资源总览的“资源依赖 / 资源类型”视图切换使用连通的分段按钮组,相邻选项共享边界并保持唯一选中语义。每个分段都必须有清晰的键盘焦点指示,焦点环不得被分段容器的圆角或 `overflow` 裁切。
|
||||
- 右侧 Supervisor 对话中,用户消息使用右对齐、最大宽度受限的主题暖色气泡,assistant 消息保持左对齐;消息换行不得产生水平溢出,执行过程卡继续占满消息区可用宽度。消息列表必须约束在右侧对话列内并独立滚动,不得覆盖中央资源或运行视图;提交按钮必须保留随状态变化的可访问名称。气泡正文在 light / dark 平台主题下均须满足 WCAG AA 普通文本 `4.5:1` 对比度。
|
||||
- 客户端正式产品仍只按最小 `1280×720` 横屏合同交付,并保留 `1280×800` 默认窗口与既有基线验收;更窄浏览器样式只负责不崩溃和开发兼容,不改成移动端创作工作台。
|
||||
- 工作台顶部播放按钮在桌面视窗中水平居中;资源管理视窗触发播放时直接切换到运行视窗并启动本地预览,不再弹出 `game.run_local` 二次确认。
|
||||
- 工作台顶部播放按钮紧贴「资源管理 / 运行」模式切换之后、整组左对齐;不再在桌面视窗中水平居中悬浮;两个页签下都常驻(运行视图空态与预览失败态要靠它重跑)。资源管理视窗触发播放时直接切换到运行视窗并启动本地预览,不再弹出 `game.run_local` 二次确认。
|
||||
- 创建模式素材画布的“素材名称”是用户可编辑的正式输出名称;“资源用途”是 manifest subtype,不向普通用户开放自由文本。新增资源默认“普通游戏美术”,可从普通游戏美术、统一视觉规范、游戏界面原型、核心美术图集四项中选择。图片精修继承源名称和用途,不显示创建模式保存设置;候选图片只从选中图片的“设为最终图”提交。精修顶栏只保留返回、导入、定位当前最终图、撤销和重做,删除进入图片上下文工具栏,通用 AI 生成只保留给创建模式。图片输出统一使用 PNG;创建模式工具动作与保存设置分层展示,“保存到项目”在 `1280×800` 和窄容器中都必须完整可见。
|
||||
- 首页创作输入区与“最近项目”之间不展示共享项目状态文本,“最近项目”标题下也不追加解释性副标题;默认、成功、进行中或失败状态均不得在该位置形成文字行,项目管理页继续保留自己的状态反馈。
|
||||
|
||||
@@ -155,7 +155,7 @@
|
||||
- 位置与层级:画布左下角(`left: 14px; bottom: 14px; z-index: 40`)。右下角是既有的缩放 / 撤销 Dock(`right: 14px; bottom: 14px`),左下角是画布上唯一两者都不占的稳定空位。工具栏是管理区 `.game-resource-book-manager` 的**直接子节点**、与画本场景并列,不进带 `scale()` 的场景层;二级菜单与「入口不可用原因」都贴着工具栏上沿弹出,不做内嵌内容。
|
||||
- 外壳用共享 chrome(`packages/image-canvas-react` 的 `CanvasToolbar / CanvasToolbarGroup / CanvasChromeButton`),样式落在 AGC 的 `resourceCanvasChrome.css`;共享包只承接通用表现,不含业务规则。
|
||||
- 接线:图片类入口走本地 IPC `start_local_project_asset_generation`(`kind` ∈ `image / character / spec / icon-spec / ui-prototype / art-spritesheet`;**提交即返回任务记录**,生成由 Rust 后台任务跑完写回项目,进度用 `list_local_project_asset_generations` 读回项目内账本 `.agent/runtime/asset-generation-tasks/tasks.json`);音频入口复用既有的无源生成链路 `derive_local_project_resource`(`generationMode: 'create'`、`editKind` = `sound-effect` / `background-music`);「上传」复用 `upload_local_asset`。生成 / 上传成功后一律用「配对读 `(revision, manifest)`」交给 `onManifestChange`,走既有 manifest 刷新与资源投影链路,不重算依赖图、不另写布局。
|
||||
- 本地排队与进度可见:AGC 本地 durable 输出槽已按**精确动作指纹**分槽(不同 prompt / 素材名各自独立成槽,具备并行能力),但本批前端仍按「同一时刻只派发一条」排队——真并行派发需要并发收口设计(配对读 + manifest CAS + 聚焦意图互不覆盖),留待下一批;所以第一条未终态时第二条提交停在**前端本地队列**里(不调用提交 IPC,显示本地排队的「排队中。」),前一条终态后自动补发;任务状态与阶段文案(后端 `phaseDetail`)由任务账本提供,前端不拼阶段、不做百分比。进度面是**画布上常驻的可折叠任务侧栏**(形态对齐网页端美术画布的任务侧栏):折叠时留贴边把手并在把手上显示在途数量,展开是两个分栏「排队/生成中」与「已完成」(各带条数,「已完成」封顶 20 条 + 列表滚动 + 高度有界);每项显示状态徽标 / 阶段文案 / 已耗时 / 素材名,可「定位到素材」。侧栏非模态(不铺全屏遮罩、不做焦点陷阱、不参与模态遮挡判据),位置在画布左侧标题栏之下、**覆盖式**(不 reflow 挤窄画布视口),提交受理后自动展开。定位动作**每次点击都终局化**:能定位就定位并选中;素材在别的栏目先切栏目;不在投影里给「素材已不在项目里 / 已登记但尚未同步」的结论;3 秒内有界兜底,不允许提示条永久停在「正在定位生成的素材…」。
|
||||
- 本地排队与进度可见:AGC 本地 durable 输出槽已按**精确动作指纹**分槽(不同 prompt / 素材名各自独立成槽,具备并行能力),但本批前端仍按「同一时刻只派发一条」排队——真并行派发需要并发收口设计(配对读 + manifest CAS + 聚焦意图互不覆盖),留待下一批;所以第一条未终态时第二条提交停在**前端本地队列**里(不调用提交 IPC,显示本地排队的「排队中。」),前一条终态后自动补发;任务状态与阶段文案(后端 `phaseDetail`)由任务账本提供,前端不拼阶段、不做百分比。进度面是**画布上常驻的可折叠任务侧栏**(形态对齐网页端美术画布的任务侧栏):展开是两个分栏「排队/生成中」与「已完成」(各带条数,「已完成」封顶 20 条 + 列表滚动 + 高度有界),关闭入口只保留头部那一枚 ×(底部重复的关闭按钮与其分割线已删除)、折叠即整块让出画布且不留贴边把手,开合只走工具条上常驻的「生成任务 · N」按钮(两个页签下都在);每项显示状态徽标 / 阶段文案 / 已耗时 / 素材名,可「定位到素材」。侧栏非模态(不铺全屏遮罩、不做焦点陷阱、不参与模态遮挡判据),位置在画布左侧标题栏之下、**覆盖式**(不 reflow 挤窄画布视口),提交受理后自动展开。定位动作**每次点击都终局化**:能定位就定位并选中;素材在别的栏目先切栏目;不在投影里给「素材已不在项目里 / 已登记但尚未同步」的结论;3 秒内有界兜底,不允许提示条永久停在「正在定位生成的素材…」。
|
||||
- 面板形态:独立浮层(`ThemedModal`),**不在当前面板下面追加内容**;面板内不写功能说明或规则解释文案。**点「生成」即同步关闭面板**(不等 IPC、不等排队、不等生成),面板里**不出现**「排队中。」「正在生成。」「提交中…」这类阶段文案——阶段文案的唯一去处是任务侧栏与工具栏提示条。**只有「点击瞬间就失败」**(校验不过、权限拒绝、start IPC 立即报错)才自动重开面板并带回草稿与原因;**受理之后才失败**只在侧栏把该任务收口为失败 + 原因,不重开面板。关闭 ≠ 取消请求(请求挂在任务与账本上,不挂在面板生命周期上)。
|
||||
- 参数口径:比例 / 尺寸选项来自网页端美术画布的纯模型(`src/components/image-editor/ImageCanvasGenerationModel.ts`),并按本地 IPC 白名单收窄(本地通道明确拒绝 `4:3`);默认档 `1:1 · 1K`,生成 UI 设计图沿用网页端 UI 设计面板的默认 `16:9 · 1K`。本地 IPC 没有 `model` 入参,因此面板**不渲染模型选择器**(渲染一个改不了请求的控件就是假控件)。
|
||||
- 规范类和固定档:生成规范下的图标规范 / 角色规范 / 自定义规范都是固定档——只读展示当前规格,不给点了不生效的比例控件。本地 IPC 只有一条规范通道(`spec` 在 Rust 侧收口到 `icon-spec`),**没有 `specType` 入参**,因此角色规范与自定义规范共用该通道,靠素材名与提示词区分。
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
|
||||
- 背景(验收人在真机上连报三条):① 提交按钮上渲染了后端 `phaseDetail`,「生成图片」的主按钮变成写着「排队中。」的状态胶囊;② 提交后提交面板不关、一直占着屏幕等生成,用户原话「不要显示排队中,点生成直接把窗口藏起来啊,你留个窗口意义何在」;③ 任务进度面是一个工具条按钮 + 非模态浮层,跟网页端美术画布的任务侧栏不是一个形态,用户原话「你把美术画布的照抄过来都不会吗」;④ 「定位到素材」点了没反应,提示条永久停在「正在定位生成的素材…」。
|
||||
- 决策(提交面板):**点「生成」即同步关闭面板**——不等 IPC、不等排队、不等生成;面板内**不出现**任何阶段文案(主按钮文案恒为动作名)。**只有「点击瞬间就失败」**(后端校验 / 权限拒绝 / start IPC 立即报错)才自动重开面板并带回草稿与原因;**受理之后才失败**只在任务侧栏把该任务收口为失败 + 原因,不重开面板。关闭 ≠ 取消(请求挂在任务与项目内账本上,不挂在面板生命周期上)。
|
||||
- 决策(进度面形态):改为**常驻画布的可折叠任务侧栏**(对齐网页端 `ImageCanvasTaskSidebarView`)——折叠时留贴边把手并在把手上显示在途数量;展开是两个分栏「排队/生成中」与「已完成」(各带条数,「已完成」封顶 20 条 + 提示「仅显示最近 N 条」);每项显示状态徽标 / 后端阶段文案 / 已耗时(前端 1s 计时)/ 素材名 + 「定位到素材」;侧栏非模态(不铺遮罩、不做焦点陷阱、不进模态遮挡判据),工具栏入口按钮常驻并显示 `生成任务 · N`,提交受理后自动展开。**原先的非模态浮层形态已删除,不留平行入口。**
|
||||
- 决策(进度面形态):改为**常驻画布的可折叠任务侧栏**(对齐网页端 `ImageCanvasTaskSidebarView`)——展开是两个分栏「排队/生成中」与「已完成」(各带条数,「已完成」封顶 20 条 + 提示「仅显示最近 N 条」),关闭入口只保留头部那一枚 ×(底部重复的关闭按钮与其 border-top 分割线已删除);折叠即整块让出画布、**不留贴边把手**;每项显示状态徽标 / 后端阶段文案 / 已耗时(前端 1s 计时)/ 素材名 + 「定位到素材」;侧栏非模态(不铺遮罩、不做焦点陷阱、不进模态遮挡判据),工具栏入口按钮是**唯一**开合口、常驻(资源管理 / 运行两个页签都在)并显示 `生成任务 · N`,提交受理后自动展开。**原先的非模态浮层形态已删除,不留平行入口。**
|
||||
- 决策(侧栏失去焦点即收起):展开时挂 document 级 `pointerdown` 捕获监听,点在侧栏内部与工具条那枚开合按钮以外的地方即收起。两处必须排除——**侧栏内部**(点任务卡、点「定位到素材」不能收起侧栏)与**开合按钮本身**(它自己负责 toggle,若也被判成「点外部」就会先收起再被 toggle 打开,表现为按钮失灵;按钮带 `data-resource-generation-task-toggle` 标记供排除)。用 `pointerdown` 而不是 `click`:画布空白处的左键 pointerdown 会 `preventDefault()`,document 上的 click 收不到那一次点击。
|
||||
- 决策(收起动画):收起不能瞬间卸载——进场有动画而消失没有,观感上是"闪一下没了"。做法是**组件自己留一帧播退场**:`open` 变 false 后进入 `leaving`,根节点挂 `is-leaving` 播 `…-leave`(与进场同向反向,160ms,`pointer-events: none`),播完(或减动效偏好的 0ms 定时)才 `setPhase('idle')` 卸载。时长在组件与 CSS 两处各写一次,**必须一致**(组件导出 `RESOURCE_CANVAS_ASSET_GENERATION_TASKS_LEAVE_MILLIS` 并在用例里钉住)。退场期间**沿用收起前那一份列表**(`lastRenderedRef`):宿主会在同一帧里收起侧栏并把在途任务收口成已完成,直接吃新 props 会让退场动画里的内容跳一下;空态分支也必须读这份冻结快照,不能读实时 `ordered`。
|
||||
- 决策(定位必须让素材真的可见):`handleResourceSelect` 只改选中、不会移动画布,而这张画布是 **transform 平移**的、资源卡不在任何滚动容器里——`card.scrollIntoView()` 碰不到滚动祖先,卡在视口外时"定位过去了但依然见不到素材"。所以聚焦链在选中之后必须**显式把画布视口居中到该卡**(`centerResourceCanvasOnResource`:读该卡在当前位置表里的 `x/y` 与 `resourceCardSizeByResourceId` 的尺寸,按 `canvasSize / 2 − 卡中心 × scale` 求平移量,**缩放保持不变**,与 `ensureResourceBookContentVisible` 的既有口径一致:定位不改用户的缩放预期)。`scrollIntoView` 一并保留(栏目页仍有带滚动条的祖先)。
|
||||
- 决策(侧栏位置):挂在画布**左侧、标题栏之下、工具栏之上**,宽 300px、**覆盖式**(不 reflow 挤窄画布视口)。理由:右侧已被「智能创作」对话面板占用、顶部是栏目标题栏、底部是栏目工具栏;覆盖式不触碰画布视口数学与资源卡排布,收起即完全让出画布。若产品要求「画布被挤窄」的 flex 兄弟列形态(网页端是那种),需要改 `game-resource-book-manager` 那段布局并单独排期。
|
||||
- 决策(定位终局化):根因是聚焦 effect 的依赖全是画布自身状态,**手动点定位不改其中任何一项** → effect 不重跑、`pendingResourceFocusRef` 无人消费、提示条永久停在中转文案。修法:新增聚焦请求序号并加入 effect 依赖;handler 重写为「能定位就定位并选中;素材在别的栏目先切栏目;不在投影里给『素材已不在项目里 / 已登记但尚未同步』的结论;挂 intent 后推进序号 + **3 秒有界兜底**;intent 被判 invalid 时也给『定位请求已失效』」,并修掉「已聚焦过」提前返回分支不清提示的同类问题(自动落卡那条链同源)。
|
||||
- 影响范围:`src/features/resource-canvas/{ResourceCanvasAssetGenerationPanelView.tsx,ResourceCanvasGenerationPanelView.tsx,ResourceCanvasAssetGenerationTasksPanelView.tsx,resourceCanvasAssetGenerationTaskModel.ts,resourceCanvasAssetGenerationQueue.ts}`、`src/view/project-development/index.tsx`,测试 `tests/{resourceCanvasAssetGenerationBackgroundClose.test.tsx,resourceCanvasAssetGenerationTasksPanel.test.tsx,resourceCanvasBottomToolbar.test.tsx,appSurface/project-development.suite.ts}`。**未改** IPC 形状与 Rust 生成通道、未改本地排队语义(仍单条在途)、未改 `packages/**`。
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 287 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 315 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 317 KiB |
@@ -0,0 +1,15 @@
|
||||
# AGC 资源工作台三处交互收口:改动前对照图(2026-09-14)
|
||||
|
||||
本目录是三处 UI 收口改动(`fix/agc-resource-workbench-chrome`)的改动前截图,供 PR 与后续回归对照使用。三张图都取自改动前的客户端「资源管理」工作台。
|
||||
|
||||
| 文件 | 画面 | 改动前的问题 |
|
||||
| --- | --- | --- |
|
||||
| `01-task-sidebar-two-close-entries.jpg` | 「生成任务」侧栏展开态(图内红框标出两处待处理对象) | 头部右上角是一枚 `‹`(`ChevronLeft`,`aria-label="收起生成任务"`),底部 footer 里还有一枚 `×`(`aria-label="关闭生成任务"`),中间由 footer 的 `border-top` 分割线隔开——同一个 `onToggleOpen` 有两个入口,且头部那枚是「返回」语义 |
|
||||
| `02-left-rail-collapse-handle.jpg` | 资源管理工作台(图内红框标出左侧贴边竖条) | 侧栏折叠后在画布左侧留一条竖排文字「生成任务」把手,常驻遮挡画布 |
|
||||
| `03-toolbar-play-centered.jpg` | 资源管理工作台(图内红框标出顶部工具条) | 顶部「播放」按钮靠 `position: absolute; left: 50%; transform: translateX(-50%)` 居中悬浮,与它左边的「资源管理 / 运行」模式切换脱节 |
|
||||
|
||||
改动后的形态、验收判据与验证方式见当次 PR 描述与 commit message;相关文档口径已同步到:
|
||||
|
||||
- [`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`](../../../prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md)(顶部播放按钮、任务侧栏进度面两条)
|
||||
- [`docs/technical/【AGC】栏目画布底部工具栏入口矩阵-2026-09-13.md`](../【AGC】栏目画布底部工具栏入口矩阵-2026-09-13.md)
|
||||
- [`docs/project-memory/shared-memory/decision-log.md`](../../../project-memory/shared-memory/decision-log.md)(进度面形态决策)
|
||||
@@ -50,7 +50,7 @@
|
||||
|
||||
- **本地排队**:AGC 本地 durable 输出槽按**精确动作指纹**分槽(`run_id = slot-<sha256(动作材料)>`,材料为 prompt / outputPath / 比例 / 尺寸 / assetKind / assetLabel / replaceExisting / requireSlices;不同 prompt 或素材名各自独立成槽,不再按 `outputPath` 共用;旧 `{outputPath,requireSlices}` 槽账本由同一精确动作懒迁移)。因此后端已具备并行能力,但**本批前端仍按「同一时刻只派发一条」排队**——真并行派发需要并发收口设计(配对读 + manifest CAS + 聚焦意图互不覆盖),留待下一批。所以第二条提交停在**前端本地队列**里(不调用提交 IPC,阶段显示本地排队的「排队中。」),第一条终态后由同一条循环自动补发;判据是「存在 `dispatched && 未终态` 的任务时不派发下一条」。
|
||||
- **阶段文案只来自后端,且只出现在任务侧栏**:派发之后由任务侧栏渲染后端账本给的 `phaseDetail`(「排队中。」/「正在生成。」/「生成已完成。」/失败原因),提交面板已关闭、不渲染任何阶段文案;前端不拼阶段、不做百分比。
|
||||
- **「生成任务」是常驻画布的可折叠侧栏**(形态对齐网页端美术画布的任务侧栏):折叠时留贴边把手并在把手上显示在途数量;展开是两个分栏「排队/生成中」与「已完成」(各带条数,「已完成」封顶 20 条 + 列表滚动 + 高度有界);位置在画布左侧标题栏之下、**覆盖式**(不 reflow 挤窄画布视口)。侧栏非模态(不铺全屏遮罩、不做焦点陷阱、**不参与** `isResourceCanvasFloatingPanelOpen` / `resourceCanvasHostGenerationPanelOpen` 的遮挡判据),提交受理后自动展开;工具栏入口按钮常驻并显示 `生成任务 · N`。每项的「定位到素材」**每次点击都终局化**(含跨栏目先切栏目、不在投影里给结论、3 秒有界兜底),不允许提示条永久停在「正在定位生成的素材…」。
|
||||
- **「生成任务」是常驻画布的可折叠侧栏**(形态对齐网页端美术画布的任务侧栏):展开是两个分栏「排队/生成中」与「已完成」(各带条数,「已完成」封顶 20 条 + 列表滚动 + 高度有界),关闭入口只保留头部那一枚 ×(底部重复的关闭按钮与其分割线已删除);折叠即整块让出画布、**不留贴边把手**;**收起与进场同源反向播一遍动画**(`…-leave`,160ms,播完才卸载;减动效下立即卸载);**失去焦点(点侧栏外部,不含那枚开合按钮本身)即自动收起**;位置在画布左侧标题栏之下、**覆盖式**(不 reflow 挤窄画布视口)。侧栏非模态(不铺全屏遮罩、不做焦点陷阱、**不参与** `isResourceCanvasFloatingPanelOpen` / `resourceCanvasHostGenerationPanelOpen` 的遮挡判据),提交受理后自动展开;工具栏入口按钮是**唯一**开合口、**两个页签(资源管理 / 运行)下都常驻**、排在「依赖 / 类型」排列方式**之前**(动作在前、排列方式收行尾),并显示 `生成任务 · N`。每项的「定位到素材」**每次点击都终局化**(含跨栏目先切栏目、不在投影里给结论、3 秒有界兜底),不允许提示条永久停在「正在定位生成的素材…」;定位成功后必须**把画布视口居中到该卡**——这张画布是 transform 平移的,`scrollIntoView()` 碰不到滚动祖先,只改选中会让用户"定位过去了但依然见不到素材"。
|
||||
|
||||
## 5. 参数口径与复用程度
|
||||
|
||||
|
||||
Reference in New Issue
Block a user