合并最新master并解决AGC回归冲突
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled

合入远程master的资源菜单收纳与开发监听优化
保留抠图恢复展示和空快照验证,整合旧请求失效保护
统一生图测试切片契约并恢复资源预览图标导入
This commit is contained in:
2026-09-17 21:30:58 +08:00
35 changed files with 1329 additions and 385 deletions
@@ -0,0 +1,113 @@
import assert from 'node:assert/strict';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { test } from 'node:test';
import { setTimeout as delay } from 'node:timers/promises';
import { fileURLToPath } from 'node:url';
import { createServer, loadConfigFromFile, normalizePath } from 'vite';
test(
'AGC 排除 Rust 构建目录且保留源码与共享组件监听',
{ timeout: 30_000 },
async () => {
const loaded = await loadConfigFromFile(
{ command: 'serve', mode: 'development' },
fileURLToPath(new URL('../vite.config.ts', import.meta.url)),
);
assert.ok(loaded);
assert.notEqual(loaded.config.server?.watch, null);
assert.notEqual(loaded.config.server?.hmr, false);
assert.ok(
[loaded.config.server?.watch?.ignored]
.flat()
.includes('**/src-tauri/target/**'),
);
const fixture = await mkdtemp(join(tmpdir(), 'agc-vite-watch-'));
const root = join(fixture, 'apps', 'ai-game-creator-shell');
const source = join(root, 'src', 'main.js');
const css = join(root, 'src', 'styles.css');
const shared = join(fixture, 'packages', 'shared', 'src', 'component.js');
const target = join(root, 'src-tauri', 'target');
const artifact = join(target, 'debug', 'incremental', 'cache.bin');
let server;
try {
for (const file of [source, css, shared, artifact]) {
await mkdir(dirname(file), { recursive: true });
await writeFile(
file,
file === css ? 'body { color: red; }' : 'export default 1;',
);
}
// 使用真实 Vite watcher 和实际配置,仅将扫描根替换为小型夹具;
// 不加载业务插件、后端或原生窗口,也不扫描开发机上的大型 target。
server = await createServer({
configFile: false,
envFile: false,
root,
logLevel: 'silent',
server: {
watch: loaded.config.server?.watch,
middlewareMode: true,
hmr: false,
fs: { allow: [fixture] },
},
optimizeDeps: { noDiscovery: true, include: [] },
});
const waitForWatchedFile = async (file) => {
const normalized = normalizePath(file);
for (let attempt = 0; attempt < 100; attempt += 1) {
if (
Object.entries(server.watcher.getWatched()).some(
([directory, names]) =>
names.some(
(name) => normalizePath(join(directory, name)) === normalized,
),
)
)
return;
await delay(50);
}
assert.fail(`源码必须仍被监听:${normalized}`);
};
await waitForWatchedFile(source);
// 真实模块转换应将 root 外的共享源码加入监听。
await server.transformRequest(`/@fs/${normalizePath(shared)}`);
for (const file of [source, css, shared]) {
const normalized = normalizePath(file);
await waitForWatchedFile(file);
const changed = new Promise((resolve, reject) => {
const timer = setTimeout(() => {
server.watcher.off('change', onChange);
reject(new Error(`未收到源码变更:${normalized}`));
}, 5_000);
function onChange(path) {
if (normalizePath(path) !== normalized) return;
clearTimeout(timer);
server.watcher.off('change', onChange);
resolve();
}
server.watcher.on('change', onChange);
});
await writeFile(
file,
file === css ? 'body { color: blue; }' : 'export default 2;',
);
await changed;
}
const targetPath = normalizePath(target);
const targetDirectories = Object.keys(server.watcher.getWatched())
.map(normalizePath)
.filter(
(path) => path === targetPath || path.startsWith(`${targetPath}/`),
);
assert.deepEqual(targetDirectories, [], 'Rust target 不应创建目录监听器');
} finally {
await server?.close();
await rm(fixture, { recursive: true, force: true });
}
},
);
@@ -728,16 +728,17 @@ async fn background_agent_runtime_can_generate_platform_art_asset() {
"tool": "canvas.asset_generate",
"reason": "生成可用于首版原型的主角素材",
"input": {
"prompt": "透明 PNG 像素月光主角,适合厨房弹幕游戏",
"prompt": "透明 PNG 像素月光主角图集,按 2 行 2 列等分网格排布,适合厨房弹幕游戏",
"outputPath": "assets/art-spritesheet.png",
"aspectRatio": "1:1",
"imageSize": "1K",
"assetKind": "art-spritesheet",
"assetLabel": "游戏首版核心美术素材",
"replaceExisting": false,
"sliceMode": "grid",
"gridX": 2,
"gridY": 2,
"replaceExisting": false
"sliceCount": null
}
}
],
@@ -778,7 +779,7 @@ async fn background_agent_runtime_can_generate_platform_art_asset() {
start_game_creator_agent_background_task_at(
&root,
"art-asset-plan",
"为月光厨房生成首版主角素材",
"为月光厨房生成首版主角素材图集,按 2 行 2 列等分网格排布",
"art-generate-run",
)
.expect("start background task");
@@ -936,10 +937,18 @@ async fn background_agent_runtime_can_generate_platform_art_asset() {
let generation_idempotency_key =
request_header(generation_request, "idempotency-key").expect("generation idempotency key");
assert!(uuid::Uuid::parse_str(&generation_idempotency_key).is_ok());
let generation_body: Value = serde_json::from_str(
generation_request
.split_once("\r\n\r\n")
.expect("generation request body")
.1,
)
.expect("generation request json");
assert_eq!(generation_body["sliceMode"], "grid");
assert_eq!(generation_body["gridX"], 2);
assert_eq!(generation_body["gridY"], 2);
assert!(generation_body["sliceCount"].is_null());
assert!(generation_request.contains(r#""source":"ai-game-creator-client""#));
assert!(generation_request.contains(r#""sliceMode":"grid""#));
assert!(generation_request.contains(r#""gridX":2"#));
assert!(generation_request.contains(r#""gridY":2"#));
assert_eq!(
canvas_requests
.iter()
@@ -6931,6 +6931,10 @@ fn agent_native_function_catalog_exposes_each_runtime_tool_with_core_schemas() {
canvas_asset.parameters["properties"]["input"]["properties"]["imageSize"]["enum"],
serde_json::json!(["0.5K", "1K", "2K", null])
);
assert_eq!(
canvas_asset.parameters["properties"]["input"]["properties"]["sliceMode"]["enum"],
serde_json::json!(["connected-components", "grid", null])
);
assert_eq!(
canvas_asset.parameters["properties"]["input"]["properties"]["assetKind"]["enum"],
serde_json::json!([
@@ -46,6 +46,7 @@ export function useDirectActiveTurns({
* 面板就是这么被反复重发布的)。这里只在内容真的变了才更新状态。
*/
const lastSnapshotSignatureRef = useRef<string>('[]');
const requestGenerationRef = useRef(0);
const retryTimerRef = useRef<number | null>(null);
useEffect(() => {
@@ -60,13 +61,16 @@ export function useDirectActiveTurns({
}, []);
const refreshActiveTurns = useCallback(async () => {
if (!invoke) {
if (!enabled || !invoke) {
return;
}
// 单飞:轮询与"回合刚开始/刚结束"的主动刷新不叠成两个在途请求。
if (inFlightRef.current) {
return inFlightRef.current;
}
const generation = requestGenerationRef.current;
const isCurrent = () =>
mountedRef.current && generation === requestGenerationRef.current;
const request = (async () => {
for (
let attempt = 1;
@@ -77,7 +81,7 @@ export function useDirectActiveTurns({
const turns = await invoke<GameCreatorDirectActiveTurn[]>(
'list_game_creator_direct_active_turns',
);
if (!mountedRef.current) {
if (!isCurrent()) {
return;
}
const nextTurns = Array.isArray(turns) ? turns : [];
@@ -90,6 +94,7 @@ export function useDirectActiveTurns({
inFlightRef.current = null;
return;
} catch {
if (!isCurrent()) return;
if (attempt < DIRECT_ACTIVE_TURNS_READ_ATTEMPTS) {
await new Promise<void>((resolve) => {
retryTimerRef.current = window.setTimeout(() => {
@@ -97,18 +102,19 @@ export function useDirectActiveTurns({
resolve();
}, DIRECT_ACTIVE_TURNS_READ_RETRY_DELAY_MS * attempt);
});
if (!isCurrent()) return;
}
}
}
// 三次都读不到:保留上一份快照(读不到不等于没有在跑),只标记"本次没读到"。
if (mountedRef.current) {
if (isCurrent()) {
setSnapshotReadFailed(true);
inFlightRef.current = null;
}
inFlightRef.current = null;
})();
inFlightRef.current = request;
return request;
}, [invoke]);
}, [enabled, invoke]);
useEffect(() => {
if (!enabled || !invoke) {
@@ -123,7 +129,12 @@ export function useDirectActiveTurns({
() => void refreshActiveTurns(),
Math.max(1_000, pollIntervalMs),
);
return () => window.clearInterval(timer);
return () => {
window.clearInterval(timer);
// 停用或切换读取器后,旧请求不得覆盖新状态,也不能占住新一轮单飞。
requestGenerationRef.current += 1;
inFlightRef.current = null;
};
}, [enabled, invoke, pollIntervalMs, refreshActiveTurns]);
return { activeTurns, refreshActiveTurns, snapshotReadFailed };
@@ -49,7 +49,7 @@ export function isResourceCanvasPanTarget(
return Boolean(
target.closest('.game-resource-card') &&
!target.closest(
'button:not(.game-resource-card-select), input, textarea, select, a, audio, video',
'button:not(.game-resource-card-select), [role="button"], input, textarea, select, a, audio, video',
),
);
}
-27
View File
@@ -7502,33 +7502,6 @@ iframe.preview-frame {
visibility: visible;
}
.game-resource-card-type-badge {
position: absolute;
top: 8px;
right: 8px;
z-index: 2;
display: inline-flex;
max-width: calc(100% - 16px);
align-items: center;
min-width: 0;
padding: 4px 8px;
overflow: hidden;
border: 1px solid rgb(255 255 255 / 72%);
border-radius: 999px;
background: rgb(75 48 38 / 84%);
color: #fff;
font-size: 10px;
font-weight: 900;
line-height: 1;
letter-spacing: 0.02em;
pointer-events: none;
text-overflow: ellipsis;
white-space: nowrap;
box-shadow: 0 8px 18px rgb(96 62 47 / 20%);
transform: scale(var(--genarrative-image-canvas-inverse-scale, 1));
transform-origin: top right;
}
/* 替换血缘标注本次会话内有效源素材卡已被 替换/ 替换素材卡替换自
左下角是卡片上唯一空闲的角右上角标是类型右下是媒体播放钮当前版本同一支橙色
把这条关系与光环联系起来 */
@@ -14,6 +14,7 @@ import {
CanvasChromeButton,
SelectionOverlay,
} from '@genarrative/image-canvas-react';
import { CanvasCardCornerActions } from '@genarrative/shared/components';
import { save as saveNativeFileDialog } from '@tauri-apps/plugin-dialog';
import {
AtSign,
@@ -715,6 +716,9 @@ const ResourceCard = memo(function ResourceCard({
cardSize,
activeMediaIdentity,
onSelect,
onShowInfo,
onChangeType,
infoPressed,
onPointerDown,
onPointerMove,
onPointerUp,
@@ -744,6 +748,9 @@ const ResourceCard = memo(function ResourceCard({
cardSize: ResourceCanvasCardSize;
activeMediaIdentity: string | null;
onSelect: (resourceId: string, options?: { append?: boolean }) => void;
onShowInfo: (resource: ProjectResource) => void;
onChangeType: (assetId: string) => void;
infoPressed: boolean;
onPointerDown: (
event: ReactPointerEvent<HTMLDivElement>,
resource: ProjectResource,
@@ -1171,13 +1178,19 @@ const ResourceCard = memo(function ResourceCard({
<span className="game-resource-card-visual" aria-hidden="true">
{visual}
</span>
<span
className="game-resource-card-type-badge"
data-resource-type={cardTypeLabel}
title={cardTypeLabel}
>
{cardTypeLabel}
</span>
<CanvasCardCornerActions
kindLabel={cardTypeLabel}
kindAriaLabel={`素材类型:${resource.label}`}
kindClassName="game-resource-card-type-badge"
infoLabel={`查看${resource.label}资源信息`}
infoPressed={infoPressed}
onKindClick={
resource.manifestAssetId
? () => onChangeType(resource.manifestAssetId!)
: undefined
}
onInfoClick={() => onShowInfo(resource)}
/>
{lineage ? (
// 文字是给人看的关系,`data-resource-lineage` 是给端到端验收的稳定判据
// (稳定 id 见卡上的 `data-resource-replaced-by` / `data-resource-replacement-of`)。
@@ -1703,7 +1716,12 @@ export default function ProjectDevelopmentView({
const [characterAnimationPanel, setCharacterAnimationPanel] =
useState<CharacterAnimationPanelState | null>(null);
/** 画布上的只读信息浮层(「信息」动作的落点),与运行页签的信息面板同源。 */
const [resourceInfoPanelOpen, setResourceInfoPanelOpen] = useState(false);
const [resourceInfoResourceId, setResourceInfoResourceId] = useState<
string | null
>(null);
const resourceInfoPanelOpen =
resourceInfoResourceId !== null &&
selectedResourceIds[0] === resourceInfoResourceId;
const [resourceCanvasMarquee, setResourceCanvasMarquee] =
useState<CanvasMarqueeState | null>(null);
/** 资源卡组织操作历史:只回滚布局坐标,不回滚素材。 */
@@ -2016,7 +2034,7 @@ export default function ProjectDevelopmentView({
*/
const clearResourceCanvasFocus = useCallback(() => {
setSelectedResourceIds([]);
setResourceInfoPanelOpen(false);
setResourceInfoResourceId(null);
if (!canDismissResourceCanvasQuickEdit(quickEditPanelRef.current)) {
return;
}
@@ -2440,7 +2458,9 @@ export default function ProjectDevelopmentView({
*
*/
useEffect(() => {
setResourceInfoPanelOpen(false);
setResourceInfoResourceId((current) =>
current === selectedResourceId ? current : null,
);
}, [selectedResourceId]);
const projectVersions = useMemo(
() => manifest.versions ?? [],
@@ -6369,6 +6389,16 @@ export default function ProjectDevelopmentView({
const showRunUnavailableHint = !runAvailable && !uiEditorRoute;
const showResourceCardInfo = useCallback(
(resource: ProjectResource) => {
handleResourceSelect(resource.id);
setResourceInfoResourceId((current) =>
current === resource.id ? null : resource.id,
);
},
[handleResourceSelect],
);
const renderResourceBookCard = useCallback(
(
resource: ProjectResource,
@@ -6426,6 +6456,9 @@ export default function ProjectDevelopmentView({
}
activeMediaIdentity={activeCardMediaIdentity}
onSelect={handleResourceSelect}
onShowInfo={showResourceCardInfo}
onChangeType={setResourceTypeAssetId}
infoPressed={resourceInfoResourceId === resource.id}
onPointerDown={(event) =>
handleResourceCardPointerDown(
event,
@@ -6457,6 +6490,8 @@ export default function ProjectDevelopmentView({
handleResourceCardPointerMove,
handleResourceCardPointerUp,
handleResourceSelect,
showResourceCardInfo,
resourceInfoResourceId,
resourceCardDragPreview,
resourceCardPreviews,
resourceReplacementLineageBadgeMap,
@@ -8097,6 +8132,7 @@ export default function ProjectDevelopmentView({
) ||
selectedResourceOpensUiEditor) ? (
<ImageCanvasSelectedLayerToolbarView
maxVisibleActions={5}
selectedLayer={selectedResourceLayer}
selectedToolbarStyle={selectedToolbarStyle}
supportedActions={selectedToolbarActions}
@@ -8197,20 +8233,6 @@ export default function ProjectDevelopmentView({
<span></span>
</CanvasChromeButton>
) : null}
{selectedResource ? (
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
label="信息"
title="信息"
pressed={resourceInfoPanelOpen}
icon={<Info className="h-4 w-4" />}
onClick={() =>
setResourceInfoPanelOpen((open) => !open)
}
>
<span></span>
</CanvasChromeButton>
) : null}
{selectedResource?.manifestAssetId ? (
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
@@ -8226,21 +8248,6 @@ export default function ProjectDevelopmentView({
<span></span>
</CanvasChromeButton>
) : null}
{selectedResource?.manifestAssetId ? (
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
label="素材类型"
title="素材类型"
icon={<Shapes className="h-4 w-4" />}
onClick={() =>
setResourceTypeAssetId(
selectedResource.manifestAssetId,
)
}
>
<span></span>
</CanvasChromeButton>
) : null}
{selectedResource?.manifestAssetId ? (
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
@@ -8438,12 +8445,12 @@ export default function ProjectDevelopmentView({
const assetId =
selectedResource.manifestAssetId;
if (!assetId) return;
setResourceInfoPanelOpen(false);
setResourceInfoResourceId(null);
setResourceTypeAssetId(assetId);
}
: undefined
}
onClose={() => setResourceInfoPanelOpen(false)}
onClose={() => setResourceInfoResourceId(null)}
/>
) : null}
</div>
@@ -411,9 +411,7 @@ export function registerClientHomeTests() {
await openResourceBookCategory('UI 交互');
expect(await findResourceSelectButton('live-hero.png')).not.toBeNull();
await openResourceBookCategory('项目版本');
expect(
await screen.findByRole('button', { name: /版本 1/ }),
).not.toBeNull();
expect(await findResourceSelectButton('版本 1')).not.toBeNull();
expect(runButton.getAttribute('data-unavailable')).toBeNull();
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith(
@@ -582,9 +580,7 @@ export function registerClientHomeTests() {
}),
).not.toBeNull();
await openResourceBookCategory('项目版本');
expect(
await screen.findByRole('button', { name: /版本 1/ }),
).not.toBeNull();
expect(await findResourceSelectButton('版本 1')).not.toBeNull();
expect(runButton.getAttribute('data-unavailable')).toBeNull();
await waitFor(() => {
expect(
@@ -3836,21 +3836,12 @@ export function registerProjectWorkbenchFoundationTests() {
];
await openResourceBookCategory('角色与对象');
fireEvent.click(await findResourceSelectButton('hero.png'));
const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' });
const toolbarLabels = within(toolbar)
.getAllByRole('button')
.map((button) => button.getAttribute('aria-label') ?? '');
const infoButton = within(toolbar).getByRole('button', { name: '信息' });
// 位置固定在「引用」之后、「编辑标签」之前:工具条上的顺序即功能顺序。
expect(toolbarLabels.indexOf('信息')).toBeGreaterThan(
toolbarLabels.indexOf('引用资源 hero.png'),
);
expect(toolbarLabels.indexOf('信息')).toBeLessThan(
toolbarLabels.indexOf('编辑标签'),
);
const infoButton = await screen.findByRole('button', {
name: '查看hero.png资源信息',
});
expect(infoButton.getAttribute('aria-pressed')).toBe('false');
// 未选中的卡片直接打开信息,不被选中变化 effect 立即关闭。
fireEvent.click(infoButton);
const canvasPanel = await screen.findByRole('dialog', {
name: '资源信息',
@@ -3882,11 +3873,11 @@ export function registerProjectWorkbenchFoundationTests() {
// Esc 与快速编辑浮层同一口径:既收浮层也清选中,整个工具条一起收起。
fireEvent.click(await findResourceSelectButton('hero.png'));
const reopenedToolbar = await screen.findByRole('toolbar', {
await screen.findByRole('toolbar', {
name: '图片工具栏',
});
fireEvent.click(
within(reopenedToolbar).getByRole('button', { name: '信息' }),
screen.getByRole('button', { name: '查看hero.png资源信息' }),
);
expect(
await screen.findByRole('dialog', { name: '资源信息' }),
@@ -4094,10 +4085,7 @@ export function registerProjectWorkbenchFoundationTests() {
),
).toBe(false);
// 音频资源的选中工具条复用美术画布的音频分支(aria-label「素材工具栏」),
// 并且只渲染宿主编排层真实接通的动作:「引用」(从卡片挪进工具条的引用入口,
// 资源卡上的圆钮已删除)「信息」(只读信息浮层)「编辑标签」(面板只编辑 manifest
// `assets[].tags`)「素材类型」(功能分类的独立入口,与标签面板分家)「重命名」
// 已接面板「删除素材」(破坏性动作放末位,前置共享分隔线,复用素材删除流程)
// 并且只渲染宿主编排层真实接通的五个动作;信息与类型由卡片角标承接。
// 「导出」复用资源面板同一条落盘链路,「改造」在宿主编排层仍是空回调,
// 不能再渲染成点了没反应的按钮。
const audioToolbar = screen.getByRole('toolbar', {
@@ -4110,15 +4098,7 @@ export function registerProjectWorkbenchFoundationTests() {
within(audioToolbar)
.getAllByRole('button')
.map((button) => button.getAttribute('aria-label')),
).toEqual([
'引用资源 bgm.mp3',
'信息',
'编辑标签',
'素材类型',
'重命名',
'导出',
'删除素材',
]);
).toEqual(['引用资源 bgm.mp3', '编辑标签', '重命名', '导出', '删除素材']);
// 工具条的「导出」必须真的走通落盘链路:原生保存对话框 + Rust 分块复制,
// 而不是只渲染一个按钮。原生对话框由入口文件 mock 成"用户选了
@@ -100,6 +100,59 @@ describe('useDirectActiveTurns', () => {
clearTimeoutSpy.mockRestore();
}
});
it('停用后晚到的非空快照不能恢复活动回合,手动刷新也不发请求', async () => {
let complete!: (turns: GameCreatorDirectActiveTurn[]) => void;
const invoke = vi.fn(
() =>
new Promise<GameCreatorDirectActiveTurn[]>((resolve) => {
complete = resolve;
}),
);
const { result, rerender } = renderHook(
({ enabled }) =>
useDirectActiveTurns({ invoke: invoke as never, enabled }),
{ initialProps: { enabled: true } },
);
rerender({ enabled: false });
const empty = result.current.activeTurns;
await act(async () => {
complete([ACTIVE_TURN]);
await result.current.refreshActiveTurns();
});
expect(result.current.activeTurns).toBe(empty);
expect(result.current.snapshotReadFailed).toBe(false);
expect(invoke).toHaveBeenCalledTimes(1);
});
it('重新启用后读取新快照,旧请求晚到不能覆盖新快照', async () => {
let completeOld!: (turns: GameCreatorDirectActiveTurn[]) => void;
const invoke = vi
.fn()
.mockImplementationOnce(
() =>
new Promise<GameCreatorDirectActiveTurn[]>((resolve) => {
completeOld = resolve;
}),
)
.mockResolvedValue([{ ...ACTIVE_TURN, runId: 'new-run' }]);
const { result, rerender } = renderHook(
({ enabled }) =>
useDirectActiveTurns({ invoke: invoke as never, enabled }),
{ initialProps: { enabled: true } },
);
rerender({ enabled: false });
rerender({ enabled: true });
await waitFor(() =>
expect(result.current.activeTurns[0]?.runId).toBe('new-run'),
);
const current = result.current.activeTurns;
await act(async () => {
completeOld([ACTIVE_TURN]);
});
expect(result.current.activeTurns).toBe(current);
expect(invoke).toHaveBeenCalledTimes(2);
});
});
describe('ActiveProjectRunsPanel', () => {
@@ -1952,9 +1952,8 @@ describe('project resource live canvas integration', () => {
await openResourceBookCategory('角色与对象');
expect(await cardBadgeText('hero.png')).toBe('角色与对象');
fireEvent.click(await findResourceSelectButton('hero.png'));
const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' });
fireEvent.click(within(toolbar).getByRole('button', { name: '素材类型' }));
// 未选中资源也能直接从卡片类型角标进入,不依赖工具栏存在。
fireEvent.click(screen.getByRole('button', { name: '素材类型:hero.png' }));
const dialog = await screen.findByRole('dialog', {
name: '设置素材类型',
});
@@ -2031,7 +2030,7 @@ describe('project resource live canvas integration', () => {
const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' });
// 类型面板:先点外部(DOM 上落在画布管理区之外),浮层与选中都不受影响。
fireEvent.click(within(toolbar).getByRole('button', { name: '素材类型' }));
fireEvent.click(screen.getByRole('button', { name: '素材类型hero.png' }));
await screen.findByRole('dialog', { name: '设置素材类型' });
fireEvent.click(document.body);
expect(screen.getByRole('dialog', { name: '设置素材类型' })).not.toBeNull();
@@ -2111,7 +2110,9 @@ describe('project resource live canvas integration', () => {
await openResourceBookCategory('角色与对象');
fireEvent.click(await findResourceSelectButton('hero.png'));
const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' });
fireEvent.click(within(toolbar).getByRole('button', { name: '信息' }));
fireEvent.click(
screen.getByRole('button', { name: '查看hero.png资源信息' }),
);
const infoPanel = await screen.findByRole('dialog', { name: '资源信息' });
// 分类值本身仍是只读文本(`dd` 里只有值,入口按钮在它外面)。
@@ -48,6 +48,7 @@ describe('resourceCanvasFocusModel', () => {
<div class="game-resource-card" id="card">
<button class="game-resource-card-select" id="select"></button>
<button class="game-resource-card-media-control" id="play"></button>
<span role="button" id="corner">信息</span>
<video id="video"></video><input id="input" />
</div>
<div contenteditable="true" id="editor"></div>
@@ -60,6 +61,7 @@ describe('resourceCanvasFocusModel', () => {
}
for (const id of [
'play',
'corner',
'video',
'input',
'editor',
@@ -375,6 +375,16 @@ function renderReplacementWorkbench(options: RenderOptions = {}) {
return { invoke, onActiveVersionChange, onPlay, onManifestChange };
}
function toolbarAction(toolbar: HTMLElement, name: string) {
const visible = within(toolbar).queryByRole('button', { name });
if (visible) return visible;
fireEvent.mouseEnter(within(toolbar).getByRole('button', { name: '更多' }));
return within(screen.getByRole('group', { name: '更多操作' })).getByRole(
'button',
{ name },
);
}
async function selectCardAndOpenToolbar(label: string) {
await waitFor(() =>
expect(
@@ -538,14 +548,80 @@ function installResourceCardIntersectionObserver() {
}
describe('版本级资源替换', () => {
it('更多浮层滚轮不平移画布,Escape 只收菜单且换选不残留', async () => {
renderReplacementWorkbench();
const toolbar = await selectCardAndOpenToolbar('legacy.png');
fireEvent.mouseEnter(within(toolbar).getByRole('button', { name: '更多' }));
const menu = screen.getByRole('group', { name: '更多操作' });
const viewport = () =>
document
.querySelector('[data-resource-viewport]')
?.getAttribute('data-resource-viewport');
const before = viewport();
expect(before).toBeTruthy();
const wheel = new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
deltaY: 120,
});
act(() => {
menu.dispatchEvent(wheel);
});
expect(wheel.defaultPrevented).toBe(false);
expect(viewport()).toBe(before);
fireEvent.keyDown(document.body, { key: 'Escape' });
expect(screen.queryByRole('group', { name: '更多操作' })).toBeNull();
expect(screen.getByRole('toolbar', { name: '图片工具栏' })).toBe(toolbar);
fireEvent.mouseEnter(within(toolbar).getByRole('button', { name: '更多' }));
fireEvent.click(await findResourceSelectButton('late.png'));
expect(screen.queryByRole('group', { name: '更多操作' })).toBeNull();
const scene = document.querySelector('.game-resource-book-scene')!;
act(() => {
scene.dispatchEvent(
new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
deltaY: 120,
clientX: 90,
clientY: 70,
}),
);
});
expect(viewport()).not.toBe(before);
});
it('信息从未选中卡打开并跟随资源身份,普通换选会关闭', async () => {
renderReplacementWorkbench();
await selectCardAndOpenToolbar('legacy.png');
const lateInfo = screen.getByRole('button', {
name: '查看late.png资源信息',
});
fireEvent.pointerDown(lateInfo, { button: 0 });
fireEvent.click(lateInfo);
const panel = screen.getByRole('dialog', { name: '资源信息' });
expect(within(panel).getByText('late.png')).toBeTruthy();
expect(lateInfo.getAttribute('aria-pressed')).toBe('true');
fireEvent.click(
screen.getByRole('button', { name: '查看legacy.png资源信息' }),
);
const switched = screen.getByRole('dialog', { name: '资源信息' });
expect(within(switched).getByText('legacy.png')).toBeTruthy();
expect(within(switched).queryByText('late.png')).toBeNull();
fireEvent.click(await findResourceSelectButton('late.png'));
expect(screen.queryByRole('dialog', { name: '资源信息' })).toBeNull();
});
it('入口只在素材被当前版本绑定时渲染,未绑定素材不给假按钮', async () => {
const { invoke } = renderReplacementWorkbench();
// 未被初始版本绑定的素材(版本创建之后才登记):工具条照常出现,但没有「替换素材」。
const lateToolbar = await selectCardAndOpenToolbar('late.png');
expect(
within(lateToolbar).queryByRole('button', { name: '替换素材' }),
).toBeNull();
fireEvent.mouseEnter(
within(lateToolbar).getByRole('button', { name: '更多' }),
);
expect(screen.queryByRole('button', { name: '替换素材' })).toBeNull();
expect(
within(lateToolbar).getByRole('button', { name: '快速编辑' }),
).not.toBeNull();
@@ -559,9 +635,7 @@ describe('版本级资源替换', () => {
// 被当前版本绑定的素材:入口出现。
const sourceToolbar = await selectCardAndOpenToolbar('legacy.png');
expect(
within(sourceToolbar).getByRole('button', { name: '替换素材' }),
).not.toBeNull();
expect(toolbarAction(sourceToolbar, '替换素材')).not.toBeNull();
});
it('从入口一路走到写入:候选弹窗禁用硬门禁项、给出格式提示、直接替换且不产生新版本', async () => {
@@ -569,7 +643,7 @@ describe('版本级资源替换', () => {
renderReplacementWorkbench();
const toolbar = await selectCardAndOpenToolbar('legacy.png');
fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' }));
fireEvent.click(toolbarAction(toolbar, '替换素材'));
await waitFor(() =>
expect(
@@ -675,7 +749,7 @@ describe('版本级资源替换', () => {
});
const toolbar = await selectCardAndOpenToolbar('legacy.png');
fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' }));
fireEvent.click(toolbarAction(toolbar, '替换素材'));
const dialog = await screen.findByRole('dialog', {
name: '选择替换素材',
});
@@ -709,7 +783,7 @@ describe('版本级资源替换', () => {
});
const toolbar = await selectCardAndOpenToolbar('legacy.png');
fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' }));
fireEvent.click(toolbarAction(toolbar, '替换素材'));
await waitFor(() =>
expect(
@@ -735,7 +809,7 @@ describe('版本级资源替换', () => {
const { invoke } = renderReplacementWorkbench();
const toolbar = await selectCardAndOpenToolbar('legacy.png');
fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' }));
fireEvent.click(toolbarAction(toolbar, '替换素材'));
const dialog = await screen.findByRole('dialog', {
name: '选择替换素材',
});
@@ -787,7 +861,7 @@ describe('版本级资源替换', () => {
).length;
const toolbar = await selectCardAndOpenToolbar('legacy.png');
fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' }));
fireEvent.click(toolbarAction(toolbar, '替换素材'));
const dialog = await screen.findByRole('dialog', {
name: '选择替换素材',
});
@@ -837,9 +911,13 @@ describe('版本级资源替换', () => {
});
const toolbar = await selectCardAndOpenToolbar('legacy.png');
const toolbarLabels = within(toolbar)
.getAllByRole('button')
.map((button) => button.getAttribute('aria-label') ?? '');
const deleteButton = toolbarAction(toolbar, '删除素材');
const toolbarLabels = [
...within(toolbar).getAllByRole('button'),
...within(screen.getByRole('group', { name: '更多操作' })).getAllByRole(
'button',
),
].map((button) => button.getAttribute('aria-label') ?? '');
// 末位:在最后一个非破坏性动作(替换素材)之后、共享导出按钮之前。
expect(toolbarLabels.indexOf('删除素材')).toBeGreaterThan(
toolbarLabels.indexOf('替换素材'),
@@ -848,9 +926,6 @@ describe('版本级资源替换', () => {
toolbarLabels.indexOf('导出'),
);
// 与前面隔开:紧邻的前一个兄弟就是共享工具条那套分隔线,不是新造的分隔符。
const deleteButton = within(toolbar).getByRole('button', {
name: '删除素材',
});
const divider = deleteButton.previousElementSibling;
expect(divider?.getAttribute('class')).toMatch(
/(?:image-canvas-editor__floating-toolbar-divider|genarrative-image-canvas__chrome-button)/,
@@ -914,7 +989,7 @@ describe('版本级资源替换', () => {
});
const toolbar = await selectCardAndOpenToolbar('legacy.png');
fireEvent.click(within(toolbar).getByRole('button', { name: '删除素材' }));
fireEvent.click(toolbarAction(toolbar, '删除素材'));
const dialog = await screen.findByRole('dialog', { name: '确认删除资源' });
fireEvent.click(
within(dialog).getByRole('checkbox', {
@@ -976,7 +1051,7 @@ describe('版本级资源替换', () => {
).toBeNull();
// 同一条工具条仍在(只读动作不受 manifest 身份影响),证明不是"整条工具条没渲染"。
expect(
within(toolbar).getByRole('button', { name: '信息' }),
screen.getByRole('button', { name: '查看草稿.png资源信息' }),
).not.toBeNull();
});
@@ -1056,7 +1131,7 @@ describe('版本级资源替换', () => {
const { invoke } = renderReplacementWorkbench();
const toolbar = await selectCardAndOpenToolbar('legacy.png');
fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' }));
fireEvent.click(toolbarAction(toolbar, '替换素材'));
const dialog = await screen.findByRole('dialog', {
name: '选择替换素材',
});
@@ -1084,7 +1159,7 @@ describe('版本级资源替换', () => {
const { invoke, onManifestChange } = renderReplacementWorkbench();
const toolbar = await selectCardAndOpenToolbar('legacy.png');
fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' }));
fireEvent.click(toolbarAction(toolbar, '替换素材'));
const dialog = await screen.findByRole('dialog', {
name: '选择替换素材',
});
@@ -1155,7 +1230,7 @@ describe('版本级资源替换', () => {
});
const toolbar = await selectCardAndOpenToolbar('legacy.png');
fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' }));
fireEvent.click(toolbarAction(toolbar, '替换素材'));
const dialog = await screen.findByRole('dialog', {
name: '选择替换素材',
});
@@ -1238,7 +1313,7 @@ describe('版本级资源替换', () => {
renderReplacementWorkbench();
const toolbar = await selectCardAndOpenToolbar('legacy.png');
fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' }));
fireEvent.click(toolbarAction(toolbar, '替换素材'));
const dialog = await screen.findByRole('dialog', {
name: '选择替换素材',
});
@@ -1274,7 +1349,7 @@ describe('版本级资源替换', () => {
const { invoke } = renderReplacementWorkbench();
const toolbar = await selectCardAndOpenToolbar('legacy.png');
fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' }));
fireEvent.click(toolbarAction(toolbar, '替换素材'));
const dialog = await screen.findByRole('dialog', {
name: '选择替换素材',
});
@@ -1356,9 +1431,7 @@ describe('版本级资源替换', () => {
// 第一次:legacy → final。
const legacyToolbar = await selectCardAndOpenToolbar('legacy.png');
fireEvent.click(
within(legacyToolbar).getByRole('button', { name: '替换素材' }),
);
fireEvent.click(toolbarAction(legacyToolbar, '替换素材'));
let dialog = await screen.findByRole('dialog', {
name: '选择替换素材',
});
@@ -1376,9 +1449,7 @@ describe('版本级资源替换', () => {
// 第二次:final → final.webp(同一个工作台会话内)。
const finalToolbar = await selectCardAndOpenToolbar('final.png');
fireEvent.click(
within(finalToolbar).getByRole('button', { name: '替换素材' }),
);
fireEvent.click(toolbarAction(finalToolbar, '替换素材'));
dialog = await screen.findByRole('dialog', { name: '选择替换素材' });
fireEvent.click(
within(dialog).getByRole('option', { name: '选择替换素材final.webp' }),
@@ -84,7 +84,7 @@ it('真实窗口与工作台状态同步收敛,回调读取最新处理器且
await act(async () => {
await Promise.resolve();
});
// 无原生 invoke 时保持初始空快照,不额外发布相同状态
// 无原生 invoke 时空快照引用不变,只发布一次
expect(publications).toHaveLength(1);
expect(cleanups).toBe(0);
expect(new Set(publications.map((item) => item.onOpenProject)).size).toBe(
@@ -154,6 +154,9 @@ export default defineConfig({
host: '127.0.0.1',
port: 3080,
strictPort: true,
watch: {
ignored: ['**/src-tauri/target/**'],
},
fs: {
allow: [repoRoot],
},
@@ -0,0 +1,16 @@
# AGC 资源菜单收纳实施计划
对应:[里程碑](./【里程碑】AGC资源菜单收纳-2026-09-17.md)Issue #409,产品已确认方案 A。
## PR #410 CI 修复
以远端合并提交 959beebf 为基线:修复菜单文件 import 排序、Web 角标结构断言、活动回合空快照与晚到请求竞态;窗口发布次数断言对齐稳定快照合同。原生 HTTP scope 检查对齐官方 updater 当前权限,不恢复退役 OSS 白名单;Rust 图集测试补齐显式切片模式与 strict schema 字段,不放宽正式校验。按故障项定向测试后运行前端全套及原生契约检查;Rust 使用独立 target,实际未执行的检查必须单独列出。推送需再次确认。
本地修复验证:`npm test` 342 个文件通过(4137 项通过、37 项跳过),窗口与空快照最后一次定向复验 9 项通过;`lint:eslint`、根目录/AGC 类型检查、原生 contract 检查、Rust fmt、编码、文档索引与 diff 检查通过。Rust 工具目录 schema 用例及后台平台美术生成用例均在 Windows 独立 target 下通过;生成用例同时检查真实 mock 请求中的 grid、2×2 参数与响应匹配。未执行全量 Rust 分片、Linux CI、生产服务或真实客户端手感验收。
1. 在 shared 扩展通用操作收纳及卡片角标控件;共用工具栏只给 AGC 开启 5 项限制,Web 卡片迁移共用角标而不改现有回调。
2. AGC 卡片承接类型和信息,保留当前面板与命令链;信息使用资源身份防止换选竞态。
3. 补工具栏/工作台定向回归,检查禁用、移入、Escape、换选和卡片事件边界。
4. 并行执行定向 Vitest、AGC 类型检查、编码与文档索引检查,再自审整体调用链。
风险:portal 浮层点击外部判定、缩放角标与拖拽冲突、原测试依赖完整工具栏。回滚仅撤销本分支 UI 与文档修改;无数据迁移。首个检查点为组件用例通过,第二个为工作台集成与类型检查。真实客户端未测则明确保留待验收状态。
@@ -0,0 +1,31 @@
# AGC 资源菜单收纳
- Version: 1
- Status: implemented,本地自动化通过,待真实客户端验收
- Date: 2026-09-17
- Parent Spec: ../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md
## 范围与评审
仅调整前端入口和临时浮层状态,不修改资源命令、权限、持久化、后端或 Web 默认菜单行为。主菜单保留前 5 项,其余悬停/点击向上展开;类型与信息下沉卡片。产品已选定方案 A,边界与既有资源操作合同无冲突,按单里程碑实施。对应 Issue #409,已获得创建 Issue 与本地实施授权;推送、PR 与飞书写入仍需单独确认。
## 验收
- 动作顺序、禁用状态和回调保持一致,少于等于 5 项不出现更多。
- 更多支持鼠标移入浮层、点击、键盘、外部关闭与视口约束。
- 卡片类型、信息入口不触发拖拽;未选中卡直接看信息,换选不残留旧信息。
- 定向组件与工作台测试、类型检查、编码/文档索引/diff 检查通过;真实客户端视觉和触摸板手感单独验收。
## 产品结论与验收待办
正式实现采用方案 A;方案 B 不进入工作台,A/B 演示只留在忽略目录供本地参考。
完整工作台回归的 2 项失败已定位为新增信息按钮导致“版本 1”模糊匹配重复,改为精确查询资源选中按钮,完整重跑通过。
## 验收证据
- `appSurface.test.ts`450 项通过、20 项跳过。
- 收纳/卡片、Web 工具栏/卡片、资源类型实时链路、替换/重命名、浮层判据、动作可用性:178 项通过;追加真实工作台「更多滚轮不平移画布、Escape 仅收菜单、换选收起」与「跨卡片信息身份」2 项通过。
- 根目录类型检查与 AGC 类型检查(含 skill-pack / config 检查)通过;编码、文档索引与 diff 检查通过。
- 浏览器中真实组件预览已检查上方展开、执行回调后关闭、卡片信息入口;预览仅用演示数据,不替代真实 AGC 客户端。
- 剩余:真实客户端、原生保存对话框、触摸板操作人工验收。未推送,未创建 PR,未更新飞书。
@@ -4,6 +4,10 @@
`waitFor(() => expect(activeTurns).toEqual([]))` 在 Hook 初始状态就能成功,不能证明首次异步读取已经完成。引用稳定性回归应显式控制 Promise 完成,并同时检查首次空响应与禁用后的引用;快照签名初值必须与初始空数组一致。窗口同步测试应验证未变化状态不重复发布,不能依赖一次多余的空态更新。
## AGC Windows 开发态首次页面加载缓慢
Vite 默认监听应用根下的 Rust `src-tauri/target`,构建产物较多时会创建大量 Windows 文件监听器。AGC 配置通过 `server.watch.ignored: ['**/src-tauri/target/**']` 排除此目录,不关闭业务源码、CSS、共享组件监听或 HMR。排查时区分后端就绪、Vite 扫描和原生窗口首绘;监听目录回归不能代替实机首绘测量,验证入口见本地开发运维文档。
## 2026-09-17 AGC 输入盒的「推理档」弹层被祖先裁切:要放开裁切而不是挪弹层
- **现象**:窄窗口下(视口 ≤1000px 时右侧对话面板只有 280px 宽)点开输入盒右下角的「推理档」,弹层是个**空盒子**:档位文字(默认 / 低 / 中 / 高 / 最高)整片看不见,只剩一个方框。
@@ -35,6 +39,8 @@ JSON 的文本读取分支不等于卡面应该展示原始 State 摘要。卡
工作台向窗口标题栏发布运行项目时,若 effect 依赖普通函数派生的回调,发布 Context 会重新渲染工作台,进而再次发布并清理,形成更新深度循环。转发入口须稳定,并在提交阶段更新实际处理器引用;发布数据变化与卸载清理分开。回归测试必须组合真实窗口 Provider 和工作台消费者,只有独立画布测试无法覆盖这条反馈链;回归时用有界发布次数阻止测试失控。画布快速操作时暴露的更新深度错误,也须检查外层状态同步,不能直接归因于滚轮频率。
活动回合快照的初始签名须与初始空数组一致,首次异步返回空数组不能额外换引用。停用、重新启用或切换读取器时应使旧请求失效,避免晚到结果覆盖新快照;测试需控制 Promise 完成时机,不能用“初始数组已为空”当作请求已结束。无原生读取器时窗口只发布一次空状态。
## 2026-09-17 工具 schema 声明的上限与真实校验不一致,会表现成「agent 调不动这个功能」
- **现象**:用户反馈「客户端没法由 agent 调用图片快速编辑功能以及背景音乐生成功能」。查工具目录时两个工具都在(`agc_edit_image``agc_create_or_derive_resource`),图片快速编辑在真实项目日志里还有成功记录;但 agent 侧写一句正常长度的背景音乐描述就失败,而客户端 UI 用同一个提示词却只是被截断加提示。
@@ -16,6 +16,7 @@
## 开发中
- 画布卡片类型与信息角标共用 `CanvasCardCornerActions`;菜单收纳共用 `OverflowActions`,宿主决定展示数量和资源命令。AGC 选中菜单前 5 项直显,Web 默认不折叠;浮层 portal 继续接入现有画布关闭与滚轮归属判据。
- 修改范围保持聚焦;优先扩展现有系统、页面、组件、DTO 和脚本,不新建平行入口或业务真相。
- UI 开发优先复用现有公共组件;跨页面或跨端重复的视觉/交互模式应沉淀到 `packages/shared`,由现有页面迁移使用,禁止在业务页复制同类 UI。共享组件只承载通用表现与交互,不下沉领域规则、后端副作用或正式业务状态。
- AGC 当前 Agent 与策划 Agent 的消息层级共用 `packages/shared``AgentMessageContent`:正文使用 `body`,思考、中间输出与工具调用使用 `process`;宿主不按 Agent 类型重新定义过程字号和颜色,错误状态保留语义色。
@@ -114,6 +114,7 @@
| 安装包与清单登记一致 | 下载安装包实算 SHA-256 与尺寸后与迁移桥清单比对 | 通过(size `104678031`、sha256 `1f67…4fd0` 一致) |
| 旧协议迁移桥 | 公网读取 `agc/latest.json` | 通过(0.1.48`downloadUrl` 指向同一对象,含 `sha256` / `size`) |
| 真实更新闭环(含升级后重启) | 0.1.47 客户端按提示下载安装并重启 | 通过(2026-09-17 用户实测:提示 → 下载 → 安装 → 关于页显示新版本,再次检查为已是最新) |
| 更新摘要端到端展示 | 公网读取渠道清单 `notes` 与客户端更新提示 | 通过(2026-09-17 用户实测:0.1.62 清单带 8 条自动摘要,客户端提示正常显示多行内容) |
待执行证据(首次渠道发布后回填):
@@ -14,6 +14,8 @@
- 未完成抠图的恢复项在现有类型行显示账本中的复杂/平面背景模式;平面模式显示已记录的自动背景色或颜色值,缺失模式/颜色不补默认值,恢复仍按原 operation 身份执行。
- 活动回合初始空快照、首次成功读取的空结果及禁用后的空态保持同一数组引用;快照签名初值与空态重置值均为 `[]`。无原生 invoke 的窗口测试只期待首次状态发布,异步空结果测试显式控制请求完成,不以初始空数组作为请求已完成的证据。
- 活动回合轮询的初始空快照与后续空结果保持同一引用;停用或切换读取器使旧请求失效,晚到快照不得恢复已停用的活动回合或覆盖新轮询结果。无原生读取器时窗口只发布一次空状态,不通过额外空数组触发重复发布。
- 工作台向窗口标题栏发布正在运行的项目时,输入未变化不得形成重复发布与清理的渲染循环;打开项目动作始终使用当前工作台处理逻辑,退出工作台后清除其标题栏状态。
- 资源子画布(含「所有资源」)保留空白处左键框选、资源卡左键选中/拖动、触摸板双指平移及捏合缩放;右键按住空白处或资源卡拖动时平移画布,不改变资源选择与布局。中键和空格抓手继续可用。总览保留既有左键平移,并支持右键平移。
- 画布接管的右键手势不弹出原生菜单;输入框、媒体操作、工具条和独立浮层不被画布抢占。指针取消、捕获丢失或窗口失焦后终止平移,不能继续跟随指针。
@@ -23,6 +25,8 @@
## 资源卡选中工具栏与导出
- AGC 选中工具栏按既有动作顺序最多直接显示前 5 项(不计分隔线),剩余动作进入「更多」。悬停、点击及键盘均可展开独立纵向浮层,优先向上展开,窗口顶边空间不足时向下避让;浮层限制在窗口内,超高时自行滚动,不带动画布。动作执行、点击外部、Escape 或换选资源后关闭;禁用状态和原处理链路保持不变。Web 美术画布默认不折叠。
- 「素材类型」与「信息」不占工具栏名额,改为资源卡右上角的类型标签和信息圆钮,与 Web 美术画布共用卡片控件。未选中卡片可直接打开信息;类型入口仅对 manifest 资产可用。控件不触发卡片拖拽或多选,信息面板仍复用运行页签的字段。
- 共享选中工具栏按实际显示的快速编辑、编辑动作、改造、导出与宿主动作组生成分隔线;空组不产生分隔线,不依赖宿主 CSS 隐藏重复线。
- AGC 所有具有本地文件路径的素材都显示带文字的「导出」按钮,位于工具栏末组的「删除素材」之前,两者之间不插入分隔线;「重命名」继续保留在前面的常规动作组。工具栏宽度上限为 `min(92vw, 800px)`,窄屏仍可横向滚动。图片、视频、音频、动画、UI、文档及其它文件共用 `isResourceCanvasExportable`,不按媒体类型限制导出;无文件路径及虚拟项目版本不提供文件导出入口。
- 导出继续复用 `saveProjectResourcesToDisk`:原生保存对话框选择路径,`save_local_project_asset_file` 复制原始文件字节,不转图片、不重编码、不另建 IPC。后端继续校验源文件、敏感路径和目标路径;取消不写文件,失败通过工作台提示。
@@ -20,6 +20,8 @@ Stdb 发布以 root 准备文件、再切换 `spacetimedb` 用户执行时,WAS
## 本地启动
AGC Vite 的 `server.watch.ignored` 排除 `**/src-tauri/target/**`,避免递归监听 Rust 构建产物、在 Windows 上创建大量文件监听器并拖慢首次页面加载。保留业务源码、CSS 与仓库共享组件的监听及热更新;不通过关闭 watcher 或 HMR 规避问题。监听回归使用 `node --test apps/ai-game-creator-shell/scripts/vite-watch.test.mjs`,验证构建目录被排除、应用源码及根目录外的共享源码仍能触发变更;原生窗口首绘耗时另行实测,不把监听测试耗时当作启动性能指标。
AGC `backend` 模式与 `all` / `api-server` 一样,必须同时探测 API 和 BgFilter worker 端口,漂移后的 worker 地址同时传给 API、worker 和 readiness 检查。不能因为旧 worker 的 `/readyz` 可访问,就把新启动失败的同端口 worker 视为就绪;AGC 前端会等待完整配套后端,worker 失败可能最终表现为 Tauri 等待前端 180 秒超时。
`npm run agc` 外层启动器先执行 `agc:serve` 并等待前端与配套后端就绪,再启动 Tauri,同时清空本次 CLI 的 `beforeDevCommand`,避免重复拉起服务和把数据库发布时间计入 Tauri 的 180 秒前端等待。准备阶段最多等待 660 秒(后端门禁仍为 600 秒),退出时清理本次启动的服务树,不停止复用的服务。AGC 自动发布显式使用 `--preserve-database`,schema 冲突须人工确认迁移,不自动清空数据。
@@ -0,0 +1,68 @@
import { Info } from 'lucide-react';
import type { CSSProperties, Ref } from 'react';
import { PlatformIconButton } from './PlatformIconButton';
/** 画布卡片共用的类型标签与信息入口,不承接资源业务状态。 */
export function CanvasCardCornerActions({
kindLabel,
kindAriaLabel,
kindClassName,
infoLabel,
style,
kindRef,
onKindClick,
onInfoClick,
infoPressed,
}: {
kindLabel?: string | null;
kindAriaLabel?: string;
kindClassName?: string;
infoLabel: string;
style?: CSSProperties;
kindRef?: Ref<HTMLSpanElement>;
onKindClick?: () => void;
onInfoClick: () => void;
infoPressed?: boolean;
}) {
return (
<span
className="shared-canvas-card-corners"
style={style}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
>
{kindLabel ? (
<span
ref={kindRef}
className={`shared-canvas-card-kind ${kindClassName ?? ''}`}
data-resource-type={kindLabel}
role={onKindClick ? 'button' : undefined}
tabIndex={onKindClick ? 0 : undefined}
aria-label={kindAriaLabel}
title={kindLabel}
onClick={onKindClick}
onKeyDown={(event) => {
if (onKindClick && (event.key === 'Enter' || event.key === ' ')) {
event.preventDefault();
onKindClick();
}
}}
>
{kindLabel}
</span>
) : null}
<PlatformIconButton
asChild="spanButton"
variant="darkMini"
className="shared-canvas-card-info"
label={infoLabel}
title="信息"
aria-pressed={infoPressed}
icon={<Info size={12} aria-hidden="true" />}
onClick={onInfoClick}
/>
</span>
);
}
@@ -0,0 +1,207 @@
/** @vitest-environment jsdom */
import {
cleanup,
fireEvent,
render,
screen,
within,
} from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { CanvasCardCornerActions } from './CanvasCardCornerActions';
import { OverflowActions } from './OverflowActions';
afterEach(cleanup);
describe('操作收纳与卡片角标', () => {
it('具名入口可收纳全部动作,并优先向上展开', () => {
render(
<OverflowActions maxVisible={0} label="素材处理">
<button></button>
<button></button>
</OverflowActions>,
);
const trigger = screen.getByRole('button', { name: '素材处理' });
expect(screen.getAllByRole('button')).toHaveLength(1);
vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue({
left: 200,
right: 300,
top: 350,
bottom: 380,
width: 100,
height: 30,
x: 200,
y: 350,
toJSON: () => ({}),
});
const height = vi
.spyOn(HTMLElement.prototype, 'scrollHeight', 'get')
.mockReturnValue(100);
fireEvent.mouseEnter(trigger);
const group = screen.getByRole('group', { name: '素材处理操作' });
expect(parseFloat(group.style.top)).toBeLessThan(350);
expect(
within(group)
.getAllByRole('button')
.map((b) => b.textContent),
).toEqual(['快速编辑', '改造']);
fireEvent.keyDown(document.body, { key: 'Escape' });
expect(screen.queryByRole('group')).toBeNull();
height.mockRestore();
vi.restoreAllMocks();
});
it('前五项可见,跳过分隔符和空 fragment,悬停展示剩余项并执行原回调', () => {
const click = vi.fn();
render(
<OverflowActions maxVisible={5}>
<>
<button></button>
<span aria-hidden="true" />
<button></button>
<></>
<button></button>
<button></button>
<button></button>
<span aria-hidden="true" />
<button onClick={click}></button>
<button disabled></button>
</>
</OverflowActions>,
);
expect(screen.getAllByRole('button')).toHaveLength(6);
expect(screen.queryByText('六')).toBeNull();
fireEvent.mouseEnter(screen.getByRole('button', { name: '更多' }));
const group = screen.getByRole('group', { name: '更多操作' });
expect(
within(group)
.getAllByRole('button')
.map((b) => b.textContent),
).toEqual(['六', '七']);
expect((screen.getByText('七') as HTMLButtonElement).disabled).toBe(true);
fireEvent.click(screen.getByText('六'));
expect(click).toHaveBeenCalledTimes(1);
expect(screen.queryByRole('group')).toBeNull();
});
it('悬停后点击仍展开、移入浮层不消失、外部关闭,Escape 恢复焦点', () => {
vi.useFakeTimers();
render(
<OverflowActions maxVisible={1}>
<button></button>
<button></button>
</OverflowActions>,
);
const more = screen.getByRole('button', { name: '更多' });
fireEvent.mouseEnter(more);
fireEvent.click(more);
fireEvent.mouseLeave(more);
fireEvent.mouseEnter(screen.getByRole('group'));
vi.advanceTimersByTime(200);
expect(screen.getByText('二')).toBeTruthy();
fireEvent.keyDown(screen.getByText('二'), { key: 'Escape' });
expect(screen.queryByRole('group')).toBeNull();
expect(document.activeElement).toBe(more);
fireEvent.click(more);
fireEvent.pointerDown(document.body);
expect(screen.queryByRole('group')).toBeNull();
vi.useRealTimers();
});
it('不溢出不显示更多,默认保持 Web 原样', () => {
const view = render(
<OverflowActions maxVisible={2}>
<button></button>
<button></button>
</OverflowActions>,
);
expect(screen.queryByText('更多')).toBeNull();
view.rerender(
<OverflowActions>
{Array.from({ length: 10 }, (_, i) => (
<button key={i}>{i}</button>
))}
</OverflowActions>,
);
expect(screen.getAllByRole('button')).toHaveLength(10);
});
it('动作减少至不溢出后关闭浮层,恢复动作不会自动重开', () => {
const view = render(
<OverflowActions maxVisible={1}>
<button></button>
<button></button>
</OverflowActions>,
);
fireEvent.mouseEnter(screen.getByRole('button', { name: '更多' }));
expect(screen.getByRole('group')).toBeTruthy();
view.rerender(
<OverflowActions maxVisible={1}>
<button></button>
</OverflowActions>,
);
expect(screen.queryByRole('group')).toBeNull();
view.rerender(
<OverflowActions maxVisible={1}>
<button></button>
<button></button>
</OverflowActions>,
);
expect(screen.queryByRole('group')).toBeNull();
});
it('浮层在窗口右下边界向上展开,方向键跳过禁用项', () => {
render(
<OverflowActions maxVisible={1}>
<button></button>
<button></button>
<button disabled></button>
<button></button>
</OverflowActions>,
);
const more = screen.getByRole('button', { name: '更多' });
vi.spyOn(more, 'getBoundingClientRect').mockReturnValue({
left: window.innerWidth - 35,
right: window.innerWidth,
top: window.innerHeight - 40,
bottom: window.innerHeight - 10,
width: 35,
height: 30,
x: 0,
y: 0,
toJSON: () => ({}),
});
// jsdom 没有真实布局,提供浮层测量以验证向上定位。
const height = vi
.spyOn(HTMLElement.prototype, 'scrollHeight', 'get')
.mockReturnValue(200);
fireEvent.click(more);
const panel = screen.getByRole('group');
expect(parseFloat(panel.style.top)).toBeLessThan(window.innerHeight - 40);
screen.getByText('二').focus();
fireEvent.keyDown(screen.getByText('二'), { key: 'ArrowDown' });
expect(document.activeElement).toBe(screen.getByText('四'));
fireEvent.keyDown(screen.getByText('四'), { key: 'Home' });
expect(document.activeElement).toBe(screen.getByText('二'));
height.mockRestore();
vi.restoreAllMocks();
});
it('卡片角标阻断指针和键盘冒泡,类型及信息分别执行', () => {
const parent = vi.fn(),
kind = vi.fn(),
info = vi.fn();
render(
<div onClick={parent} onPointerDown={parent} onKeyDown={parent}>
<CanvasCardCornerActions
kindLabel="角色"
kindAriaLabel="素材类型"
infoLabel="资源信息"
onKindClick={kind}
onInfoClick={info}
/>
</div>,
);
const label = screen.getByRole('button', { name: '素材类型' });
fireEvent.pointerDown(label);
fireEvent.click(label);
fireEvent.keyDown(label, { key: 'Enter' });
fireEvent.click(screen.getByRole('button', { name: '资源信息' }));
expect(kind).toHaveBeenCalledTimes(2);
expect(info).toHaveBeenCalledOnce();
expect(parent).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,237 @@
import {
Children,
cloneElement,
Fragment,
isValidElement,
type ReactNode,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { createPortal } from 'react-dom';
type ActionProps = { children?: ReactNode; 'aria-hidden'?: boolean | 'true' };
function flatten(nodes: ReactNode, prefix = ''): ReactNode[] {
return Children.toArray(nodes).flatMap((node, index) =>
isValidElement<ActionProps>(node) && node.type === Fragment
? flatten(node.props.children, `${prefix}${index}.`)
: [
isValidElement(node)
? cloneElement(node, { key: `${prefix}${index}` })
: node,
],
);
}
function isDivider(node: ReactNode) {
return (
isValidElement<ActionProps>(node) &&
(node.props['aria-hidden'] === true || node.props['aria-hidden'] === 'true')
);
}
/** 只负责展示收纳;动作权限、禁用与执行仍由调用方提供。 */
export function OverflowActions({
children,
maxVisible = Infinity,
label = '更多',
}: {
children: ReactNode;
maxVisible?: number;
label?: string;
}) {
const [open, setOpen] = useState(false);
const [position, setPosition] = useState({ left: 8, top: 8, maxHeight: 320 });
const trigger = useRef<HTMLButtonElement>(null);
const panel = useRef<HTMLDivElement>(null);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const id = useId();
const limit = Number.isFinite(maxVisible)
? Math.max(0, Math.floor(maxVisible))
: Infinity;
const nodes = flatten(children);
let count = 0;
const split = nodes.findIndex((node) => !isDivider(node) && ++count > limit);
const primary = split < 0 ? nodes : nodes.slice(0, split);
while (primary.length && isDivider(primary[primary.length - 1]))
primary.pop();
const overflow =
split < 0 ? [] : nodes.slice(split).filter((node) => !isDivider(node));
useEffect(() => {
if (overflow.length === 0) setOpen(false);
}, [overflow.length]);
const cancelClose = () => {
if (timer.current !== null) clearTimeout(timer.current);
timer.current = null;
};
const show = () => {
cancelClose();
setOpen(true);
};
const scheduleClose = () => {
cancelClose();
timer.current = setTimeout(() => {
if (!panel.current?.contains(document.activeElement)) setOpen(false);
}, 150);
};
useEffect(
() => () => {
if (timer.current !== null) clearTimeout(timer.current);
},
[],
);
useLayoutEffect(() => {
if (!open || !overflow.length) return;
const update = () => {
const anchor = trigger.current?.getBoundingClientRect();
if (!anchor) return;
const width = panel.current?.getBoundingClientRect().width ?? 200;
const height = panel.current?.scrollHeight ?? 320;
const below = window.innerHeight - anchor.bottom - 12;
const above = anchor.top - 12;
// 优先上展,留出卡片预览;窗口顶边空间不足时才向下避让。
const down = above < 80 && below > above;
const maxHeight = Math.max(40, Math.min(360, down ? below : above));
setPosition({
left: Math.max(
8,
Math.min(anchor.right - width, window.innerWidth - width - 8),
),
top: down
? anchor.bottom + 4
: Math.max(8, anchor.top - Math.min(height, maxHeight) - 4),
maxHeight,
});
};
update();
window.addEventListener('resize', update);
window.addEventListener('scroll', update, true);
return () => {
window.removeEventListener('resize', update);
window.removeEventListener('scroll', update, true);
};
}, [open, overflow.length, children]);
useEffect(() => {
if (!open) return;
const outside = (event: PointerEvent) => {
if (
event.target instanceof Node &&
!trigger.current?.contains(event.target) &&
!panel.current?.contains(event.target)
) {
setOpen(false);
}
};
const escape = (event: KeyboardEvent) => {
if (event.key !== 'Escape' || event.defaultPrevented) return;
event.preventDefault();
event.stopPropagation();
setOpen(false);
trigger.current?.focus();
};
document.addEventListener('pointerdown', outside);
document.addEventListener('keydown', escape);
return () => {
document.removeEventListener('pointerdown', outside);
document.removeEventListener('keydown', escape);
};
}, [open]);
if (!overflow.length) return <>{children}</>;
return (
<>
{primary}
<button
ref={trigger}
type="button"
className="shared-overflow-trigger image-canvas-editor__floating-toolbar-text-button"
aria-label={label}
aria-expanded={open}
aria-controls={open ? id : undefined}
onMouseEnter={show}
onMouseLeave={scheduleClose}
onClick={show}
onKeyDown={(event) => {
if (event.key === 'ArrowDown') {
event.preventDefault();
show();
requestAnimationFrame(() =>
panel.current
?.querySelector<HTMLButtonElement>('button:not(:disabled)')
?.focus(),
);
}
if (event.key === 'Escape') {
event.stopPropagation();
setOpen(false);
}
}}
>
{label} <span aria-hidden="true"></span>
</button>
{open
? createPortal(
<div
ref={panel}
id={id}
role="group"
aria-label={`${label}操作`}
className="shared-overflow-panel image-canvas-editor__portal-menu"
style={position}
onMouseEnter={cancelClose}
onMouseLeave={scheduleClose}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
if ((event.target as Element).closest('button:not(:disabled)'))
setOpen(false);
}}
onBlur={(event) => {
if (
!event.currentTarget.contains(event.relatedTarget) &&
event.relatedTarget !== trigger.current
)
setOpen(false);
}}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
setOpen(false);
trigger.current?.focus();
}
if (
['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)
) {
event.preventDefault();
const buttons = Array.from(
event.currentTarget.querySelectorAll<HTMLButtonElement>(
'button:not(:disabled)',
),
);
const index = buttons.indexOf(
document.activeElement as HTMLButtonElement,
);
const next =
event.key === 'Home'
? 0
: event.key === 'End'
? buttons.length - 1
: (index +
(event.key === 'ArrowDown' ? 1 : -1) +
buttons.length) %
buttons.length;
buttons[next]?.focus();
}
}}
>
{overflow}
</div>,
document.body,
)
: null}
</>
);
}

Some files were not shown because too many files have changed in this diff Show More