fix: 保留跨搜索/折叠选择,隐藏选择参与删除时必须明确确认。

更新行为文档
补充测试
This commit is contained in:
2026-07-31 20:04:48 +08:00
parent 8526063cea
commit 2da3dc0544
8 changed files with 125 additions and 51 deletions
@@ -5823,11 +5823,11 @@
- 当前口径:`server-rs/crates/platform-llm/tests/live_stream_tool_calls.rs` 是默认忽略的真实端点工具调用 smoke;`on_delta` 只接收文本,工具调用从最终 `LlmRunResponse.tool_calls` 读取。现有测试没有原始 SSE 录制、事件类型/slot/分片顺序保存或逐事件比较,因此两类测试都不能证明 raw SSE fidelity 或抓包转录无偏差。
- 现有确定性流式工具覆盖应与普通 Anthropic 文本流测试分开统计:三协议真实来源 fixture、Responses 仅有 completed / incomplete 终态事件时的恢复、并行 slot 聚合、截断参数和无片段 `StreamUnavailable` 等用例共同覆盖 parser 边界;未来若需证明转录一致性,必须另行增加受控原始 SSE capture/compare 能力。
## 2026-07-27 图片画布左侧素材库统一可见范围多选与批量下载
## 2026-07-27 图片画布左侧素材库统一稳定多选与批量操作
- 范围边界:本次只重写左侧素材库选择模式,不改变中央画布舞台和图层列表的选择、框选或下载语义。选择集合固定为当前搜索结果中已展开、上传完成且媒体地址有效的素材搜索折叠变化后立即清理不可见选择
- 输入语义:桌面普通点击替换选择,`Ctrl / Cmd + 点击` 切换追加,`Shift + 点击` 按当前可见顺序选择连续区间`Ctrl / Cmd + Shift + 点击` 追加连续区间;触摸和笔输入单击切换选择。退出选择模式、关闭素材栏或切到图层栏统一清空选择、锚点和框选状态,非选择模式不显示历史选中高亮。
- 工具栏与导出:批量工具栏作为素材滚动列表的固定非滚动底栏,提供可见范围全选 / 取消全选、下载、删除和取消;选择模式隐藏单行下载 / 重命名并禁用行拖拽和右键菜单。移动端选择模式使用独立的侧栏高度状态,并让素材列表恢复纵向滚动,避免普通模式 `14rem` 高度上限被固定底栏、标题和搜索区吃完。一个选中素材直接下载,多个选中素材复用画布素材导出管线生成 `项目名-选中素材-YYYYMMDD-HHmmss.zip`,根目录为 `项目名-选中素材/`;单素材、序列帧和两类集合 ZIP 的下载名统一包含到秒的本地时间戳。素材卡整行是统一选择命中区,标题和空白区不得绕过 Ctrl / Cmd / Shift 选择处理。
- 范围边界:本次只重写左侧素材库选择模式,不改变中央画布舞台和图层列表的选择、框选或下载语义。选择集合以全部上传完成且媒体地址有效的素材为有效性边界,不因搜索折叠或展开变化而收缩;只有素材被删除、进入上传中 / 失败态或媒体地址失效时才清理对应选择和范围锚点
- 输入语义:鼠标、键盘、触摸和笔输入单击都只切换当前素材,不替换其它已选素材;`Shift + 点击` 按当前可见顺序连续区间增量加入现有选择,锚点当前不可见时退化为切换目标素材并建立新锚点。当前搜索结果的全选 / 取消全选只增量增删已展开的可见素材并保留其它选择,同时清空上次单项选择的范围锚点。退出选择模式、关闭素材栏或切到图层栏统一清空选择、锚点和框选状态,非选择模式不显示历史选中高亮。
- 工具栏与导出:批量工具栏作为素材滚动列表的固定非滚动底栏,展示跨搜索与折叠状态保留的全部已选数量,并提供当前可见范围全选 / 取消全选、下载、删除和取消。下载消费完整选中集合;删除完整选中集合时,如果其中存在当前未显示素材,必须先用危险确认弹窗明确展示全部删除数量和未显示数量,用户确认前不得执行删除。选择模式隐藏单行下载 / 重命名并禁用行拖拽和右键菜单。移动端选择模式使用独立的侧栏高度状态,并让素材列表恢复纵向滚动,避免普通模式 `14rem` 高度上限被固定底栏、标题和搜索区吃完。一个选中素材直接下载,多个选中素材复用画布素材导出管线生成 `项目名-选中素材-YYYYMMDD-HHmmss.zip`,根目录为 `项目名-选中素材/`;单素材、序列帧和两类集合 ZIP 的下载名统一包含到秒的本地时间戳。素材卡整行是统一选择命中区,标题和空白区不得绕过单项切换或 Shift 范围处理。
## 2026-07-29 抽取通用多 Agent Runtime 公共内核第一阶段
@@ -195,7 +195,7 @@
- 现象:批量选择多个素材后,搜索、折叠文件夹或展开文件夹会让已选数量下降、Shift 范围锚点丢失,后续批量下载或删除遗漏此前已选素材。
- 原因:搜索结果和文件夹展开状态只描述当前 UI 可见范围,不描述素材是否仍然有效;用 `visibleAssetIds` reconcile 全局选择会把暂时隐藏误判为素材失效。
- 处理:由唯一 `useImageCanvasAssetSelection` 持有选择集合、范围锚点、框选和全部选择 mutation;全局选择只按全部 `selectableAssetIds` 清理真正删除、上传未完成、上传失败或媒体地址无效的 ID。`visibleAssetIds` 只作为单项切换、Shift 可见区间和当前结果全选 / 取消全选的动作入参,批量下载与删除消费 hook 输出的完整 `selectedAssets`
- 处理:由唯一 `useImageCanvasAssetSelection` 持有选择集合、范围锚点、框选和全部选择 mutation;全局选择只按全部 `selectableAssetIds` 清理真正删除、上传未完成、上传失败或媒体地址无效的 ID。`visibleAssetIds` 只作为单项切换、Shift 可见区间和当前结果全选 / 取消全选的动作入参,批量下载与删除消费 hook 输出的完整 `selectedAssets`;删除中包含当前未显示选择时,必须明确展示全部数量和未显示数量并二次确认
- 验证:模型测试覆盖隐藏选择保留、可见范围增量和真正失效 ID 清理;图片画布素材集成测试覆盖搜索、折叠 / 展开后选中数量稳定及当前可见全选不影响隐藏选择。
- 关联:`src/components/image-editor/useImageCanvasAssetSelection.ts``src/components/image-editor/useImageCanvasAssetLibrary.ts``src/components/image-editor/ImageCanvasSidebarView.tsx``src/components/image-editor/ImageCanvasEditorView.tsx`
@@ -169,7 +169,7 @@
- 画布素材 ZIP 的 `metadata.json` 只保存前端信息弹窗和导出文件列表可见的展示快照:项目标题、导出时间、图层标题、文件路径、类型、生成输入、模型显示名、Task 短 ID、Object 显示值、Resolution / 时长和导出错误。导出的生成输入只保留用户实际填写或选择的内容;系统默认兜底提示词、固定工作流提示词、内置图标描述、UI 提取素材固定提示词等内置提示词即使存在于历史 `generationInputs`,也不得写入导出元数据。不得把 `projectId``layerId``resourceId``sourceAssetId``sourceResourceId`、原始 `prompt` / `actualPrompt` / `provider` 或画布坐标、锁定、隐藏等布局状态写入导出元数据;Object 字段仅沿用信息弹窗当前可见值。
- `生成角色动作` 的完成结果按序列帧素材处理:图层主 `src` 使用 `frames[0].imageSrc``mediaType` 固定为 `image-sequence``assetKind` 固定为 `character-animation`,完整帧列表写入 `imageSequenceFrames``previewVideoPath` 只保留为上游预览视频来源,不作为画布主媒体。下载和 ZIP 导出必须因此得到序列帧 ZIP / frames 目录,不能回退为预览视频或首帧 PNG;后端抽帧后逐帧保存带绿幕源图,上传时消费并释放原帧字节,再用该对象的 600 秒 OSS URL 完成透明化并落盘透明帧素材。
- 素材面板支持按素材名、文件夹名、生成信息、用户可见模型、任务和媒体类型搜索。选择模式的选中集合以全部上传完成且媒体地址有效的可选素材为有效性边界,不因搜索、折叠或展开变化而收缩;只有素材被删除、进入上传中 / 失败态或媒体地址失效时才剔除对应选择及范围锚点。素材缩略图、标题、元数据和卡片空白区统一作为整行选择命中区:鼠标、键盘、触摸和笔输入单击都只切换当前素材,不替换其它已选素材;`Shift + 点击` 按当前可见顺序把连续区间增量加入现有选择,锚点当前不可见时退化为切换目标素材并建立新锚点。当前搜索结果的全选 / 取消全选只增量增删当前已展开的可见素材并保留其它选择,同时清空上次单项选择的范围锚点。退出选择模式、关闭素材栏或切换到图层栏时必须同时清空选择锚点、选中集合和框选状态,非选择模式不得残留选中高亮。
- 素材选择模式的批量工具栏是素材列表滚动区之外的固定底栏,始终显示跨搜索与折叠状态保留的全部已选数量、当前可见素材全选 / 取消全选、下载、删除和取消;下载和删除作用于完整选中集合,不得只消费当前可见 ID。工具栏自身禁止横向溢出和滚动,空间不足时按钮在工具栏内部换行。不得把工具栏作为滚动列表末项或依赖 `position: sticky` 的自然位置。移动端进入素材选择模式时必须提高素材侧栏高度并允许素材列表纵向滚动,为固定底栏之上至少保留一张素材卡的可操作空间,不得沿用普通模式 `14rem` 上限压扁列表。选择模式隐藏单素材下载 / 重命名动作并禁用素材拖拽和右键菜单,避免批量意图回退成单素材操作。Provider 与 `isEditorInternalProcessingModel(...)` 识别的内部处理模型不得进入搜索索引,避免出现隐藏字段可命中或命中但无可见匹配字段;原始审计元数据只保留给服务端和后台管理读取,普通用户(包括素材 owner)不得通过响应取得。
- 素材选择模式的批量工具栏是素材列表滚动区之外的固定底栏,始终显示跨搜索与折叠状态保留的全部已选数量、当前可见素材全选 / 取消全选、下载、删除和取消;下载和删除作用于完整选中集合,不得只消费当前可见 ID。删除集合中存在当前未显示素材时,必须先用危险确认弹窗明确展示全部删除数量和未显示数量,确认前不得执行删除。工具栏自身禁止横向溢出和滚动,空间不足时按钮在工具栏内部换行。不得把工具栏作为滚动列表末项或依赖 `position: sticky` 的自然位置。移动端进入素材选择模式时必须提高素材侧栏高度并允许素材列表纵向滚动,为固定底栏之上至少保留一张素材卡的可操作空间,不得沿用普通模式 `14rem` 上限压扁列表。选择模式隐藏单素材下载 / 重命名动作并禁用素材拖拽和右键菜单,避免批量意图回退成单素材操作。Provider 与 `isEditorInternalProcessingModel(...)` 识别的内部处理模型不得进入搜索索引,避免出现隐藏字段可命中或命中但无可见匹配字段;原始审计元数据只保留给服务端和后台管理读取,普通用户(包括素材 owner)不得通过响应取得。
- 图层面板支持按图层名、生成信息、用户可见模型、任务和媒体类型搜索;Provider 与内部处理模型同样不得进入搜索索引。普通用户图层快照也不得携带这些内部字段。支持选择多个图层后创建图层组,组名和 groupId 随画布布局保存。
- 小地图支持拖拽视口框,拖动时画布 viewport 跟随移动;`pointermove` 更新必须通过 `requestAnimationFrame` 合帧,结束拖拽时 flush 最后一帧,避免高频 pointermove 直接压垮 React 渲染和项目持久化链路。
- 鼠标滚轮默认按设备原始 `deltaX / deltaY` 二维平移画布视口;当按住 Shift 且设备上报 `deltaX = 0` 时,视口输入适配层把 `deltaY` 映射为横向位移并将纵向位移置零,核心平移模型仍只消费归一化后的 X / Y。按住 Ctrl / Cmd 滚轮才缩放画布,并阻止浏览器页面缩放。缩放比例显示保持现有换算口径,最低可缩小到 `5%`
@@ -101,7 +101,7 @@
- 主视图继续保留上传文件读取、上传占位卡片进度、拖到画布坐标、创建画布图层、工程资源持久化和画布图层清理;素材删除通过 `onDeleteAssets` 回调通知主视图清理关联图层。
- 该 hook 有独立单测覆盖素材库加载归一化、401 登录、新建文件夹临时 id 替换、素材移动、删除回调和多选删除,避免后续整理侧栏 JSX 时丢失素材库能力。
- `useImageCanvasAssetSelection.ts`
- 作为素材选择的唯一状态边界,统一持有选择模式、完整选中集合、范围锚点、可选素材有效性 reconcile、单项 / Shift / 当前可见全选增量、框选几何与框选生命周期,并向批量下载 / 删除只暴露已经按素材顺序解析的 `selectedAssets`
- 作为素材选择的唯一状态边界,统一持有选择模式、完整选中集合、范围锚点、可选素材有效性 reconcile、单项 / Shift / 当前可见全选增量、框选几何与框选生命周期,并向批量下载 / 删除只暴露已经按素材顺序解析的 `selectedAssets`;删除入口根据当前 `visibleAssetIds` 识别未显示选择并在执行完整集合删除前弹出危险确认
- 搜索和文件夹折叠只在侧栏产生 `visibleAssetIds` 并传入增量动作,不得直接修改或 reconcile 选择集合;选择行为测试集中在该 hook,素材库 model 不再维护第二套选择状态机。
## 第十一阶段模块
@@ -13,9 +13,11 @@ import type {
RefObject,
SetStateAction,
} from 'react';
import { useState } from 'react';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformBatchActionToolbar } from '../common/PlatformBatchActionToolbar';
import { PlatformDangerConfirmDialog } from '../common/PlatformDangerConfirmDialog';
import { PlatformTextField } from '../common/PlatformTextField';
import { ImageCanvasAssetFolderSectionView } from './ImageCanvasAssetFolderSectionView';
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
@@ -26,7 +28,10 @@ import type {
EditorAssetFolder,
UploadTarget,
} from './ImageCanvasEditorTypes';
import type { AssetSelectionInteraction } from './useImageCanvasAssetSelection';
import {
type AssetSelectionInteraction,
countHiddenSelectedAssets,
} from './useImageCanvasAssetSelection';
export type GroupedEditorAssetFolder = EditorAssetFolder & {
assets: EditorAsset[];
@@ -152,6 +157,10 @@ export function ImageCanvasAssetLibraryPanelView({
deleteSelectedAssets,
closeAssetSelectionMode,
}: ImageCanvasAssetLibraryPanelViewProps) {
const [
hiddenSelectionDeleteConfirmOpen,
setHiddenSelectionDeleteConfirmOpen,
] = useState(false);
let selectionToggleActionText = '全选';
if (allVisibleAssetsSelected) {
selectionToggleActionText = '取消全选';
@@ -160,6 +169,18 @@ export function ImageCanvasAssetLibraryPanelView({
if (selectedAssetIds.size > 0) {
selectionToggleLabel = `${selectionToggleActionText} · 已选 ${selectedAssetIds.size}`;
}
const hiddenSelectedAssetCount = countHiddenSelectedAssets(
selectedAssetIds,
visibleAssetIds,
);
const requestDeleteSelectedAssets = () => {
if (hiddenSelectedAssetCount > 0) {
setHiddenSelectionDeleteConfirmOpen(true);
return;
}
deleteSelectedAssets();
};
return (
<div className="image-canvas-editor__asset-library">
@@ -304,7 +325,7 @@ export function ImageCanvasAssetLibraryPanelView({
tone="warning"
size="sm"
disabled={selectedAssetIds.size === 0 || isExportingSelectedAssets}
onClick={deleteSelectedAssets}
onClick={requestDeleteSelectedAssets}
>
<Trash2 className="h-4 w-4" />
@@ -320,6 +341,19 @@ export function ImageCanvasAssetLibraryPanelView({
</PlatformActionButton>
</PlatformBatchActionToolbar>
) : null}
<PlatformDangerConfirmDialog
open={hiddenSelectionDeleteConfirmOpen}
title="删除全部已选素材"
description={`将删除 ${selectedAssetIds.size} 个已选素材,其中 ${hiddenSelectedAssetCount} 个当前未显示。`}
confirmLabel="确认删除全部已选素材"
onClose={() => setHiddenSelectionDeleteConfirmOpen(false)}
onConfirm={() => {
setHiddenSelectionDeleteConfirmOpen(false);
deleteSelectedAssets();
}}
>
</PlatformDangerConfirmDialog>
</div>
);
}
@@ -924,9 +924,7 @@ describe('ImageCanvasEditorView asset library integration', () => {
fireEvent.click(screen.getByText('账号素材A'));
expect(within(toolbar).getByText(/ 2/u)).toBeTruthy();
fireEvent.click(
within(toolbar).getByRole('button', { name: /^/u }),
);
fireEvent.click(within(toolbar).getByRole('button', { name: /^/u }));
expect(within(toolbar).getByText(/ 3/u)).toBeTruthy();
fireEvent.click(
within(toolbar).getByRole('button', { name: /^/u }),
@@ -946,6 +944,26 @@ describe('ImageCanvasEditorView asset library integration', () => {
await user.click(screen.getByRole('button', { name: '展开其它素材' }));
expect(foldedAssetList).toHaveProperty('hidden', false);
expect(within(toolbar).getByText(/ 2/u)).toBeTruthy();
await user.click(screen.getByRole('button', { name: '折叠其它素材' }));
await user.click(within(toolbar).getByRole('button', { name: '删除' }));
const hiddenDeleteDialog = screen.getByRole('dialog', {
name: '删除全部已选素材',
});
expect(
within(hiddenDeleteDialog).getByText(
'将删除 2 个已选素材,其中 1 个当前未显示。',
),
).toBeTruthy();
expect(deleteEditorAssetMock).not.toHaveBeenCalled();
await user.click(
within(hiddenDeleteDialog).getByRole('button', {
name: '确认删除全部已选素材',
}),
);
expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-b');
expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-c');
expect(deleteEditorAssetMock).not.toHaveBeenCalledWith('asset-a');
});
it('removes canvas layers linked to deleted assets', async () => {
@@ -1014,37 +1032,39 @@ describe('ImageCanvasEditorView asset library integration', () => {
it('saves the canvas layout after deleting an asset linked by source resource id', async () => {
const user = userEvent.setup();
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce(withEditorProjectCanvasRevision({
projectId: 'editor-project-default',
title: '生成资源项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'layer-generated-a',
resourceId: 'resource-generated-a',
title: '生成资源A',
x: 100,
y: 120,
width: 320,
height: 240,
originalWidth: 320,
originalHeight: 240,
zIndex: 1,
sourceType: 'generated',
},
],
resources: [
{
resourceId: 'resource-generated-a',
projectId: 'editor-project-default',
imageSrc: '/editor-test/project/generated-a.png',
width: 320,
height: 240,
sourceType: 'generated',
},
],
updatedAt: '2026-06-12T00:00:00.000Z',
}));
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce(
withEditorProjectCanvasRevision({
projectId: 'editor-project-default',
title: '生成资源项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'layer-generated-a',
resourceId: 'resource-generated-a',
title: '生成资源A',
x: 100,
y: 120,
width: 320,
height: 240,
originalWidth: 320,
originalHeight: 240,
zIndex: 1,
sourceType: 'generated',
},
],
resources: [
{
resourceId: 'resource-generated-a',
projectId: 'editor-project-default',
imageSrc: '/editor-test/project/generated-a.png',
width: 320,
height: 240,
sourceType: 'generated',
},
],
updatedAt: '2026-06-12T00:00:00.000Z',
}),
);
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
{
@@ -1118,14 +1138,16 @@ describe('ImageCanvasEditorView asset library integration', () => {
height: 240,
sourceType: 'uploaded',
});
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce(withEditorProjectCanvasRevision({
projectId: 'editor-project-default',
title: '空画布项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-06-12T00:00:00.000Z',
}));
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce(
withEditorProjectCanvasRevision({
projectId: 'editor-project-default',
title: '空画布项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-06-12T00:00:00.000Z',
}),
);
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
{
@@ -6,6 +6,7 @@ import { describe, expect, it, vi } from 'vitest';
import type { EditorAsset } from './ImageCanvasEditorTypes';
import {
countHiddenSelectedAssets,
createAssetMarqueeFromPointer,
createAssetMarqueeSelectionRect,
getSelectableAssets,
@@ -30,6 +31,14 @@ function createAsset(overrides: Partial<EditorAsset> = {}): EditorAsset {
}
describe('useImageCanvasAssetSelection', () => {
it('counts selected assets outside the current visible scope', () => {
expect(
countHiddenSelectedAssets(new Set(['asset-a', 'asset-b', 'asset-c']), [
'asset-a',
]),
).toBe(2);
});
it('owns selectable-asset validity', () => {
expect(
getSelectableAssets([
@@ -44,6 +44,15 @@ export function getSelectableAssets(assets: EditorAsset[]) {
);
}
export function countHiddenSelectedAssets(
selectedAssetIds: Set<string>,
visibleAssetIds: string[],
) {
const visibleIds = new Set(visibleAssetIds);
return [...selectedAssetIds].filter((assetId) => !visibleIds.has(assetId))
.length;
}
function doRectsIntersect(rect: ClientRectLike, selectionRect: ClientRectLike) {
return (
rect.left <= selectionRect.right &&