合并最新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
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:
@@ -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',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user