支持画布与资源面板多选素材批量追加标签

复用标签编辑器并冻结整组选中素材,保留原标签与素材类型

原生批量校验后一次保存,覆盖权限、版本冲突与无变化边界

修复保存输入锁和切项目迟到回包,补齐混合选择入口原因

新增前后端回归并记录本地验证及待真实客户端验收事项
This commit is contained in:
2026-09-17 21:51:59 +08:00
parent da44d66dc8
commit 105591bac5
18 changed files with 2905 additions and 53 deletions
@@ -2320,6 +2320,27 @@ pub(crate) fn update_local_project_resource_classification(
)
}
/// 为一批已登记素材追加标签:整批一次校验、一次 manifest 写入、一次 revision 推进。
///
/// 权限位与单素材分类更新同口径取 `asset.register`(命令包装层只做权限门面,
/// 身份 / 写锁 / CAS / 原子写与审计都在 `project/manifest.rs` 内完成)。
/// 这里刻意**不**循环调用单素材命令:逐项调用会写出多份 manifest、推进多次 revision
/// 中途失败还会留下"前几个素材改了、后面的没改"的部分写入。
#[tauri::command]
pub(crate) fn add_local_project_resource_tags(
input: AddLocalProjectResourceTagsInput,
) -> Result<AddLocalProjectResourceTagsResult, String> {
let root = Path::new(input.project_path.trim());
enforce_project_permission_policy(root, "asset.register")?;
add_manifest_asset_tags_at(
root,
&input.expected_project_id,
input.expected_project_revision,
input.asset_ids,
input.tags,
)
}
#[tauri::command]
pub(crate) async fn derive_local_project_resource(
input: DeriveLocalProjectResourceInput,
@@ -2589,6 +2589,7 @@ fn main() {
register_local_asset,
create_ui_design_resource,
update_local_project_resource_classification,
add_local_project_resource_tags,
derive_local_project_resource,
list_pending_local_project_resource_edits,
resume_local_project_resource_edit,
@@ -1343,6 +1343,240 @@ pub(crate) fn update_manifest_asset_classification_at(
})
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct AddLocalProjectResourceTagsInput {
pub(crate) project_path: String,
pub(crate) expected_project_id: String,
pub(crate) expected_project_revision: u64,
pub(crate) asset_ids: Vec<String>,
#[serde(default)]
pub(crate) tags: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AddLocalProjectResourceTagsResult {
pub(crate) assets: Vec<GameCreationAppAssetManifestEntry>,
pub(crate) committed_project_revision: u64,
}
/// 一次批量追加的素材上限:与主规范「每批最多 200 个不同素材」一致,按**去重后**数量计算。
/// 批次越大,锁内要重算的合并结果越多,manifest 也越大;无界批次会把成本摊到之后每一次读写上。
pub(crate) const ASSET_BATCH_TAG_MAX_ASSETS: usize = 200;
/// 批量追加标签的素材 ID 归一化:trim、按**首次出现顺序**去重,再在此处收口批次上下界。
///
/// 这里是"整句拒绝"的失败关闭口径,不做任何静默容忍:
///
/// - 空白 `assetId` 直接失败,不 `continue` 跳过。静默跳过会让"请求了 N 个素材"和"实际写了
/// N-1 个"分叉,而调用方拿到的仍是成功——这正是本合同要排除的静默部分写;
/// - 空批次失败;
/// - 去重后超限立即失败(在扫描到第 201 个不同 ID 时就返回,不对剩余 ID 继续做去重扫描),
/// 更不做"截断到 200 个":截断会让用户以为 250 个素材都加上了标签。
fn normalize_manifest_batch_asset_ids(asset_ids: &[String]) -> Result<Vec<String>, String> {
let mut normalized: Vec<String> = Vec::new();
for asset_id in asset_ids {
let asset_id = asset_id.trim();
if asset_id.is_empty() {
return Err("批量标签 assetId 不能为空".to_string());
}
if !normalized.iter().any(|existing| existing == asset_id) {
normalized.push(asset_id.to_string());
if normalized.len() > ASSET_BATCH_TAG_MAX_ASSETS {
return Err(format!(
"批量标签最多支持 {ASSET_BATCH_TAG_MAX_ASSETS} 个素材"
));
}
}
}
if normalized.is_empty() {
return Err("批量标签至少需要一个素材".to_string());
}
Ok(normalized)
}
/// 批量追加的标签归一化:沿用主规范的 trim / 去空 / 去重口径(复用
/// [`normalize_manifest_asset_tags`],其中已含数量与单标签长度收口)。
///
/// 只有**归一后为空**才拒绝:请求里全是空白标签时,用户填的东西一个字都不会落盘,
/// 此时若当成"成功且无变化"返回,界面会显示保存成功而素材上什么都没有。
fn normalize_manifest_batch_tags(tags: &[String]) -> Result<Vec<String>, String> {
let normalized = normalize_manifest_asset_tags(tags)?;
if normalized.is_empty() {
return Err("批量标签不能为空".to_string());
}
Ok(normalized)
}
/// 追加语义:只把请求里**尚不存在**的标签按请求顺序补到原有标签之后。
/// 原有标签的顺序、分类、类型、路径与来源都不参与改写——本命令没有删除或替换语义。
fn merge_manifest_asset_tags(existing: &[String], incoming: &[String]) -> Vec<String> {
let mut merged = existing.to_vec();
for tag in incoming {
if !merged.iter().any(|current| current == tag) {
merged.push(tag.clone());
}
}
merged
}
/// 锁内先算完的整批计划:任何一项缺失或超限都在这里失败,此时 manifest 一个字节都没动。
struct ManifestAssetTagAppendPlan {
/// 按请求顺序(去重后)返回的素材条目,标签为合并后的完整列表。
assets: Vec<GameCreationAppAssetManifestEntry>,
/// 真正需要落值的目标:`(assets 下标, 合并后的标签)`。
updates: Vec<(usize, Vec<String>)>,
/// 确实发生变化的素材 ID,供审计记录使用;空表示整批无变化。
changed_asset_ids: Vec<String>,
}
/// 先校验**全部**目标与**全部**合并结果,再决定是否写值。
///
/// 顺序是刻意的:第一阶段只读,任一目标不存在、任一合并结果超过标签上界都在写之前返回错误;
/// 只有全部通过,第二阶段才逐项落值。这样"缺任一资产 / 超限"都不可能留下部分写入。
fn plan_manifest_asset_tag_append(
manifest: &GameCreationAppManifest,
asset_ids: &[String],
tags: &[String],
) -> Result<ManifestAssetTagAppendPlan, String> {
let mut assets = Vec::with_capacity(asset_ids.len());
let mut updates: Vec<(usize, Vec<String>)> = Vec::with_capacity(asset_ids.len());
let mut changed_asset_ids = Vec::new();
for asset_id in asset_ids {
let index = manifest
.assets
.iter()
.position(|asset| &asset.id == asset_id)
.ok_or_else(|| format!("项目资源不存在:{asset_id}"))?;
let asset = &manifest.assets[index];
// 合并结果复用同一个上界函数:已有标签已归一化,这里等价于对整份新列表再收口一次。
// 上界函数只报"16 个"这种通用口径,200 个素材的批次里看不出是哪一项超了,所以在**调用点**
// 补上目标身份(ID + 可读 localPath)并说明整批未写:用户要能直接定位到那一张素材。
let merged = normalize_manifest_asset_tags(&merge_manifest_asset_tags(&asset.tags, tags))
.map_err(|error| {
format!(
"素材 {}{})的标签合并结果不合法:{error};本次未写入任何素材",
asset.id, asset.local_path
)
})?;
if merged != asset.tags {
changed_asset_ids.push(asset.id.clone());
}
updates.push((index, merged.clone()));
assets.push(GameCreationAppAssetManifestEntry {
tags: merged,
..asset.clone()
});
}
Ok(ManifestAssetTagAppendPlan {
assets,
updates,
changed_asset_ids,
})
}
/// 为一批已登记素材追加标签:一次校验、一次 manifest 写入、一次 revision 推进。
///
/// 语义与 [`update_manifest_asset_classification_at`] 同源(`asset.register` 权限位、项目身份、
/// 项目写锁、revision CAS、manifest 原子写、审计在 manifest 落盘之后 / revision 推进之前),
/// 但作用域是**整批**
///
/// - 项目身份校验两次(进入前与持锁后各一次),锁内按 `expectedProjectRevision` 做一次 CAS
/// - 锁内先算完整批计划,任一目标缺失或任一合并结果超限都**不写任何一项**;
/// - 整批无变化时**不写盘、不审计、不推进 revision**,直接返回当前条目与当前 revision;
/// - 真正有变化时才写一次 manifest、追加一条审计、推进一次 revision。
///
/// 已落盘之后的审计或 revision 失败照实报"整批已写入",不回滚、也不谎称回滚:manifest 是权威
/// 真相且已经改变,把错误说成"没写"只会让用户拿错状态去重试。
pub(crate) fn add_manifest_asset_tags_at(
root: &Path,
expected_project_id: &str,
expected_project_revision: u64,
asset_ids: Vec<String>,
tags: Vec<String>,
) -> Result<AddLocalProjectResourceTagsResult, String> {
if expected_project_revision
> shared_contracts::game_creation_app::GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION
{
return Err("expectedProjectRevision 超出 JavaScript 安全整数范围".to_string());
}
let expected_project_id = expected_project_id.trim();
if expected_project_id.is_empty() {
return Err("批量标签 expectedProjectId 不能为空".to_string());
}
let asset_ids = normalize_manifest_batch_asset_ids(&asset_ids)?;
let tags = normalize_manifest_batch_tags(&tags)?;
if read_existing_manifest_for_project(root)?.project_id != expected_project_id {
return Err("project-identity-conflict".to_string());
}
// 锁的 commandId 用本命令自己的动作名(审计/排障时能区分是批量追加还是别的写路径);
// 权限门面仍然是 `asset.register`,见 `commands.rs` 的命令包装层。
let _lock = acquire_project_write_lock(root, ASSET_BATCH_TAG_AUDIT_RECORD_TYPE)?;
if read_existing_manifest_for_project(root)?.project_id != expected_project_id {
return Err("project-identity-conflict".to_string());
}
if read_game_creator_agent_runtime_project_revision(root)?.revision != expected_project_revision
{
return Err("project-revision-conflict".to_string());
}
// no-op 判定发生在锁内、写盘之前:整批标签都已经存在时,连 manifest 都不必重写一次。
// 这不是优化洁癖——重写会换掉文件 mtime 与内容字节,让"什么都没做"看起来像一次真实改动。
let plan = plan_manifest_asset_tag_append(
&read_existing_manifest_for_project(root)?,
&asset_ids,
&tags,
)?;
if plan.changed_asset_ids.is_empty() {
return Ok(AddLocalProjectResourceTagsResult {
assets: plan.assets,
committed_project_revision: expected_project_revision,
});
}
let plan = mutate_manifest_at(root, |manifest| {
// 锁内复核:`mutate_manifest_at` 自己重新读盘,所以这里按同一套规则重算一遍再落值。
// 复核失败会在 `write_manifest_locked` 之前返回错误,仍然零写入;重算也保证不会拿
// 锁外算出的绝对标签列表去覆盖这份 manifest 上刚出现的新标签。
let plan = plan_manifest_asset_tag_append(manifest, &asset_ids, &tags)?;
for (index, merged) in &plan.updates {
manifest.assets[*index].tags = merged.clone();
}
Ok(plan)
})?;
// 复核阶段才发现"锁外以为有变化、锁内其实已无变化"的极端竞态:这一次写盘写出的就是原内容,
// 不能凭空补一条审计或推进 revision。正常路径不会走到这里——整批目标在此之前已经通过锁内 no-op 判定。
if plan.changed_asset_ids.is_empty() {
return Ok(AddLocalProjectResourceTagsResult {
assets: plan.assets,
committed_project_revision: expected_project_revision,
});
}
append_agent_db_record(
root,
serde_json::json!({
"recordType": ASSET_BATCH_TAG_AUDIT_RECORD_TYPE,
"assetIds": plan.changed_asset_ids,
"expectedProjectRevision": expected_project_revision,
"appendedTags": tags,
}),
)
.map_err(|error| format!("批量标签已写入,但审计记录失败:{error}"))?;
let committed_project_revision = advance_agent_runtime_project_revision_locked(root)
.map_err(|error| format!("批量标签已写入,但项目 revision 未能推进:{error}"))?;
Ok(AddLocalProjectResourceTagsResult {
assets: plan.assets,
committed_project_revision,
})
}
/// 批量标签写入的审计类型:一次批量追加只留一条记录,装的是"谁被追加了什么"。
pub(crate) const ASSET_BATCH_TAG_AUDIT_RECORD_TYPE: &str = "asset.tags.append";
pub(crate) fn create_manifest_task_at(
root: &Path,
task_id: &str,
@@ -1,4 +1,4 @@
import { Download, Upload, X } from 'lucide-react';
import { Download, ListFilter, Upload, X } from 'lucide-react';
import { useEffect, useId, useRef } from 'react';
import type { ResourceCanvasPanelEntry } from './resourceCanvasAssetTransferModel';
@@ -20,6 +20,16 @@ export type ResourceCanvasPanelViewProps = {
onToggleEntry: (resourceId: string) => void;
onSelectAll: () => void;
onClearSelection: () => void;
/**
* 批量追加标签入口。由宿主给出「当前完整选中集」解析出的实际目标数量与禁用原因:
* 数量按去重后的已登记素材算(跨筛选保留的选择也算在内),不是只算面板可见项。
* 未传时动作行不出现该入口。
*/
batchTags?: {
targetCount: number;
blockedReason: string | null;
onOpen: () => void;
};
onUploadFiles: (files: FileList) => void;
onDownloadSelection: () => void;
isUploading: boolean;
@@ -38,6 +48,7 @@ export function ResourceCanvasPanelView({
onToggleEntry,
onSelectAll,
onClearSelection,
batchTags,
onUploadFiles,
onDownloadSelection,
isUploading,
@@ -140,6 +151,23 @@ export function ResourceCanvasPanelView({
>
</button>
{batchTags ? (
<button
type="button"
className="game-resource-panel-batch-tags"
// 禁用原因走 title + 下方可见提示:混合选择(版本 / 未登记附件 /
// 已删除资源)或超限时不能只对其中一部分静默保存,
// 所以入口直接禁用而不是点进去再失败。
title={batchTags.blockedReason ?? undefined}
disabled={batchTags.blockedReason !== null}
onClick={batchTags.onOpen}
>
<ListFilter size={15} aria-hidden="true" />
{batchTags.targetCount > 1
? `批量标签(${batchTags.targetCount}`
: '批量标签'}
</button>
) : null}
<button
type="button"
className="game-resource-panel-download"
@@ -155,6 +183,13 @@ export function ResourceCanvasPanelView({
</button>
</div>
{batchTags?.blockedReason ? (
<p
className="game-resource-panel-batch-tag-reason"
role="status"
>{`批量标签不可用:${batchTags.blockedReason}`}</p>
) : null}
{notice ? (
<p className="game-resource-panel-notice" role="status">
{notice}
@@ -1092,6 +1092,12 @@
font-size: 0.78rem;
}
.game-resource-panel-batch-tag-reason {
margin: 0;
color: #8c6252;
font-size: 0.78rem;
}
.game-resource-panel-empty {
margin: 0;
color: #8c6252;
@@ -1,7 +1,7 @@
import '../../features/project-workspace/resourceClassificationTagPanel.css';
import { X } from 'lucide-react';
import { useState } from 'react';
import { useRef, useState } from 'react';
import { PlatformActionButton } from '../../../../../packages/shared/src/components/PlatformActionButton';
import { PlatformPillBadge } from '../../../../../packages/shared/src/components/PlatformPillBadge';
@@ -21,6 +21,15 @@ type UpdateLocalProjectResourceClassificationResult = {
committedProjectRevision: number;
};
/**
* 批量追加标签的原生响应:`assets` 是按去重后请求 ID 首现顺序返回的整组最新条目,
* `committedProjectRevision` 是本批写入后的项目 revision(整批无变化时是当前值)。
*/
export type AddLocalProjectResourceTagsResult = {
assets: GameCreationAppAssetManifestEntry[];
committedProjectRevision: number;
};
/**
* 标签草稿沿用写入路径的归一化边界,只按中英文逗号、顿号与换行切分。
* 与输入框旧的"整段逗号分隔文本"口径完全一致,改动只是把结果换成逐个可删的 pill。
@@ -41,27 +50,128 @@ function mergeResourceClassificationTagDraft(
return next;
}
function resourceClassificationErrorMessage(error: unknown) {
function resourceClassificationErrorMessage(
error: unknown,
fallback = '保存素材标签失败',
) {
// 项目身份 / 版本 CAS 拒绝翻成用户可读中文,其余原样透出;
// 与重命名、删除共用同一份映射。
return projectAssetCommandErrorMessage(error, '保存素材标签失败');
return projectAssetCommandErrorMessage(error, fallback);
}
type ResourceClassificationPanelProps = {
/**
* 标签 pill 列表:单素材模式渲染**已有标签**(逐项可删),批量模式渲染**待追加草稿**
* (逐项可删,但删的是草稿、不是素材上已落盘的标签)。
*
* 两个模式共用这一份实现:同在「编辑素材标签」面板内,不需要为此抽到 `packages/shared`
* 真有第二个宿主面板用到带删除按钮的 pill 时再抽 `PlatformRemovableTagPill`,不要复制。
*/
function ResourceTagPillList({
ariaLabel,
removeAriaLabel,
tags,
onRemove,
disabled = false,
}: {
ariaLabel: string;
removeAriaLabel: (tag: string) => string;
tags: readonly string[];
onRemove: (tag: string) => void;
/** 保存期间锁住 pill 上的删除(批量模式用;单素材模式保持既有行为不传)。 */
disabled?: boolean;
}) {
if (tags.length === 0) return null;
return (
<ul className="game-resource-tag-list" aria-label={ariaLabel}>
{tags.map((tag) => (
<li key={tag}>
<PlatformPillBadge
tone="warning"
size="xs"
className="game-resource-tag-pill"
>
{tag}
<button
type="button"
className="game-resource-tag-remove"
aria-label={removeAriaLabel(tag)}
disabled={disabled}
onClick={() => onRemove(tag)}
>
<X size={11} aria-hidden="true" />
</button>
</PlatformPillBadge>
</li>
))}
</ul>
);
}
type ResourceClassificationPanelCommonProps = {
projectPath: string;
projectId: string;
asset: GameCreationAppAssetManifestEntry;
onClose: () => void;
onSaved: (result: UpdateLocalProjectResourceClassificationResult) => void;
};
export function ResourceClassificationPanel({
type ResourceSingleClassificationPanelProps =
ResourceClassificationPanelCommonProps & {
mode?: 'single';
asset: GameCreationAppAssetManifestEntry;
onSaved: (result: UpdateLocalProjectResourceClassificationResult) => void;
};
type ResourceBatchClassificationPanelProps =
ResourceClassificationPanelCommonProps & {
mode: 'batch';
/**
* 打开面板时由宿主冻结的目标集(去重、首现顺序)。本面板只读第一帧的值:
* 之后画布选中或资源面板筛选再变,也不改这一批的写入对象。
*/
assetIds: readonly string[];
onSaved: (result: AddLocalProjectResourceTagsResult) => void;
};
export type ResourceClassificationPanelProps =
| ResourceSingleClassificationPanelProps
| ResourceBatchClassificationPanelProps;
function dedupeResourceAssetIds(assetIds: readonly string[]) {
const seen = new Set<string>();
const deduped: string[] = [];
for (const assetId of assetIds) {
if (seen.has(assetId)) continue;
seen.add(assetId);
deduped.push(assetId);
}
return deduped;
}
/**
* 「编辑素材标签」面板:单素材模式与批量模式共用同一个面板骨架、同一套标签草稿
* 拆分口径和同一把保存锁,只有编辑对象与提交命令不同。
*
* - 单素材:既有增删标签行为不变(写入命令 `update_local_project_resource_classification`)。
* - 批量:只把草稿里的标签**追加**到整组冻结素材(写入命令 `add_local_project_resource_tags`),
* 面板里不显示、也不允许删除各素材已有标签,更不会把已有标签并集当作提交值。
*/
export function ResourceClassificationPanel(
props: ResourceClassificationPanelProps,
) {
return props.mode === 'batch' ? (
<ResourceBatchClassificationPanel {...props} />
) : (
<ResourceSingleClassificationPanel {...props} />
);
}
/** 单素材标签编辑:既有增删标签行为,一次一份素材。 */
function ResourceSingleClassificationPanel({
projectPath,
projectId,
asset,
onClose,
onSaved,
}: ResourceClassificationPanelProps) {
}: ResourceSingleClassificationPanelProps) {
/**
* 本面板只编辑标签:素材类型(功能分类)在「设置素材类型」面板里单独设置。
*
@@ -182,31 +292,12 @@ export function ResourceClassificationPanel({
工具条上。曾长在这里的类型 chip 只改本地 state、不落盘,保存又只能借道标签的
「添加」,导致"改了类型没生效"。
*/}
{tags.length > 0 ? (
<ul className="game-resource-tag-list" aria-label="已有标签">
{tags.map((tag) => (
// 单点使用,先不抽到 packages/shared。若第二处出现带删除按钮的标签 pill,
// 抽到 `packages/shared` 做 `PlatformRemovableTagPill`,不要复制这份实现。
<li key={tag}>
<PlatformPillBadge
tone="warning"
size="xs"
className="game-resource-tag-pill"
>
{tag}
<button
type="button"
className="game-resource-tag-remove"
aria-label={`删除标签 ${tag}`}
onClick={() => removeTag(tag)}
>
<X size={11} aria-hidden="true" />
</button>
</PlatformPillBadge>
</li>
))}
</ul>
) : null}
<ResourceTagPillList
ariaLabel="已有标签"
removeAriaLabel={(tag) => `删除标签 ${tag}`}
tags={tags}
onRemove={removeTag}
/>
<PlatformTextField
aria-label="新增标签"
placeholder="新增标签,多个用逗号分隔"
@@ -245,3 +336,184 @@ export function ResourceClassificationPanel({
</ThemedModal>
);
}
/**
* 批量追加标签:编辑对象是打开面板时冻结的整组素材 ID。
*
* 面板里**只有待追加草稿** —— 各素材已有标签的并集既不显示、也不进入提交值,
* 因为「把并集分发给每一项」会给每份素材都补上别人的标签。保存就是一次
* `add_local_project_resource_tags`,绝不逐素材循环调用单素材写入命令。
*/
function ResourceBatchClassificationPanel({
projectPath,
projectId,
assetIds,
onClose,
onSaved,
}: ResourceBatchClassificationPanelProps) {
/**
* 冻结目标集:只取第一帧。宿主在打开时已经快照了一份,这里再冻一次,
* 保证「面板已开、用户又改了画布选中或资源面板筛选」时这一批的写入对象不变。
*/
const [targetAssetIds] = useState(() => dedupeResourceAssetIds(assetIds));
const [draftTags, setDraftTags] = useState<string[]>([]);
const [tagDraft, setTagDraft] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
/**
* 同一事件里重复提交的同步兜底:`saving` 是 state,按钮的 `disabled` 要等下一次
* 渲染才生效,双击 / 回车与点击连着来时会各发一次请求。这里用 ref 立刻上锁。
*/
const inFlightRef = useRef(false);
/** 与单素材模式同一份切分口径:回车 / 中英文逗号 / 顿号把草稿落成待追加 pill。 */
function commitTagDraft() {
// 保存在飞时输入框已禁用,这里再兜一次:草稿改了也不会被提交,清掉只会误导用户。
if (saving) return;
if (!tagDraft.trim()) return;
setDraftTags((current) =>
mergeResourceClassificationTagDraft(current, tagDraft),
);
setTagDraft('');
}
function removeDraftTag(tag: string) {
if (saving) return;
setDraftTags((current) => current.filter((item) => item !== tag));
}
async function appendResourceTags(tagsToAppend: readonly string[]) {
const tags = normalizeGameCreationAppAssetTags(tagsToAppend);
// 空草稿没有可追加内容:不提交、不读 revision,避免"空操作也报保存"。
if (tags.length === 0) return;
if (inFlightRef.current) return;
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
setError('批量追加标签需要在客户端内保存');
return;
}
inFlightRef.current = true;
setSaving(true);
setError(null);
try {
// 与单素材写入同一口径:先读项目 revision,再带项目身份与版本 CAS 提交。
const status = await invoke<{ revision: number }>(
'get_local_game_project_revision',
{ projectPath },
);
if (!Number.isSafeInteger(status.revision) || status.revision < 0) {
throw new Error('项目 revision 无效');
}
const result = await invoke<AddLocalProjectResourceTagsResult>(
'add_local_project_resource_tags',
{
input: {
projectPath,
expectedProjectId: projectId,
expectedProjectRevision: status.revision,
assetIds: targetAssetIds,
tags,
},
},
);
// 只有原生确认写入后才清草稿:失败(含 CAS 冲突)保留待追加标签供直接重试。
setDraftTags([]);
setTagDraft('');
onSaved(result);
} catch (saveError) {
setError(resourceClassificationErrorMessage(saveError, '批量追加标签失败'));
} finally {
inFlightRef.current = false;
setSaving(false);
}
}
/** 底部唯一的「追加标签」= 把输入框尾巴(含没按回车的部分)落成 pill,然后一次保存。 */
async function appendDraftAndSave() {
const tagsToAppend = mergeResourceClassificationTagDraft(
draftTags,
tagDraft,
);
setDraftTags(tagsToAppend);
setTagDraft('');
await appendResourceTags(tagsToAppend);
}
const hasTagsToAppend =
normalizeGameCreationAppAssetTags(
mergeResourceClassificationTagDraft(draftTags, tagDraft),
).length > 0;
return (
<ThemedModal
open
ariaLabel="批量追加标签"
onClose={onClose}
// 与单素材模式同一把保存锁:保存在飞时 Escape / 点遮罩 / 头部 × 都不能关。
closeOnBackdrop={!saving}
closeOnEscape={!saving}
panelClassName="game-approval-dialog game-resource-classification-dialog"
>
<header>
<div>
<h2></h2>
<p>{`已选 ${targetAssetIds.length} 项素材`}</p>
</div>
<button
type="button"
aria-label="关闭批量追加标签"
disabled={saving}
onClick={onClose}
>
×
</button>
</header>
<div className="game-resource-classification-body">
{/*
只渲染待追加草稿:素材原有标签既不展示也不参与提交。删除按钮在这里删的是
草稿,不是已落盘的标签 —— 批量删除既有标签不在本次范围。
*/}
<ResourceTagPillList
ariaLabel="待追加标签"
removeAriaLabel={(tag) => `移除待追加标签 ${tag}`}
tags={draftTags}
onRemove={removeDraftTag}
// 保存在飞时不许改草稿:新输入的标签不会被这次提交带上,成功后又会被清空,
// 用户会以为"改了但没保存"。锁住输入与删除,语义才是"这一批正在写"。
disabled={saving}
/>
<PlatformTextField
aria-label="新增标签"
placeholder="新增标签,多个用逗号分隔"
value={tagDraft}
disabled={saving}
onChange={(event) => setTagDraft(event.currentTarget.value)}
onBlur={commitTagDraft}
onKeyDown={(event) => {
if (
event.key === 'Enter' ||
event.key === ',' ||
event.key === ''
) {
event.preventDefault();
commitTagDraft();
}
}}
/>
{error ? (
<p className="game-resource-classification-error" role="alert">
{error}
</p>
) : null}
</div>
<footer className="game-resource-classification-footer">
<PlatformActionButton
onClick={() => void appendDraftAndSave()}
disabled={saving || !hasTagsToAppend}
>
</PlatformActionButton>
</footer>
</ThemedModal>
);
}
@@ -247,6 +247,7 @@ import {
uploadProjectAssetFilesAndReadSnapshot,
} from './projectResourceLiveUpdateModel';
import { ResourceAssetDeleteDialog } from './ResourceAssetDeleteDialog';
import { resolveResourceBatchTagTargets } from './resourceBatchTagTargetModel';
import {
createResourceBookTransitionController,
type ResourceBookTransitionController,
@@ -2104,6 +2105,25 @@ export default function ProjectDevelopmentView({
useState(false);
const [resourceClassificationAssetId, setResourceClassificationAssetId] =
useState<string | null>(null);
/**
* ID +
*
* revision manifest
* `projectPath`
* ID
*/
const [resourceBatchTagsTarget, setResourceBatchTagsTarget] = useState<{
assetIds: string[];
projectPath: string;
projectId: string;
/**
* await + manifest
* + ID A B A
*/
epoch: number;
} | null>(null);
/** 批量标签的项目代次:项目身份一变就 +1(切走再切回也算两代)。 */
const resourceBatchTagsEpochRef = useRef(0);
/**
* `resourceClassificationAssetId`
* 宿
@@ -2506,6 +2526,16 @@ export default function ProjectDevelopmentView({
[],
);
/**
* await
* A B A +2
*/
if (
currentProjectIdentityRef.current.projectPath !== projectPath ||
currentProjectIdentityRef.current.projectId !== manifest.projectId
) {
resourceBatchTagsEpochRef.current += 1;
}
currentProjectIdentityRef.current = {
projectPath,
projectId: manifest.projectId,
@@ -2732,11 +2762,44 @@ export default function ProjectDevelopmentView({
*
*/
const canvasResources = resources;
/**
* ****
* 2 /
*
*/
const resourceBatchTagTargets = useMemo(
() => resolveResourceBatchTagTargets(canvasResources, selectedResourceIds),
[canvasResources, selectedResourceIds],
);
/** 资源画布选中态:单选是长度为 1 的数组,多选/框选保持同一份状态。 */
const selectedResourceId = selectedResourceIds[0] ?? null;
const selectedResource =
canvasResources.find((resource) => resource.id === selectedResourceId) ??
null;
/**
*
* - 1
* - 2
* - /
*/
const resourceTagEditorEntry = useMemo(() => {
if (selectedResourceIds.length >= 2) {
return resourceBatchTagTargets.ok
? { mode: 'batch' as const, disabledReason: null, assetId: null }
: {
mode: 'batch' as const,
disabledReason: resourceBatchTagTargets.reason,
assetId: null,
};
}
return selectedResource?.manifestAssetId
? {
mode: 'single' as const,
disabledReason: null,
assetId: selectedResource.manifestAssetId,
}
: null;
}, [resourceBatchTagTargets, selectedResource, selectedResourceIds.length]);
/**
*
*
@@ -2987,6 +3050,10 @@ export default function ProjectDevelopmentView({
useEffect(() => {
setResourceVersionReplacementLineage(null);
}, [manifest.projectId, projectPath]);
/** 批量标签的冻结目标不跨项目:切项目就关掉面板(迟到回包的丢弃见保存处理)。 */
useEffect(() => {
setResourceBatchTagsTarget(null);
}, [manifest.projectId, projectPath]);
useEffect(() => {
const report = dependencyLayout.readReport ?? typeLayout.readReport;
if (
@@ -3617,12 +3684,24 @@ export default function ProjectDevelopmentView({
*
* `true`宿`resourceClassificationAssetId`
* `onClose`
*
* `options.isStillCurrent`****
* "回读期间切项目" `get_local_game_manifest`
* `onManifestChange`
*
*
* `options.readBackFailureNotice`
* "标签已保存,但刷新失败"
*/
const reloadManifestAfterAssetCommand = useCallback(
async (
committedProjectRevision: number,
commitId: string,
options: { keepClassificationPanelOpen?: boolean } = {},
options: {
keepClassificationPanelOpen?: boolean;
isStillCurrent?: () => boolean;
readBackFailureNotice?: (error: unknown) => string;
} = {},
) => {
if (!options.keepClassificationPanelOpen) {
setResourceClassificationAssetId(null);
@@ -3635,6 +3714,8 @@ export default function ProjectDevelopmentView({
'get_local_game_manifest',
{ projectPath, commandId: 'asset.list' },
);
// 回读期间项目可能已经切换:旧项目的整份清单绝不能写进新项目。
if (options.isStillCurrent && !options.isStillCurrent()) return;
if (next.projectId !== manifest.projectId) return;
onManifestChange(projectPath, next, {
projectId: next.projectId,
@@ -3644,7 +3725,11 @@ export default function ProjectDevelopmentView({
});
} catch (error) {
setResourceWorkbenchNotice(
error instanceof Error ? error.message : String(error),
options.readBackFailureNotice
? options.readBackFailureNotice(error)
: error instanceof Error
? error.message
: String(error),
);
}
},
@@ -3664,6 +3749,80 @@ export default function ProjectDevelopmentView({
},
[reloadManifestAfterAssetCommand],
);
/**
* ID +
*
*
*
*/
const openResourceBatchTags = useCallback(() => {
if (!resourceBatchTagTargets.ok) {
setResourceWorkbenchNotice(resourceBatchTagTargets.reason);
return false;
}
setResourceBatchTagsTarget({
assetIds: [...resourceBatchTagTargets.assetIds],
projectPath,
projectId: manifest.projectId,
epoch: resourceBatchTagsEpochRef.current,
});
return true;
}, [manifest.projectId, projectPath, resourceBatchTagTargets]);
/**
* 宿
*
* revision ** manifest **`get_local_game_manifest` +
* `onManifestChange` manifest
*
*
* `reloadManifestAfterAssetCommand`
* "没改过"
*
* await manifest
* ** + + ID** ID A B A
* "对上身份"
*/
const handleResourceBatchTagsSaved = useCallback(
async (
target: { projectPath: string; projectId: string; epoch: number },
result: {
assets: GameCreationAppAssetManifestEntry[];
committedProjectRevision: number;
},
) => {
const isTargetCurrent = () => {
const current = currentProjectIdentityRef.current;
return (
resourceBatchTagsEpochRef.current === target.epoch &&
current.projectPath === target.projectPath &&
current.projectId === target.projectId
);
};
/**
*
* manifest `onManifestChange`
*/
if (!isTargetCurrent()) return;
await reloadManifestAfterAssetCommand(
result.committedProjectRevision,
`asset-tags-batch:${result.assets.map((asset) => asset.id).join(',')}`,
// 与单素材标签一致:保存成功后保持面板打开(草稿已清空,可继续追加下一批),
// 关闭只走头部 ×。
{
keepClassificationPanelOpen: true,
// 回读完成后再判一次:回读期间切项目时,回来的是旧项目的整份清单。
isStillCurrent: isTargetCurrent,
// 原生已经写入成功,只是整份 manifest 没读回来:说清"已保存、刷新失败",
// 不给用户"保存失败、其实已落盘"的错觉。
readBackFailureNotice: (error) =>
`标签已保存,但刷新失败:${
error instanceof Error ? error.message : String(error)
}`,
},
);
},
[reloadManifestAfterAssetCommand],
);
const handleResourceClassificationDeleted = useCallback(
async (result: { assetId: string; committedProjectRevision: number }) => {
await reloadManifestAfterAssetCommand(
@@ -9052,7 +9211,14 @@ export default function ProjectDevelopmentView({
selectedResource &&
isResourceDocumentPreviewable(selectedResource),
) ||
selectedResourceOpensUiEditor) ? (
selectedResourceOpensUiEditor ||
/*
* /
*
* "为什么不能批量写"
*
*/
resourceTagEditorEntry !== null) ? (
<ImageCanvasSelectedLayerToolbarView
maxVisibleActions={5}
selectedLayer={selectedResourceLayer}
@@ -9155,17 +9321,32 @@ export default function ProjectDevelopmentView({
<span></span>
</CanvasChromeButton>
) : null}
{selectedResource?.manifestAssetId ? (
{resourceTagEditorEntry ? (
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
label="编辑标签"
title="编辑标签"
icon={<ListFilter className="h-4 w-4" />}
onClick={() =>
setResourceClassificationAssetId(
selectedResource.manifestAssetId,
)
// 多选无法整批写入(混入版本 / 未登记素材或超限)时
// 入口禁用,并把原因挂在 title 上;绝不静默只改首项。
title={
resourceTagEditorEntry.disabledReason ??
'编辑标签'
}
disabled={
resourceTagEditorEntry.disabledReason !==
null
}
icon={<ListFilter className="h-4 w-4" />}
onClick={() => {
if (
resourceTagEditorEntry.mode === 'batch'
) {
openResourceBatchTags();
return;
}
setResourceClassificationAssetId(
resourceTagEditorEntry.assetId,
);
}}
>
<span></span>
</CanvasChromeButton>
@@ -10083,6 +10264,28 @@ export default function ProjectDevelopmentView({
)
}
onClearSelection={() => setSelectedResourceIds([])}
batchTags={{
// 数量按完整选中集解析出的**实际目标**算:跨筛选保留的选择也要进这个数字,
// 不能只算面板当前可见项。
targetCount: resourceBatchTagTargets.ok
? resourceBatchTagTargets.assetIds.length
: 0,
// 空选择不给原因(面板刚打开时本来就没什么可选);有选择但解析不过
// (版本 / 未登记素材、不足 2 项、超限)才显示禁用原因。
blockedReason:
selectedResourceIds.length === 0 || resourceBatchTagTargets.ok
? null
: resourceBatchTagTargets.reason,
onOpen: () => {
/*
* setState React
* window Escape
* Escape
* /宿
*/
if (openResourceBatchTags()) setResourcePanelOpen(false);
},
}}
onUploadFiles={(files) => void uploadResourcePanelFiles(files)}
onDownloadSelection={() =>
void downloadResourcePanelEntries(selectedResourcePanelEntries)
@@ -10106,6 +10309,23 @@ export default function ProjectDevelopmentView({
onSaved={(result) => void handleResourceClassificationSaved(result)}
/>
) : null}
{/*
`resourceBatchTagsTarget`
*/}
{resourceBatchTagsTarget ? (
<ResourceClassificationPanel
key={`batch:${resourceBatchTagsTarget.assetIds.join(',')}`}
mode="batch"
projectPath={resourceBatchTagsTarget.projectPath}
projectId={resourceBatchTagsTarget.projectId}
assetIds={resourceBatchTagsTarget.assetIds}
onClose={() => setResourceBatchTagsTarget(null)}
onSaved={(result) =>
void handleResourceBatchTagsSaved(resourceBatchTagsTarget, result)
}
/>
) : null}
{/*
=
"选中即落盘"宿
@@ -0,0 +1,84 @@
/**
* 多选素材批量追加标签的**目标集解析**(纯模型)。
*
* 批量标签只对「当前入口展示的完整选中集合」生效,且整批要么全写、要么不写。
* 因此打开面板前必须先把选中集解析成一份**去重后的 manifest 资产 ID 列表**
* 或者给出一个明确的禁用原因:
*
* - 选择集里含未登记素材(项目版本 / 附件 / 任务产物 / Agent 回执)时不能只写其中一部分;
* - 选择集里含已被删除、投影里已不存在的 ID 时同上;
* - 去重后不足 2 项、超过批次上限时也不进入批量模式。
*
* 返回顺序固定为「去重后请求 ID 的首次出现顺序」,与原生返回条目的顺序口径一致。
* 这里不读 manifest、不碰 Tauri,只吃投影与选中 ID,方便直接钉住上述判定。
*/
/** 每批最多 200 个不同素材(按去重后的资产 ID 数量计算)。 */
export const RESOURCE_BATCH_TAG_MAX_ASSETS = 200;
/** 批量标签的目标:打开面板时冻结的资产 ID 列表(首现顺序、已去重)。 */
export type ResourceBatchTagTarget = {
assetIds: string[];
};
export type ResourceBatchTagTargetResolution =
| { ok: true; assetIds: string[] }
| { ok: false; reason: string };
export function resolveResourceBatchTagTargets(
resources: readonly {
id: string;
manifestAssetId: string | null;
}[],
selectedResourceIds: readonly string[],
): ResourceBatchTagTargetResolution {
const selected = new Set<string>();
const selectedIds: string[] = [];
for (const resourceId of selectedResourceIds) {
if (selected.has(resourceId)) continue;
selected.add(resourceId);
selectedIds.push(resourceId);
}
const resourcesById = new Map(resources.map((item) => [item.id, item]));
const missingIds = selectedIds.filter(
(resourceId) => !resourcesById.has(resourceId),
);
if (missingIds.length > 0) {
return {
ok: false,
reason: '选择里含已被删除的素材,不能只保存其中一部分;请重新选择',
};
}
const registeredIds: string[] = [];
let hasUnregistered = false;
for (const resourceId of selectedIds) {
const assetId = resourcesById.get(resourceId)?.manifestAssetId ?? null;
if (!assetId) {
hasUnregistered = true;
continue;
}
if (!registeredIds.includes(assetId)) registeredIds.push(assetId);
}
if (hasUnregistered) {
return {
ok: false,
reason:
'选择里含未登记素材(项目版本 / 附件 / 任务产物),不能只保存其中一部分;请排除后重试',
};
}
if (registeredIds.length < 2) {
return {
ok: false,
reason: '批量追加标签至少要选择 2 项已登记素材',
};
}
if (registeredIds.length > RESOURCE_BATCH_TAG_MAX_ASSETS) {
return {
ok: false,
reason: `每批最多 ${RESOURCE_BATCH_TAG_MAX_ASSETS} 项素材,请缩小选择`,
};
}
return { ok: true, assetIds: registeredIds };
}
@@ -0,0 +1,92 @@
import { describe, expect, test } from 'vitest';
import {
resolveResourceBatchTagTargets,
RESOURCE_BATCH_TAG_MAX_ASSETS,
} from '../src/view/project-development/resourceBatchTagTargetModel';
function registered(id: string, assetId: string) {
return { id, manifestAssetId: assetId };
}
describe('resolveResourceBatchTagTargets', () => {
test('按选中集首现顺序给出去重后的资产 ID,顺序不按 manifest 排', () => {
const resolution = resolveResourceBatchTagTargets(
[registered('asset:bg', 'asset-bg'), registered('asset:hero', 'asset-hero')],
['asset:hero', 'asset:bg', 'asset:hero'],
);
expect(resolution).toEqual({ ok: true, assetIds: ['asset-hero', 'asset-bg'] });
});
test('不足 2 项已登记素材时不进入批量模式', () => {
const resolution = resolveResourceBatchTagTargets(
[registered('asset:hero', 'asset-hero')],
['asset:hero'],
);
expect(resolution.ok).toBe(false);
});
test('混入未登记素材(项目版本 / 附件 / 任务产物)时整批拒绝而不是只写已登记项', () => {
const resolution = resolveResourceBatchTagTargets(
[
registered('asset:hero', 'asset-hero'),
registered('asset:npc', 'asset-npc'),
{ id: 'version:v1', manifestAssetId: null },
],
['asset:hero', 'asset:npc', 'version:v1'],
);
expect(resolution.ok).toBe(false);
if (resolution.ok) throw new Error('unreachable');
expect(resolution.reason).toContain('未登记素材');
});
test('选中集里的 ID 在投影里已不存在(素材被删)时同样拒绝', () => {
const resolution = resolveResourceBatchTagTargets(
[registered('asset:hero', 'asset-hero')],
['asset:hero', 'asset:deleted'],
);
expect(resolution.ok).toBe(false);
if (resolution.ok) throw new Error('unreachable');
expect(resolution.reason).toContain('已被删除');
});
test(`去重后超过 ${RESOURCE_BATCH_TAG_MAX_ASSETS} 项时按上限拒绝`, () => {
const resources = Array.from(
{ length: RESOURCE_BATCH_TAG_MAX_ASSETS + 1 },
(_, index) => registered(`asset:a${index}`, `asset-a${index}`),
);
const resolution = resolveResourceBatchTagTargets(
resources,
resources.map((item) => item.id),
);
expect(resolution.ok).toBe(false);
if (resolution.ok) throw new Error('unreachable');
expect(resolution.reason).toContain(String(RESOURCE_BATCH_TAG_MAX_ASSETS));
const atLimit = resources.slice(0, RESOURCE_BATCH_TAG_MAX_ASSETS);
expect(
resolveResourceBatchTagTargets(
atLimit,
atLimit.map((item) => item.id),
).ok,
).toBe(true);
});
test('多个资源 ID 指向同一个资产时按资产去重,不把同一份素材算两遍', () => {
const resolution = resolveResourceBatchTagTargets(
[
registered('asset:hero', 'asset-hero'),
registered('attachment:hero', 'asset-hero'),
],
['asset:hero', 'attachment:hero'],
);
// 去重后只剩 1 份素材:批量模式没有可写入的整组,直接拒绝。
expect(resolution.ok).toBe(false);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,126 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
resolveResourceCanvasPanelEntries,
} from '../src/features/resource-canvas/resourceCanvasAssetTransferModel';
import { ResourceCanvasPanelView } from '../src/features/resource-canvas/ResourceCanvasPanelView';
import type { ProjectResource } from '../src/view/project-development/resourceProjectionModel';
afterEach(cleanup);
function createResource(overrides: Partial<ProjectResource> = {}): ProjectResource {
return {
id: 'asset:hero',
category: 'character',
subtype: 'character',
label: '主角立绘',
path: 'assets/hero.png',
mediaType: 'image/png',
sourceLabel: '生成',
taskTitle: null,
manifestAssetId: 'asset-hero',
producerTaskId: null,
externalResourceId: null,
referenceResourceIds: [],
dependencies: [],
dependencyDepth: 0,
...overrides,
};
}
const entries = resolveResourceCanvasPanelEntries([
{
resource: createResource(),
categoryLabel: '角色与对象',
typeLabel: '图片',
previewIdentity: null,
previewStatus: 'idle',
previewSourceUrl: null,
previewError: null,
},
{
resource: createResource({
id: 'asset:npc',
label: '配角立绘',
path: 'assets/npc.png',
manifestAssetId: 'asset-npc',
}),
categoryLabel: '角色与对象',
typeLabel: '图片',
previewIdentity: null,
previewStatus: 'idle',
previewSourceUrl: null,
previewError: null,
},
]);
function renderPanel(
overrides: {
batchTags?: {
targetCount: number;
blockedReason: string | null;
onOpen: () => void;
};
} = {},
) {
render(
<ResourceCanvasPanelView
entries={entries}
selectedResourceIds={[entries[0]!.resourceId, entries[1]!.resourceId]}
onToggleEntry={vi.fn()}
onSelectAll={vi.fn()}
onClearSelection={vi.fn()}
onUploadFiles={vi.fn()}
onDownloadSelection={vi.fn()}
isUploading={false}
notice=""
onClose={vi.fn()}
{...overrides}
/>,
);
return within(screen.getByRole('dialog', { name: '资源面板' }));
}
describe('资源面板动作行的批量标签入口', () => {
it('入口显示完整选中集解析出的实际目标数量,而不是面板可见项数量', () => {
const onOpen = vi.fn();
const panel = renderPanel({
// 面板可见 2 项,但本次选择跨筛选保留了 5 项:数字必须按实际目标显示。
batchTags: { targetCount: 5, blockedReason: null, onOpen },
});
fireEvent.click(panel.getByRole('button', { name: '批量标签(5' }));
expect(onOpen).toHaveBeenCalledTimes(1);
});
it('混合选择或超限时入口禁用,并把原因显示在动作行下方', () => {
const onOpen = vi.fn();
const panel = renderPanel({
batchTags: {
targetCount: 0,
blockedReason:
'选择里含未登记素材(项目版本 / 附件 / 任务产物),不能只保存其中一部分;请排除后重试',
onOpen,
},
});
const button = panel.getByRole('button', { name: '批量标签' }) as HTMLButtonElement;
expect(button.disabled).toBe(true);
expect(button.title).toContain('未登记素材');
expect(
panel.getByText(//u),
).not.toBeNull();
fireEvent.click(button);
expect(onOpen).not.toHaveBeenCalled();
});
it('宿主没给批量入口(未接线)时动作行不出现该按钮', () => {
const panel = renderPanel();
expect(panel.queryByRole('button', { name: '批量标签' })).toBeNull();
expect(panel.getByRole('button', { name: '清空选择' })).not.toBeNull();
});
});
@@ -439,8 +439,13 @@ describe('ResourceClassificationPanel 编辑素材标签', () => {
);
// 工具条写着「分类与标签」却打开纯标签面板会误导用户,入口必须与面板同名。
// 多选无法整批写入(混入版本 / 未登记素材、超限)时 title 换成禁用原因,
// 所以这里钉的是「可用态的入口名」与「必须有禁用原因分支」,不再钉 title 字面量。
expect(viewSource).toContain('label="编辑标签"');
expect(viewSource).toContain('title="编辑标签"');
expect(viewSource).toContain("'编辑标签'");
expect(viewSource).toMatch(
/title=\{\s*resourceTagEditorEntry\.disabledReason \?\?\s*''/u,
);
expect(viewSource).toContain('<span>编辑标签</span>');
expect(viewSource).not.toContain('label="分类与标签"');
});
@@ -0,0 +1,346 @@
// @vitest-environment jsdom
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, test, vi } from 'vitest';
import { ResourceClassificationPanel } from '../src/view/project-development/ResourceClassificationPanel';
function installInvoke(
implementation: (command: string, args?: unknown) => Promise<unknown>,
) {
const invoke = vi.fn(implementation);
(
window as unknown as {
__TAURI__?: { core?: { invoke?: typeof invoke } };
}
).__TAURI__ = { core: { invoke } };
return invoke;
}
function removeInvoke() {
delete (
window as unknown as {
__TAURI__?: { core?: { invoke?: unknown } };
}
).__TAURI__;
}
function renderBatchPanel(
overrides: {
assetIds?: readonly string[];
onClose?: () => void;
onSaved?: (result: unknown) => void;
} = {},
) {
const element = (assetIds: readonly string[]) => (
<ResourceClassificationPanel
mode="batch"
projectPath="C:/project"
projectId="project-1"
assetIds={assetIds}
onClose={overrides.onClose ?? vi.fn()}
onSaved={overrides.onSaved ?? vi.fn()}
/>
);
const view = render(element(overrides.assetIds ?? ['asset-hero', 'asset-npc']));
return {
rerenderWithAssetIds: (assetIds: readonly string[]) =>
view.rerender(element(assetIds)),
};
}
/** 待追加草稿按 pill 文本读出(pill 内的删除按钮文本是 `×`)。 */
function draftPillLabels() {
const list = screen.queryByRole('list', { name: '待追加标签' });
if (!list) return [];
return within(list)
.getAllByRole('listitem')
.map((item) => item.textContent?.replace('×', '').trim());
}
function batchTagWrites(invoke: ReturnType<typeof vi.fn>) {
return invoke.mock.calls.filter(
([command]) => command === 'add_local_project_resource_tags',
);
}
afterEach(() => {
cleanup();
removeInvoke();
});
describe('ResourceClassificationPanel 批量追加标签', () => {
test('只有待追加草稿:不显示任何已有标签,也不出现单素材写入命令', async () => {
const user = userEvent.setup();
const invoke = installInvoke(async (command) => {
if (command === 'get_local_game_project_revision') {
return { revision: 3 };
}
if (command === 'add_local_project_resource_tags') {
return { assets: [], committedProjectRevision: 4 };
}
throw new Error(`unexpected command: ${command}`);
});
renderBatchPanel();
expect(
screen.getByRole('heading', { name: '批量追加标签' }),
).not.toBeNull();
// 副标题是**实际目标数量**,不是首项素材名。
expect(screen.getByText('已选 2 项素材')).not.toBeNull();
expect(screen.queryByRole('list', { name: '已有标签' })).toBeNull();
expect(draftPillLabels()).toEqual([]);
await user.type(
screen.getByPlaceholderText('新增标签,多个用逗号分隔'),
'春节{Enter}',
);
// 回车把草稿落成待追加 pill,但这不是素材已有标签。
expect(draftPillLabels()).toEqual(['春节']);
await user.click(screen.getByRole('button', { name: '追加标签' }));
await waitFor(() => expect(batchTagWrites(invoke)).toHaveLength(1));
expect(invoke.mock.calls.map(([command]) => command)).toEqual([
'get_local_game_project_revision',
'add_local_project_resource_tags',
]);
// 绝不逐素材循环调用单素材写入命令。
expect(
invoke.mock.calls.filter(
([command]) =>
command === 'update_local_project_resource_classification',
),
).toHaveLength(0);
});
test('保存把整组冻结 ID 与去重后的标签一次性交给原生,先读 revision 再提交', async () => {
const user = userEvent.setup();
const onSaved = vi.fn();
const invoke = installInvoke(async (command) => {
if (command === 'get_local_game_project_revision') {
return { revision: 7 };
}
if (command === 'add_local_project_resource_tags') {
return {
assets: [{ id: 'asset-hero' }, { id: 'asset-npc' }],
committedProjectRevision: 8,
};
}
throw new Error(`unexpected command: ${command}`);
});
renderBatchPanel({
assetIds: ['asset-npc', 'asset-hero', 'asset-npc'],
onSaved,
});
await user.type(
screen.getByPlaceholderText('新增标签,多个用逗号分隔'),
'春节, 新春,春节',
);
await user.click(screen.getByRole('button', { name: '追加标签' }));
await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1));
const [, args] = batchTagWrites(invoke)[0]!;
expect(args).toEqual({
input: {
projectPath: 'C:/project',
expectedProjectId: 'project-1',
expectedProjectRevision: 7,
// 首现顺序 + 去重:不是只写首项,也不重复同一份素材。
assetIds: ['asset-npc', 'asset-hero'],
tags: ['春节', '新春'],
},
});
// 保存成功后草稿清空,面板可以继续追加下一批。
expect(draftPillLabels()).toEqual([]);
});
test('打开面板后宿主再传新的目标集,保存仍用打开时冻结的那一组', async () => {
const user = userEvent.setup();
const invoke = installInvoke(async (command) => {
if (command === 'get_local_game_project_revision') {
return { revision: 3 };
}
if (command === 'add_local_project_resource_tags') {
return { assets: [], committedProjectRevision: 4 };
}
throw new Error(`unexpected command: ${command}`);
});
const { rerenderWithAssetIds } = renderBatchPanel({
assetIds: ['asset-hero', 'asset-npc'],
});
rerenderWithAssetIds(['asset-hero', 'asset-npc', 'asset-bg']);
expect(screen.getByText('已选 2 项素材')).not.toBeNull();
await user.type(
screen.getByPlaceholderText('新增标签,多个用逗号分隔'),
'春节',
);
await user.click(screen.getByRole('button', { name: '追加标签' }));
await waitFor(() => expect(batchTagWrites(invoke)).toHaveLength(1));
const [, args] = batchTagWrites(invoke)[0]!;
expect(args).toMatchObject({
input: { assetIds: ['asset-hero', 'asset-npc'] },
});
});
test('空草稿不提交:按钮禁用、不读 revision、不写任何命令', async () => {
const user = userEvent.setup();
const invoke = installInvoke(async (command) => {
throw new Error(`unexpected command: ${command}`);
});
renderBatchPanel();
const submit = screen.getByRole('button', { name: '追加标签' });
expect(submit).toHaveProperty('disabled', true);
await user.click(submit);
expect(invoke).not.toHaveBeenCalled();
});
test('保存期间锁住重复提交与关闭,失败后保留待追加草稿并报出原因', async () => {
const user = userEvent.setup();
const onClose = vi.fn();
let rejectSave: ((error: Error) => void) | null = null;
const invoke = installInvoke(async (command) => {
if (command === 'get_local_game_project_revision') {
return { revision: 7 };
}
if (command === 'add_local_project_resource_tags') {
return new Promise((_resolve, reject) => {
rejectSave = reject;
});
}
throw new Error(`unexpected command: ${command}`);
});
renderBatchPanel({ onClose });
await user.type(
screen.getByPlaceholderText('新增标签,多个用逗号分隔'),
'春节',
);
await user.click(screen.getByRole('button', { name: '追加标签' }));
await waitFor(() => expect(rejectSave).not.toBeNull());
// 保存在飞:重复提交与关闭(头部 ×)都被禁用。
expect(screen.getByRole('button', { name: '追加标签' })).toHaveProperty(
'disabled',
true,
);
const closeButton = screen.getByRole('button', {
name: '关闭批量追加标签',
});
expect(closeButton).toHaveProperty('disabled', true);
await user.click(closeButton);
await user.keyboard('{Escape}');
expect(onClose).not.toHaveBeenCalled();
rejectSave!(new Error('project-revision-conflict'));
// 失败:草稿保留(可直接重试),原因是可读中文而不是错误码。
await waitFor(() =>
expect(screen.getByRole('alert').textContent).toBe(
'项目已被其它操作改动,请刷新后重试',
),
);
expect(draftPillLabels()).toEqual(['春节']);
expect(screen.getByRole('button', { name: '追加标签' })).toHaveProperty(
'disabled',
false,
);
});
test('待追加草稿可以逐项移除,移除的只是草稿不是素材已有标签', async () => {
const user = userEvent.setup();
installInvoke(async () => undefined);
renderBatchPanel();
await user.type(
screen.getByPlaceholderText('新增标签,多个用逗号分隔'),
'春节,',
);
await user.type(
screen.getByPlaceholderText('新增标签,多个用逗号分隔'),
'新春,',
);
expect(draftPillLabels()).toEqual(['春节', '新春']);
await user.click(
screen.getByRole('button', { name: '移除待追加标签 春节' }),
);
expect(draftPillLabels()).toEqual(['新春']);
});
test('客户端外运行时不写盘,只给出提示', async () => {
const user = userEvent.setup();
removeInvoke();
renderBatchPanel();
await user.type(
screen.getByPlaceholderText('新增标签,多个用逗号分隔'),
'春节',
);
await user.click(screen.getByRole('button', { name: '追加标签' }));
expect(screen.getByRole('alert').textContent).toContain('需要在客户端内');
});
test('保存进行中锁住输入与草稿删除,同一批只发一次请求,成功后草稿才清空', async () => {
const user = userEvent.setup();
let resolveSave: ((value: unknown) => void) | null = null;
const invoke = installInvoke(async (command) => {
if (command === 'get_local_game_project_revision') {
return { revision: 3 };
}
if (command === 'add_local_project_resource_tags') {
return new Promise((resolve) => {
resolveSave = resolve;
});
}
throw new Error(`unexpected command: ${command}`);
});
renderBatchPanel();
const field = screen.getByPlaceholderText(
'新增标签,多个用逗号分隔',
) as HTMLInputElement;
await user.type(field, '春节,');
expect(draftPillLabels()).toEqual(['春节']);
// 同一事件里的第二次点击也不能再发一次:`saving` 的 state 还没渲染出来,
// 靠的是同步的 inFlight 锁。
const submit = screen.getByRole('button', { name: '追加标签' });
fireEvent.click(submit);
fireEvent.click(submit);
await waitFor(() => expect(resolveSave).not.toBeNull());
expect(batchTagWrites(invoke)).toHaveLength(1);
// 保存在飞:新输入不会被这一批带上,成功后又会被清空,所以输入框与草稿删除都锁住。
expect(field.disabled).toBe(true);
fireEvent.change(field, { target: { value: '保存中乱敲的标签' } });
// 禁用状态下这次输入进不了受控 state:草稿没变,保存成功后输入框仍是空的。
expect(draftPillLabels()).toEqual(['春节']);
const removeButton = screen.getByRole('button', {
name: '移除待追加标签 春节',
}) as HTMLButtonElement;
expect(removeButton.disabled).toBe(true);
fireEvent.click(removeButton);
expect(draftPillLabels()).toEqual(['春节']);
resolveSave!({ assets: [], committedProjectRevision: 4 });
await waitFor(() => expect(draftPillLabels()).toEqual([]));
expect(field.value).toBe('');
});
});
@@ -63,6 +63,7 @@
- 所有资源卡在卡面显示正式资源名称,沿用生成命名和用户重命名后的资源投影;长名称单行省略,实际可交互的卡片入口提供完整名称提示。来源、完整路径、任务和媒体类型等详细字段继续进入搜索索引和中央详情;卡片入口的可访问名称必须包含稳定可辨识的资源名与类别。文档卡仅展示居中文档图标和名称,正文在独立预览中展示,不把正文摘要铺在卡面。
- 显式“整理画布”重排当前栏目全部资源(包含手动坐标和被筛选隐藏的资源),其他栏目不变;“所有资源”页作用于全部可展示资源。重排可一次撤销,恢复原坐标及手动标记。自动协调仍保留手动坐标,不因新增素材自行重排。
- 当前画布可见资源框选后可成组移动,保持相对位置;松手统一保存且一次撤销。取消手势还原拖动前布局,切项目清理选择,不将隐藏或跨栏目残留选择带入操作。
- 多选已登记素材后,可从选中工具栏“编辑标签”或资源面板“批量标签”入口统一追加标签。面板明确实际目标数量,保存对象在打开时冻结,保留每项原标签及素材类型;不把已有标签并集覆盖到每项。混合未登记资源、超过 200 项、任何一项标签越界或项目版本冲突时整批拒绝,不静默跳过。一次保存更新整批素材,失败保留待追加标签;切项目不将迟到结果写入新项目。单素材编辑保留原有增删标签行为。
- 卡片外层是非交互容器;“打开详情”与“播放 / 暂停”必须是可分别键盘聚焦的同级按钮,禁止在 `<button>` 中嵌套 `<button>`。播放键不选择资源、不打开详情。
- 同一时间最多播放一个卡片音频或视频。打开详情、切换布局模式、切换项目、进入运行视图、搜索 / 筛选隐藏当前资源、当前资源被删除或资源身份 / 路径变化时,必须暂停旧媒体并收口待播放意图;卡片卸载还必须执行防御性暂停。同资源 ID 的无关 manifest 更新不得重建控件或抢走焦点。
- 图片、视频首帧和项目文档只在卡片进入资源画布可见区及小幅预取边界后读取;音频只在用户点击播放后读取。前端逻辑调度器必须使用有界并发、同身份去重、有界 LRU 淘汰,不允许按全部投影同步启动读取。`play > detail > visible` 是固定调度优先级;可见性预取不得占满全部队列容量或静默丢弃播放 / 详情请求,主动请求进入硬上限队列时必须替换最低优先级的排队预取并优先执行。终态预览缓存同时受 `48` 项和 `64 MiB` 总载荷双重上限约束,媒体按解码后的 Blob 字节、文档按 UTF-8 字节计入;Tauri 返回的 data URL 只允许作为 IPC 临时载体,进入 React 状态前必须转换为可撤销的 Blob URL。LRU 淘汰、资源删除、项目 / mode 切换和卸载都必须 `revokeObjectURL`,不能让 base64 字符串或失效 Blob 长期占用 WebView。
@@ -3,7 +3,7 @@
| 字段 | 值 |
| --- | --- |
| Milestone | docs/project-memory/plans/【里程碑】多选素材批量标签-2026-09-17.md |
| Status | ready |
| Status | implemented-awaiting-runtime-acceptance |
| Owner | 主 Agent 集成;deepseek-flash 实现和独立 review |
## 修改边界
@@ -28,3 +28,7 @@
## 风险与回滚
批量写不能掩盖部分失败;类型字段不能借标签保存被读时自愈值覆盖。单素材编辑与多素材追加保留明确分支,不重构其它菜单动作;本地独立提交可回退新需求,保留主线合并及既有画布工作。
## 下一验收入口
在已登记素材上分别从画布多选工具栏与资源面板进入批量标签,确认新增标签、原标签保留、刷新后持久化与跨项目切换。真实客户端验收通过后,将持久行为保留在主规范,删除本实施计划与里程碑;远程推送与 CI 等用户恢复安排,不自行启动。
@@ -3,7 +3,7 @@
| 字段 | 值 |
| --- | --- |
| Version | 1.0 |
| Status | approved |
| Status | implemented-awaiting-runtime-acceptance |
| Date | 2026-09-17 |
| Parent Spec | docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md |
@@ -21,13 +21,23 @@
## 验收
- [ ] 两个入口均按完整冻结选择集编辑,不只修改首项。
- [ ] 原有标签、正式分类和无关素材不变。
- [ ] 权限/身份/CAS/任意项上限失败时无部分写入;成功一次 revision。
- [ ] 空操作、重复标签和重复 ID 不重复写入;最大 200 项有界。
- [ ] 保存锁定、失败草稿保留、切项目迟到响应正确处理。
- [ ] 单素材标签、菜单角标和画布布局回归通过。
- [x] 两个入口均按完整冻结选择集编辑,不只修改首项。
- [x] 原有标签、正式分类和无关素材不变。
- [x] 权限/身份/CAS/任意项上限失败时无部分写入;成功一次 revision。
- [x] 空操作、重复标签和重复 ID 不重复写入;最大 200 项有界。
- [x] 保存锁定、失败草稿保留、切项目迟到响应正确处理。
- [x] 单素材标签、菜单角标和画布布局回归通过。
- [ ] 真实客户端点击、原生 IPC 写盘与跨窗口操作验收。
## 证据
前端组件与宿主整合测试、Rust 原生定向测试、AGC 类型检查、ESLint、编码/文档索引/diff 检查;真实客户端验证单独报告。
## 本地验证与审查
- 原生 `classification_tests` 22 项通过(保留既有 10 项并补批量写入、原子失败、no-op、权限、上限和审计测试)。
- 真实工作台组件的批量标签集成用例 9 项通过,包括画布和资源面板入口、两种混合选择顺序、完整目标集、回读期间切项目以及已保存但刷新失败。
- 单/批量面板、目标模型、资源面板、类型、重命名和标签统计等 9 文件、68 项定向回归通过;AGC 类型检查、目标 ESLint、编码与文档索引检查通过。
- 主线合并态 appSurface 450 项通过、20 项跳过;前端实现者完成批量接线后的同套回归,仍为 450 项通过、20 项跳过。
- 独立 review 的有效问题已落实:空白素材 ID 整批拒绝、超限错误定位到素材、批量锁操作名称、保存中输入锁、项目代次校验及首项为版本卡时入口原因可达。
- 浏览器工具当前无法连接,未将 jsdom 矩形/事件模拟称为真实客户端验收。没有推送、没有 CI 轮询、没有修改飞书。
@@ -16,6 +16,8 @@
## 开发中
- AGC 批量追加素材标签由原生在一次项目写锁与 revision CAS 下合并各项原标签,先校验全批再写 manifest;前端不能循环单素材分类命令,不回传展示层推导的分类或旧标签全集,以免部分写入或覆盖未编辑字段。
- 画布卡片类型与信息角标共用 `CanvasCardCornerActions`;菜单收纳共用 `OverflowActions`,宿主决定展示数量和资源命令。AGC 选中菜单前 5 项直显,Web 默认不折叠;浮层 portal 继续接入现有画布关闭与滚轮归属判据。
- 修改范围保持聚焦;优先扩展现有系统、页面、组件、DTO 和脚本,不新建平行入口或业务真相。
- UI 开发优先复用现有公共组件;跨页面或跨端重复的视觉/交互模式应沉淀到 `packages/shared`,由现有页面迁移使用,禁止在业务页复制同类 UI。共享组件只承载通用表现与交互,不下沉领域规则、后端副作用或正式业务状态。