fix(agc): 「定位到素材」每次点击都终局化,不再卡在「正在定位生成的素材…」
- index.tsx 新增 `resourceAssetGenerationFocusRequest` 序号并加进聚焦 effect 的依赖:手动点定位不改画布
任何状态,原依赖一个都不变 → effect 不重跑、intent 永远没人消费,这正是「点了没反应 + 提示条永久
停在中转文案」的根因
- index.tsx `focusResourceAssetGenerationTask` 重写:先按当前投影与清单判一次目标——不在投影里就当场给
结论(素材已不在项目里 / 已登记但尚未同步),不挂 intent 也不留中转提示;在别的栏目先切栏目再定位;
挂 intent 后推进聚焦请求序号,并起一个 3 秒有界兜底:仍停在中转文案就收口成可执行提示,intent 被判
invalid 被静默清掉时也给一条「定位请求已失效」的结论,绝不留下悬而未决的状态
- index.tsx 聚焦 effect 的 `focusedCommitIdsRef` 提前返回分支补 `setResourceWorkbenchNotice('')`:
已聚焦过时把中转提示一并收掉,不再永久留在提示条上(自动落卡那条链同源问题)
- index.tsx `resourceAssetGenerationContextRef` 扩到带 `manifest` / `resources` / `activePageCategory`
并在每次渲染刷新:定位回调据此取权威目标与栏目,不再依赖可能过期的闭包值
- 新增 4 条 AppSurface 用例(同栏目直点定位并选中卡片、素材在另一个栏目先切栏目再定位、被搜索挡住时给出
既有「清除搜索并定位」、素材不在项目里时给明确结论且清掉中转提示)
This commit is contained in:
@@ -1577,6 +1577,17 @@ export default function ProjectDevelopmentView({
|
||||
const resourceAssetGenerationPanelTaskIdRef = useRef<string | null>(null);
|
||||
const [resourceAssetGenerationTasksPanelOpen, setResourceAssetGenerationTasksPanelOpen] =
|
||||
useState(false);
|
||||
/**
|
||||
* 「定位到素材」的聚焦请求序号。
|
||||
*
|
||||
* 聚焦 effect(`resolveResourceFocusIntent` 那条链)的依赖全是画布自身状态,手动点一次定位
|
||||
* 不改其中任何一项 → effect 不会重跑,intent 永远没人消费、提示条停在中转文案上。所以每次
|
||||
* 点击都要推进这个序号,让「这次请求」成为一个真实的依赖变化。
|
||||
*/
|
||||
const [resourceAssetGenerationFocusRequest, setResourceAssetGenerationFocusRequest] =
|
||||
useState(0);
|
||||
/** 提示条文案的 ref 版:有界兜底要判断此刻是否还停在中转文案上。 */
|
||||
const resourceWorkbenchNoticeRef = useRef('');
|
||||
const [resourceBottomToolbarUploading, setResourceBottomToolbarUploading] =
|
||||
useState(false);
|
||||
const [resourcePanelNotice, setResourcePanelNotice] = useState('');
|
||||
@@ -5561,6 +5572,9 @@ export default function ProjectDevelopmentView({
|
||||
}
|
||||
if (focusedCommitIdsRef.current.has(intent.commitId)) {
|
||||
pendingResourceFocusRef.current = null;
|
||||
// 这条资源已经聚焦过了:把中转提示一并收掉,否则「生成资源已保存,正在同步资源与布局…」
|
||||
// 这类文字会永久留在提示条上。
|
||||
setResourceWorkbenchNotice('');
|
||||
return;
|
||||
}
|
||||
intent.completed = true;
|
||||
@@ -5578,6 +5592,9 @@ export default function ProjectDevelopmentView({
|
||||
manifest.projectId,
|
||||
projectPath,
|
||||
resources,
|
||||
// 手动「定位到素材」不改画布任何状态,靠这个序号把「这次定位请求」变成真实的依赖变化;
|
||||
// 少了它 effect 不会重跑,intent 永远没人消费。
|
||||
resourceAssetGenerationFocusRequest,
|
||||
selectResourceCanvasPage,
|
||||
typeLayout.layout.positions,
|
||||
typeLayout.settled,
|
||||
@@ -6671,13 +6688,20 @@ export default function ProjectDevelopmentView({
|
||||
projectId: manifest.projectId,
|
||||
hasIconSpecReference,
|
||||
onManifestChange,
|
||||
manifest,
|
||||
resources,
|
||||
activePageCategory,
|
||||
});
|
||||
resourceAssetGenerationContextRef.current = {
|
||||
projectPath,
|
||||
projectId: manifest.projectId,
|
||||
hasIconSpecReference,
|
||||
onManifestChange,
|
||||
manifest,
|
||||
resources,
|
||||
activePageCategory,
|
||||
};
|
||||
resourceWorkbenchNoticeRef.current = resourceWorkbenchNotice;
|
||||
|
||||
const replaceResourceAssetGenerationTask = useCallback(
|
||||
(next: ResourceCanvasAssetGenerationTask) => {
|
||||
@@ -6908,32 +6932,91 @@ export default function ProjectDevelopmentView({
|
||||
};
|
||||
}, [manifest.projectId, projectPath]);
|
||||
|
||||
/** 「生成任务」面板里点一条已完成任务:复用既有聚焦链定位到它的素材卡。 */
|
||||
/**
|
||||
* 「生成任务」面板里点一条已完成任务:复用既有聚焦链定位到它的素材卡。
|
||||
*
|
||||
* **每次点击都必须终局化**:素材不在投影里 / 不在当前栏目 / 被搜索挡住 / 画布还在布局,四种情况
|
||||
* 各有结论,不允许留下悬而未决的 intent 与中转提示。所以这里做三件事:
|
||||
*
|
||||
* 1. 先按当前投影与清单判一次「这次点击有没有可定位的目标」——没有就直接给可执行结论,
|
||||
* 连 intent 都不挂(挂上去也没人消费);
|
||||
* 2. 有目标就挂 intent,并推进 `resourceAssetGenerationFocusRequest`:聚焦 effect 的依赖全是
|
||||
* 画布自身状态,不推进这个序号时 effect 不会重跑,点了等于没点(这正是「点了没反应且提示条
|
||||
* 永久停在中转文案」的根因);
|
||||
* 3. 起一个有界兜底:3 秒后仍停在中转文案就收口成可执行提示,绝不把中转态留给用户。
|
||||
*/
|
||||
const focusResourceAssetGenerationTask = useCallback(
|
||||
(task: ResourceCanvasAssetGenerationTask) => {
|
||||
if (!task.assetId) {
|
||||
const assetId = task.assetId;
|
||||
if (!assetId) {
|
||||
return;
|
||||
}
|
||||
const context = resourceAssetGenerationContextRef.current;
|
||||
const resourceId = `asset:${assetId}`;
|
||||
const target = context.resources.find(
|
||||
(resource) => resource.id === resourceId,
|
||||
);
|
||||
const locateNotice = '正在定位生成的素材…';
|
||||
if (!target) {
|
||||
// 不在投影里:还没同步到画布,或者素材已经不在项目里。两种都当场给结论,
|
||||
// 不放 intent 也不留中转提示。
|
||||
pendingResourceFocusRef.current = null;
|
||||
setResourceWorkbenchNotice(
|
||||
(context.manifest.assets ?? []).some((asset) => asset.id === assetId)
|
||||
? '素材已登记但尚未同步到画布,请稍候重试'
|
||||
: '素材已不在项目里(可能已被删除)',
|
||||
);
|
||||
return;
|
||||
}
|
||||
// 手动定位不能复用自动落卡那条 commitId:`focusedCommitIdsRef` 会把同一个 commitId 记为
|
||||
// 「已聚焦」,重复点同一条任务就会静默失效,所以这里用一次一点击的 flowId。
|
||||
const flowId = `asset-generation-focus:${task.taskId}:${Date.now()}`;
|
||||
activeFocusFlowIdRef.current = flowId;
|
||||
pendingResourceFocusRef.current = {
|
||||
flowId,
|
||||
saveAttemptId: task.assetId,
|
||||
sessionId: task.assetId,
|
||||
draftId: task.assetId,
|
||||
saveAttemptId: assetId,
|
||||
sessionId: assetId,
|
||||
draftId: assetId,
|
||||
commitId: flowId,
|
||||
projectPath: context.projectPath,
|
||||
projectId: context.projectId,
|
||||
focusGeneration: focusGenerationRef.current,
|
||||
resourceId: `asset:${task.assetId}`,
|
||||
resourceId,
|
||||
completed: false,
|
||||
};
|
||||
setResourceWorkbenchNotice('正在定位生成的素材…');
|
||||
if (target.category !== context.activePageCategory) {
|
||||
// 素材在别的栏目:先切过去(切栏目本身就是 effect 的依赖变化),再让聚焦链在那边定位。
|
||||
selectResourceCanvasPage(target.category);
|
||||
}
|
||||
setResourceWorkbenchNotice(locateNotice);
|
||||
setResourceAssetGenerationFocusRequest((current) => current + 1);
|
||||
window.setTimeout(() => {
|
||||
if (focusedCommitIdsRef.current.has(flowId)) {
|
||||
// 真的聚焦过了。
|
||||
return;
|
||||
}
|
||||
const pending = pendingResourceFocusRef.current;
|
||||
if (pending?.flowId === flowId) {
|
||||
if (resourceWorkbenchNoticeRef.current !== locateNotice) {
|
||||
// 聚焦链已经给出别的结论(例如「被当前搜索条件隐藏」+「清除搜索并定位」)。
|
||||
return;
|
||||
}
|
||||
pendingResourceFocusRef.current = null;
|
||||
setResourceWorkbenchNotice(
|
||||
'未能定位到素材:画布可能仍在布局或素材暂不可见,请稍后重试',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (resourceWorkbenchNoticeRef.current === '') {
|
||||
// intent 被判 invalid(项目 / 画布已切换)时聚焦链会清掉 intent 与提示:手动点击
|
||||
// 不能静默丢弃,给一条能解释「为什么没动」的结论。
|
||||
setResourceWorkbenchNotice(
|
||||
'定位请求已失效(项目或画布已切换),请重新点击定位',
|
||||
);
|
||||
}
|
||||
}, 3_000);
|
||||
},
|
||||
[],
|
||||
[selectResourceCanvasPage],
|
||||
);
|
||||
|
||||
/** 工具栏「上传」:与资源面板上传同一条「上传 + 配对读清单」链路。 */
|
||||
|
||||
@@ -10898,6 +10898,220 @@ export function registerProjectAgentStatusTests() {
|
||||
).not.toBeNull();
|
||||
}, 20_000);
|
||||
|
||||
/**
|
||||
* 「定位到素材」的公共夹具:项目里有一个 character 资产 + 一条已完成、指向它的生成任务。
|
||||
*
|
||||
* `openCategory` 决定先停在哪个栏目:停在别的栏目就能覆盖「素材在另一个栏目」这条分支。
|
||||
*/
|
||||
async function renderGenerationLocateView(input: {
|
||||
projectId: string;
|
||||
projectPath: string;
|
||||
openCategory: string;
|
||||
assetId: string;
|
||||
ledgerRecords: Record<string, unknown>[];
|
||||
}) {
|
||||
const manifest = createGameCreationAppManifest(
|
||||
input.projectId,
|
||||
'生成任务定位测试',
|
||||
);
|
||||
manifest.assets = [
|
||||
{
|
||||
id: input.assetId,
|
||||
kind: 'character',
|
||||
mediaType: 'image/png',
|
||||
localPath: 'assets/locate-target.png',
|
||||
source: { kind: 'canvas', resourceId: 'locate-target-resource' },
|
||||
},
|
||||
];
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'read_local_project_resource_graph') {
|
||||
return resourceGraphForInputs(args);
|
||||
}
|
||||
if (command === 'read_local_project_resource_canvas_layout') {
|
||||
return {
|
||||
schemaVersion: 'game-creator-resource-layout.v1',
|
||||
projectId: input.projectId,
|
||||
mode: args?.mode,
|
||||
revision: 0,
|
||||
positions: [],
|
||||
updatedAt: 0,
|
||||
};
|
||||
}
|
||||
if (command === 'list_local_project_asset_generations') {
|
||||
return input.ledgerRecords;
|
||||
}
|
||||
if (command === 'get_local_game_project_revision') {
|
||||
return { revision: 7 };
|
||||
}
|
||||
if (command === 'get_local_game_manifest') {
|
||||
return manifest;
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
|
||||
render(
|
||||
React.createElement(ProjectDevelopmentView, {
|
||||
projectName: manifest.name,
|
||||
projectPath: input.projectPath,
|
||||
manifest,
|
||||
attachments: [],
|
||||
recentRunStatus: null,
|
||||
recentRunStopReason: null,
|
||||
supervisor: React.createElement('div', null, '项目总控'),
|
||||
onHomeOpen: vi.fn(),
|
||||
onProjectsOpen: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
await openResourceBookCategory(input.openCategory);
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成任务' }));
|
||||
return screen.findByRole('region', { name: '生成任务' });
|
||||
}
|
||||
|
||||
function completedGenerationRecord(input: {
|
||||
taskId: string;
|
||||
assetId: string | null;
|
||||
projectId: string;
|
||||
}) {
|
||||
return {
|
||||
taskId: input.taskId,
|
||||
projectId: input.projectId,
|
||||
kind: 'character',
|
||||
assetName: '定位目标素材',
|
||||
status: 'completed',
|
||||
phaseDetail: '生成已完成。',
|
||||
createdAtMillis: 1,
|
||||
startedAtMillis: 1,
|
||||
finishedAtMillis: 2,
|
||||
assetId: input.assetId,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
it('locates a generated asset that lives in another column instead of leaving the notice pending', async () => {
|
||||
const panel = await renderGenerationLocateView({
|
||||
projectId: 'workbench-locate-other-column',
|
||||
projectPath: '/tmp/workbench-locate-other-column',
|
||||
// 停在 UI 交互栏目:目标素材在 character 栏目。
|
||||
openCategory: 'UI 交互',
|
||||
assetId: 'locate-other-column-asset',
|
||||
ledgerRecords: [
|
||||
completedGenerationRecord({
|
||||
taskId: 'task-locate-other-column',
|
||||
assetId: 'locate-other-column-asset',
|
||||
projectId: 'workbench-locate-other-column',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', { name: '定位素材 定位目标素材' }),
|
||||
);
|
||||
|
||||
// 悬而未决的中转提示必须消失,并且真的切到目标素材所在栏目。
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText('正在定位生成的素材…')).toBeNull(),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
document.querySelector(
|
||||
'.game-resource-book-scene-titlebar.is-active[data-resource-book-category="character"]',
|
||||
),
|
||||
).not.toBeNull(),
|
||||
);
|
||||
}, 20_000);
|
||||
|
||||
it('focuses a generated asset that is already in the current column', async () => {
|
||||
// 这条是「点了没反应」的最小复现:不切栏目、不搜索,画布状态一个都不变,
|
||||
// 只靠聚焦请求序号让 effect 重跑。
|
||||
const panel = await renderGenerationLocateView({
|
||||
projectId: 'workbench-locate-same-column',
|
||||
projectPath: '/tmp/workbench-locate-same-column',
|
||||
openCategory: '角色与对象',
|
||||
assetId: 'locate-same-column-asset',
|
||||
ledgerRecords: [
|
||||
completedGenerationRecord({
|
||||
taskId: 'task-locate-same-column',
|
||||
assetId: 'locate-same-column-asset',
|
||||
projectId: 'workbench-locate-same-column',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', { name: '定位素材 定位目标素材' }),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
document.querySelector(
|
||||
'.game-resource-card-select[data-resource-id="asset:locate-same-column-asset"][aria-pressed="true"]',
|
||||
),
|
||||
).not.toBeNull(),
|
||||
);
|
||||
expect(screen.queryByText('正在定位生成的素材…')).toBeNull();
|
||||
}, 20_000);
|
||||
|
||||
it('surfaces the existing clear-search action when the generated asset is filtered out', async () => {
|
||||
const panel = await renderGenerationLocateView({
|
||||
projectId: 'workbench-locate-hidden',
|
||||
projectPath: '/tmp/workbench-locate-hidden',
|
||||
openCategory: '角色与对象',
|
||||
assetId: 'locate-hidden-asset',
|
||||
ledgerRecords: [
|
||||
completedGenerationRecord({
|
||||
taskId: 'task-locate-hidden',
|
||||
assetId: 'locate-hidden-asset',
|
||||
projectId: 'workbench-locate-hidden',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// 用搜索条件把目标素材挡掉:筛选面板的关键词就是画布唯一的搜索入口。
|
||||
fireEvent.keyDown(window, { key: 'f', ctrlKey: true });
|
||||
fireEvent.change(screen.getByLabelText('查找素材'), {
|
||||
target: { value: 'zzz-no-such-resource' },
|
||||
});
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', { name: '定位素材 定位目标素材' }),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText('正在定位生成的素材…')).toBeNull(),
|
||||
);
|
||||
expect(screen.getByRole('button', { name: '清除搜索并定位' })).not.toBeNull();
|
||||
}, 20_000);
|
||||
|
||||
it('settles a locate request whose asset is not in the project at all', async () => {
|
||||
const panel = await renderGenerationLocateView({
|
||||
projectId: 'workbench-locate-missing',
|
||||
projectPath: '/tmp/workbench-locate-missing',
|
||||
openCategory: '角色与对象',
|
||||
assetId: 'locate-missing-asset',
|
||||
ledgerRecords: [
|
||||
completedGenerationRecord({
|
||||
taskId: 'task-locate-missing',
|
||||
assetId: 'asset-that-no-longer-exists',
|
||||
projectId: 'workbench-locate-missing',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', { name: '定位素材 定位目标素材' }),
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText('素材已不在项目里(可能已被删除)'),
|
||||
).not.toBeNull();
|
||||
expect(screen.queryByText('正在定位生成的素材…')).toBeNull();
|
||||
}, 20_000);
|
||||
|
||||
it('routes the audio column entries to the existing audio generation chain', async () => {
|
||||
const manifest = createGameCreationAppManifest(
|
||||
'workbench-bottom-toolbar-audio',
|
||||
|
||||
Reference in New Issue
Block a user