diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index d3a8f48ba..74170f9e1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -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 { + 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, diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 68ba228eb..666dfa388 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -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, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs index d5c37f54f..4f8b0c793 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs @@ -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, + #[serde(default)] + pub(crate) tags: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AddLocalProjectResourceTagsResult { + pub(crate) assets: Vec, + 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, String> { + let mut normalized: Vec = 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, 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 { + 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, + /// 真正需要落值的目标:`(assets 下标, 合并后的标签)`。 + updates: Vec<(usize, Vec)>, + /// 确实发生变化的素材 ID,供审计记录使用;空表示整批无变化。 + changed_asset_ids: Vec, +} + +/// 先校验**全部**目标与**全部**合并结果,再决定是否写值。 +/// +/// 顺序是刻意的:第一阶段只读,任一目标不存在、任一合并结果超过标签上界都在写之前返回错误; +/// 只有全部通过,第二阶段才逐项落值。这样"缺任一资产 / 超限"都不可能留下部分写入。 +fn plan_manifest_asset_tag_append( + manifest: &GameCreationAppManifest, + asset_ids: &[String], + tags: &[String], +) -> Result { + let mut assets = Vec::with_capacity(asset_ids.len()); + let mut updates: Vec<(usize, Vec)> = 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, + tags: Vec, +) -> Result { + 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, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/classification_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/classification_tests.rs index d66a0d736..0dab003fb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/classification_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/classification_tests.rs @@ -559,3 +559,716 @@ fn asset_classification_audit_failure_is_reported_and_never_faked() { assert_eq!(asset.tags, vec!["主舞台"]); fs::remove_dir_all(root).ok(); } + +/// 批量标签测试的公共脚手架:登记素材、读原始字节、数审计记录。 +fn register_batch_tag_asset(root: &Path, local_path: &str, kind: &str, media_type: &str) -> String { + register_local_asset_entry( + root, + local_path, + kind, + media_type, + "asset", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Uploaded, + canvas_project_id: None, + resource_id: None, + asset_object_id: None, + task_id: None, + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }, + ) + .expect("register asset") + .id +} + +fn project_with_batch_tag_assets( + test_name: &str, + specs: &[(&str, &str, &str)], +) -> (PathBuf, Vec) { + let root = classification_test_root(test_name); + init_local_game_project_at(&root, "batch-tag-project", "批量标签测试") + .expect("initialize batch tag project"); + let ids = specs + .iter() + .map(|(local_path, kind, media_type)| { + register_batch_tag_asset(&root, local_path, kind, media_type) + }) + .collect::>(); + (root, ids) +} + +fn batch_tag_project_id(root: &Path) -> String { + read_existing_manifest_for_project(root) + .expect("read manifest") + .project_id +} + +fn batch_tag_revision(root: &Path) -> u64 { + read_game_creator_agent_runtime_project_revision(root) + .expect("read project revision") + .revision +} + +fn batch_tag_manifest_bytes(root: &Path) -> Vec { + fs::read(root.join(".agent/manifest.json")).expect("read manifest bytes") +} + +/// 批量追加的 `recordType`:与 `asset.*` 命名族一致,`.append` 表达"只追加、不替换既有标签"。 +fn batch_tag_audit_records(root: &Path) -> Vec { + let (records, truncated) = + read_agent_db_records_bounded(root, 4 * 1024 * 1024).expect("read agent db records"); + assert!(!truncated, "批量标签测试的 agent.db 不应触达尾窗上限"); + records + .into_iter() + .filter(|record| { + record.get("recordType").and_then(serde_json::Value::as_str) + == Some(ASSET_BATCH_TAG_AUDIT_RECORD_TYPE) + }) + .collect() +} + +fn batch_tag_manifest_entry(root: &Path, asset_id: &str) -> GameCreationAppAssetManifestEntry { + read_existing_manifest_for_project(root) + .expect("read manifest") + .assets + .into_iter() + .find(|asset| asset.id == asset_id) + .expect("manifest asset") +} + +/// 成功路径:只追加,原有标签顺序、分类与其它字段原样保留,未选中的素材一个字节都不变。 +/// 返回顺序按去重后的请求 ID 首次出现顺序;整批只推进一次 revision、只追加一条审计。 +#[test] +fn batch_tags_append_keeps_existing_tags_and_categories() { + let (root, ids) = project_with_batch_tag_assets( + "batch-append", + &[ + ("assets/hero.png", "character", "image/png"), + ("assets/theme.mp3", "background-music", "audio/mpeg"), + ("assets/untouched.png", "image", "image/png"), + ], + ); + let project_id = batch_tag_project_id(&root); + let revision_before = batch_tag_revision(&root); + // 两份目标素材带着**不同的**原有标签与分类进入:一个已有标签、一个是空标签集。 + update_manifest_asset_classification_at( + &root, + &project_id, + revision_before, + &ids[0], + "scene", + vec!["原甲".to_string(), "原乙".to_string()], + ) + .expect("preset first asset classification"); + let revision_after_preset = batch_tag_revision(&root); + update_manifest_asset_classification_at( + &root, + &project_id, + revision_after_preset, + &ids[1], + "audio", + Vec::new(), + ) + .expect("preset second asset classification"); + let revision_before_batch = batch_tag_revision(&root); + let untouched_before = batch_tag_manifest_entry(&root, &ids[2]); + + let result = add_manifest_asset_tags_at( + &root, + &project_id, + revision_before_batch, + vec![ids[1].clone(), ids[0].clone()], + vec!["新一".to_string(), "新二".to_string()], + ) + .expect("append batch tags"); + + assert_eq!( + result + .assets + .iter() + .map(|asset| asset.id.clone()) + .collect::>(), + vec![ids[1].clone(), ids[0].clone()], + "返回条目必须按去重后请求 ID 的首次出现顺序" + ); + assert_eq!(result.assets[0].tags, vec!["新一", "新二"]); + assert_eq!(result.assets[1].tags, vec!["原甲", "原乙", "新一", "新二"]); + assert_eq!(result.committed_project_revision, revision_before_batch + 1); + assert_eq!(batch_tag_revision(&root), revision_before_batch + 1); + + let first = batch_tag_manifest_entry(&root, &ids[0]); + assert_eq!(first.tags, vec!["原甲", "原乙", "新一", "新二"]); + assert_eq!(first.category, GameCreationAppAssetCategory::Scene); + assert_eq!(first.kind, "character"); + assert_eq!(first.local_path, "assets/hero.png"); + assert_eq!(first.media_type, "image/png"); + let second = batch_tag_manifest_entry(&root, &ids[1]); + assert_eq!(second.tags, vec!["新一", "新二"]); + assert_eq!(second.category, GameCreationAppAssetCategory::Audio); + assert_eq!(second.kind, "background-music"); + assert_eq!(batch_tag_manifest_entry(&root, &ids[2]), untouched_before); + + let audit = batch_tag_audit_records(&root); + assert_eq!(audit.len(), 1, "整批只留一条审计"); + assert_eq!( + audit[0] + .get("assetIds") + .and_then(serde_json::Value::as_array), + Some(&vec![ + serde_json::Value::String(ids[1].clone()), + serde_json::Value::String(ids[0].clone()) + ]) + ); + assert_eq!( + audit[0] + .get("expectedProjectRevision") + .and_then(serde_json::Value::as_u64), + Some(revision_before_batch) + ); + + fs::remove_dir_all(root).ok(); +} + +/// 重复 ID 与重复标签都按第一次出现收口:既不重复写入素材,也不重复追加同一个标签。 +#[test] +fn batch_tags_dedupe_asset_ids_and_tags() { + let (root, ids) = project_with_batch_tag_assets( + "batch-dedupe", + &[ + ("assets/hero.png", "image", "image/png"), + ("assets/theme.mp3", "background-music", "audio/mpeg"), + ], + ); + let project_id = batch_tag_project_id(&root); + let revision_before = batch_tag_revision(&root); + + let result = add_manifest_asset_tags_at( + &root, + &project_id, + revision_before, + vec![format!(" {} ", ids[0]), ids[0].clone(), ids[1].clone()], + vec![ + " 标签 ".to_string(), + "标签".to_string(), + " 另一个 ".to_string(), + ], + ) + .expect("append deduped batch tags"); + + assert_eq!( + result + .assets + .iter() + .map(|asset| asset.id.clone()) + .collect::>(), + ids, + "去重后按首次出现顺序返回,两个素材各一次" + ); + for asset in &result.assets { + assert_eq!(asset.tags, vec!["标签", "另一个"]); + } + assert_eq!(result.committed_project_revision, revision_before + 1); + fs::remove_dir_all(root).ok(); +} + +/// 整批无变化:不写盘(manifest 字节不变)、不审计、不推进 revision,返回当前条目与当前 revision。 +#[test] +fn batch_tags_repeat_is_a_noop_without_write_audit_or_revision() { + let (root, ids) = project_with_batch_tag_assets( + "batch-noop", + &[ + ("assets/hero.png", "image", "image/png"), + ("assets/theme.mp3", "background-music", "audio/mpeg"), + ], + ); + let project_id = batch_tag_project_id(&root); + let first = add_manifest_asset_tags_at( + &root, + &project_id, + batch_tag_revision(&root), + ids.clone(), + vec!["重复标签".to_string()], + ) + .expect("first batch append"); + let revision_after_first = first.committed_project_revision; + assert_eq!(batch_tag_audit_records(&root).len(), 1); + let bytes_before = batch_tag_manifest_bytes(&root); + + let repeat = add_manifest_asset_tags_at( + &root, + &project_id, + revision_after_first, + vec![ids[0].clone(), ids[1].clone(), ids[0].clone()], + vec!["重复标签".to_string(), " 重复标签 ".to_string()], + ) + .expect("repeating the same batch tags must succeed"); + + assert_eq!(repeat.committed_project_revision, revision_after_first); + assert_eq!(repeat.assets.len(), 2); + for asset in &repeat.assets { + assert_eq!(asset.tags, vec!["重复标签"]); + } + assert_eq!( + batch_tag_manifest_bytes(&root), + bytes_before, + "无变化时不得重写 manifest" + ); + assert_eq!(batch_tag_revision(&root), revision_after_first); + assert_eq!( + batch_tag_audit_records(&root).len(), + 1, + "无变化不得追加假变更审计" + ); + fs::remove_dir_all(root).ok(); +} + +/// 混合批次:一部分目标无变化、一部分目标有变化时,审计只记**实际变化**的素材, +/// 响应仍然按请求顺序返回**全部**目标的最新条目,revision 只推进一次。 +#[test] +fn batch_tags_mixed_noop_and_change_audits_only_changed_assets() { + let (root, ids) = project_with_batch_tag_assets( + "batch-mixed", + &[ + ("assets/hero.png", "image", "image/png"), + ("assets/theme.mp3", "background-music", "audio/mpeg"), + ], + ); + let project_id = batch_tag_project_id(&root); + // 第一项已经有目标标签(本批对它无变化),第二项没有(本批真正改动它)。 + let revision_after_preset = update_manifest_asset_classification_at( + &root, + &project_id, + batch_tag_revision(&root), + &ids[0], + "unclassified", + vec!["已有".to_string()], + ) + .expect("preset first asset tags") + .committed_project_revision; + let first_before = batch_tag_manifest_entry(&root, &ids[0]); + let second_before = batch_tag_manifest_entry(&root, &ids[1]); + + let result = add_manifest_asset_tags_at( + &root, + &project_id, + revision_after_preset, + ids.clone(), + vec!["已有".to_string()], + ) + .expect("mixed no-op and change batch"); + + assert_eq!( + result + .assets + .iter() + .map(|asset| asset.id.clone()) + .collect::>(), + ids, + "响应必须按请求顺序返回全部目标素材" + ); + assert_eq!(result.assets[0].tags, vec!["已有"]); + assert_eq!(result.assets[1].tags, vec!["已有"]); + assert_eq!(result.committed_project_revision, revision_after_preset + 1); + assert_eq!( + batch_tag_manifest_entry(&root, &ids[0]), + first_before, + "本批对该素材无变化时不得改写它" + ); + assert_eq!( + batch_tag_manifest_entry(&root, &ids[1]).tags, + vec!["已有"], + "有变化的目标必须真实落盘" + ); + assert_ne!( + batch_tag_manifest_entry(&root, &ids[1]), + second_before, + "第二项应当发生改动" + ); + + let audit = batch_tag_audit_records(&root); + assert_eq!(audit.len(), 1, "混合批次只留一条审计"); + assert_eq!( + audit[0] + .get("assetIds") + .and_then(serde_json::Value::as_array), + Some(&vec![serde_json::Value::String(ids[1].clone())]), + "审计只记实际发生变化的素材" + ); + fs::remove_dir_all(root).ok(); +} + +/// 缺任一目标(含末项非法)时整批零部分写:已有素材的标签、manifest 字节与 revision 都不动。 +#[test] +fn batch_tags_missing_target_writes_nothing() { + let (root, ids) = project_with_batch_tag_assets( + "batch-missing", + &[ + ("assets/hero.png", "image", "image/png"), + ("assets/theme.mp3", "background-music", "audio/mpeg"), + ], + ); + let project_id = batch_tag_project_id(&root); + let revision_before = batch_tag_revision(&root); + let bytes_before = batch_tag_manifest_bytes(&root); + + let error = add_manifest_asset_tags_at( + &root, + &project_id, + revision_before, + vec![ids[0].clone(), ids[1].clone(), "asset-missing".to_string()], + vec!["新标签".to_string()], + ) + .expect_err("a missing target must fail the whole batch"); + + assert!( + error.contains("项目资源不存在"), + "unexpected error: {error}" + ); + assert!(error.contains("asset-missing"), "unexpected error: {error}"); + assert_eq!(batch_tag_manifest_bytes(&root), bytes_before); + assert_eq!(batch_tag_revision(&root), revision_before); + assert!(batch_tag_audit_records(&root).is_empty()); + for asset_id in &ids { + assert!( + batch_tag_manifest_entry(&root, asset_id).tags.is_empty(), + "缺目标失败后不得留下部分写入" + ); + } + fs::remove_dir_all(root).ok(); +} + +/// 合并后的标签总量与单标签长度按既有上界失败关闭:都不写盘、不推进 revision。 +#[test] +fn batch_tags_rejects_merged_tag_limit_and_single_tag_length() { + let (root, ids) = project_with_batch_tag_assets( + "batch-limits", + &[ + ("assets/hero.png", "image", "image/png"), + ("assets/theme.mp3", "background-music", "audio/mpeg"), + ], + ); + let project_id = batch_tag_project_id(&root); + let sixteen = (0..16) + .map(|index| format!("原标签{index}")) + .collect::>(); + let revision_after_sixteen = update_manifest_asset_classification_at( + &root, + &project_id, + batch_tag_revision(&root), + &ids[0], + "unclassified", + sixteen.clone(), + ) + .expect("preset sixteen tags") + .committed_project_revision; + let fifteen = sixteen[..15].to_vec(); + let revision_after_fifteen = update_manifest_asset_classification_at( + &root, + &project_id, + revision_after_sixteen, + &ids[1], + "audio", + fifteen, + ) + .expect("preset fifteen tags") + .committed_project_revision; + let bytes_before = batch_tag_manifest_bytes(&root); + + // 16 个原有标签 + 1 个新标签 = 17 > 上界:整批失败,两项目标都不动。 + let over_limit = add_manifest_asset_tags_at( + &root, + &project_id, + revision_after_fifteen, + ids.clone(), + vec!["再来一个".to_string()], + ) + .expect_err("a merged tag count above the limit must fail the whole batch"); + assert!(over_limit.contains("最多支持"), "unexpected: {over_limit}"); + // 通用上界文案必须能定位到具体素材:ID + 可读 localPath + 整批零写。 + assert!(over_limit.contains(&ids[0]), "unexpected: {over_limit}"); + assert!( + over_limit.contains("assets/hero.png"), + "unexpected: {over_limit}" + ); + assert!( + over_limit.contains("本次未写入任何素材"), + "unexpected: {over_limit}" + ); + + // 33 个字符的标签:单标签长度上界,同样整批失败。 + let over_chars = add_manifest_asset_tags_at( + &root, + &project_id, + revision_after_fifteen, + ids.clone(), + vec!["像".repeat(33)], + ) + .expect_err("an over-long tag must fail the whole batch"); + assert!(over_chars.contains("不能超过"), "unexpected: {over_chars}"); + // 单标签超长在**输入归一化**阶段就被拒绝,那时还没有任何"目标素材"可归因, + // 因此这里刻意不出现素材 ID;按素材定位是「合并后数量超限」这类锁内合并失败的职责。 + assert!(!over_chars.contains(&ids[0]), "unexpected: {over_chars}"); + + assert_eq!(batch_tag_manifest_bytes(&root), bytes_before); + assert_eq!(batch_tag_revision(&root), revision_after_fifteen); + assert!(batch_tag_audit_records(&root).is_empty()); + assert_eq!(batch_tag_manifest_entry(&root, &ids[0]).tags, sixteen); + + // 边界内必须成功:15 个原有标签 + 1 = 16,单标签 32 个字符。 + let boundary = add_manifest_asset_tags_at( + &root, + &project_id, + revision_after_fifteen, + vec![ids[1].clone()], + vec!["像".repeat(32)], + ) + .expect("merged tag count exactly at the limit must succeed"); + assert_eq!(boundary.assets[0].tags.len(), 16); + assert_eq!(boundary.assets[0].tags[15].chars().count(), 32); + fs::remove_dir_all(root).ok(); +} + +/// 空批次与空标签都不接受:空白 `assetId` 整批拒绝(不静默跳过),全空标签归一后拒绝。 +#[test] +fn batch_tags_rejects_empty_batch_and_empty_tags() { + let (root, ids) = + project_with_batch_tag_assets("batch-empty", &[("assets/hero.png", "image", "image/png")]); + let project_id = batch_tag_project_id(&root); + let revision_before = batch_tag_revision(&root); + let bytes_before = batch_tag_manifest_bytes(&root); + let tags = vec!["标签".to_string()]; + + let empty_batch = add_manifest_asset_tags_at( + &root, + &project_id, + revision_before, + Vec::new(), + tags.clone(), + ) + .expect_err("an empty asset batch must be rejected"); + assert!( + empty_batch.contains("至少需要一个素材"), + "unexpected: {empty_batch}" + ); + + // 空白 assetId 一律拒绝:跳过它会让"请求了 2 个素材"变成"实际写了 1 个",且调用方仍拿到成功。 + for blank in [String::new(), " ".to_string()] { + let error = add_manifest_asset_tags_at( + &root, + &project_id, + revision_before, + vec![ids[0].clone(), blank], + tags.clone(), + ) + .expect_err("a blank assetId must fail the whole batch"); + assert!(error.contains("assetId 不能为空"), "unexpected: {error}"); + } + + for empty_tags in [Vec::new(), vec![String::new()], vec![" ".to_string()]] { + let error = add_manifest_asset_tags_at( + &root, + &project_id, + revision_before, + ids.clone(), + empty_tags, + ) + .expect_err("empty tags must be rejected"); + assert!(error.contains("不能为空"), "unexpected: {error}"); + } + + assert_eq!(batch_tag_manifest_bytes(&root), bytes_before); + assert_eq!(batch_tag_revision(&root), revision_before); + assert!(batch_tag_audit_records(&root).is_empty()); + assert!( + batch_tag_manifest_entry(&root, &ids[0]).tags.is_empty(), + "空白 assetId / 空标签失败后不得留下部分写入" + ); + fs::remove_dir_all(root).ok(); +} + +/// 批次上界:去重后 200 个素材成功,201 个失败且不写任何一项。 +#[test] +fn batch_tags_accepts_two_hundred_assets_and_rejects_two_hundred_one() { + let root = classification_test_root("batch-bound-200"); + init_local_game_project_at(&root, "batch-tag-bound-project", "批量标签上界测试") + .expect("initialize batch bound project"); + let ids = (0..=ASSET_BATCH_TAG_MAX_ASSETS) + .map(|index| { + register_batch_tag_asset(&root, &format!("assets/a{index}.png"), "image", "image/png") + }) + .collect::>(); + let project_id = batch_tag_project_id(&root); + + let revision_before = batch_tag_revision(&root); + let bytes_before = batch_tag_manifest_bytes(&root); + let over = add_manifest_asset_tags_at( + &root, + &project_id, + revision_before, + ids.clone(), + vec!["超限".to_string()], + ) + .expect_err("more than the batch limit must be rejected"); + assert!(over.contains("最多支持 200 个素材"), "unexpected: {over}"); + assert_eq!(batch_tag_manifest_bytes(&root), bytes_before); + assert_eq!(batch_tag_revision(&root), revision_before); + + let boundary = add_manifest_asset_tags_at( + &root, + &project_id, + revision_before, + ids[..ASSET_BATCH_TAG_MAX_ASSETS].to_vec(), + vec!["批量".to_string()], + ) + .expect("exactly the batch limit must succeed"); + assert_eq!(boundary.assets.len(), ASSET_BATCH_TAG_MAX_ASSETS); + assert_eq!(boundary.committed_project_revision, revision_before + 1); + assert_eq!(boundary.assets[0].tags, vec!["批量"]); + assert_eq!( + batch_tag_manifest_entry(&root, &ids[ASSET_BATCH_TAG_MAX_ASSETS]).tags, + Vec::::new(), + "第 201 个素材不在本批范围内,不得被写入" + ); + fs::remove_dir_all(root).ok(); +} + +/// 项目身份与 revision CAS 沿用单素材口径:身份不符 / 版本冲突都在写之前失败。 +#[test] +fn batch_tags_keeps_project_identity_and_revision_cas() { + let (root, ids) = + project_with_batch_tag_assets("batch-cas", &[("assets/hero.png", "image", "image/png")]); + let project_id = batch_tag_project_id(&root); + let revision_before = batch_tag_revision(&root); + let bytes_before = batch_tag_manifest_bytes(&root); + + let identity_error = add_manifest_asset_tags_at( + &root, + "another-project", + revision_before, + ids.clone(), + vec!["新标签".to_string()], + ) + .expect_err("a foreign project identity must fail closed"); + assert_eq!(identity_error, "project-identity-conflict"); + + let revision_error = add_manifest_asset_tags_at( + &root, + &project_id, + revision_before + 1, + ids.clone(), + vec!["新标签".to_string()], + ) + .expect_err("a stale revision must fail closed"); + assert_eq!(revision_error, "project-revision-conflict"); + + assert_eq!(batch_tag_manifest_bytes(&root), bytes_before); + assert_eq!(batch_tag_revision(&root), revision_before); + assert!(batch_tag_audit_records(&root).is_empty()); + fs::remove_dir_all(root).ok(); +} + +/// DTO 必须 camelCase 且拒绝未知字段。 +#[test] +fn batch_tags_input_rejects_unknown_fields() { + let parsed = serde_json::from_value::(serde_json::json!({ + "projectPath": "C:/project", + "expectedProjectId": "project", + "expectedProjectRevision": 1, + "assetIds": ["asset-1"], + "tags": ["标签"], + })) + .expect("camelCase payload must deserialize"); + assert_eq!(parsed.asset_ids, vec!["asset-1"]); + assert_eq!(parsed.tags, vec!["标签"]); + + let rejected = serde_json::from_value::(serde_json::json!({ + "projectPath": "C:/project", + "expectedProjectId": "project", + "expectedProjectRevision": 1, + "assetIds": ["asset-1"], + "tags": ["标签"], + "unexpected": true, + })); + assert!(rejected.is_err()); +} + +/// 权限门面:`asset.register` 被拒绝时命令整体失败,manifest、revision 与审计都不动。 +#[test] +fn batch_tags_command_requires_asset_register_permission() { + let (root, ids) = project_with_batch_tag_assets( + "batch-permission", + &[("assets/hero.png", "image", "image/png")], + ); + let project_id = batch_tag_project_id(&root); + let revision_before = batch_tag_revision(&root); + let bytes_before = batch_tag_manifest_bytes(&root); + + let mut policy = crate::ProjectPermissionPolicy::default(); + policy.denied_commands.push("asset.register".to_string()); + write_project_permission_policy_at(&root, policy).expect("write permission policy"); + + let error = + crate::commands::add_local_project_resource_tags(AddLocalProjectResourceTagsInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: project_id.clone(), + expected_project_revision: revision_before, + asset_ids: ids.clone(), + tags: vec!["新标签".to_string()], + }) + .expect_err("a denied asset.register must fail closed"); + + assert_eq!(error, "项目权限策略拒绝执行:asset.register"); + assert_eq!(batch_tag_manifest_bytes(&root), bytes_before); + assert_eq!(batch_tag_revision(&root), revision_before); + assert!(batch_tag_audit_records(&root).is_empty()); + fs::remove_dir_all(root).ok(); +} + +/// 审计失败必须照实报「整批已写入」,不谎称回滚、也不留下假审计。 +#[test] +fn batch_tags_audit_failure_reports_written_batch() { + let (root, ids) = project_with_batch_tag_assets( + "batch-audit-failure", + &[ + ("assets/hero.png", "image", "image/png"), + ("assets/theme.mp3", "background-music", "audio/mpeg"), + ], + ); + let project_id = batch_tag_project_id(&root); + let revision_before = batch_tag_revision(&root); + fs::write( + root.join(".agent/runtime/test-fail-next-agent-db-record"), + ASSET_BATCH_TAG_AUDIT_RECORD_TYPE, + ) + .expect("write audit failure injection marker"); + + let error = add_manifest_asset_tags_at( + &root, + &project_id, + revision_before, + ids.clone(), + vec!["新标签".to_string()], + ) + .expect_err("an audit append failure must surface to the caller"); + + assert!(error.contains("已写入"), "unexpected error: {error}"); + assert!(error.contains("审计记录失败"), "unexpected error: {error}"); + assert!(batch_tag_audit_records(&root).is_empty()); + for asset_id in &ids { + assert_eq!( + batch_tag_manifest_entry(&root, asset_id).tags, + vec!["新标签"], + "manifest 是权威真相:已写入就必须能读回来,不伪报 rollback" + ); + } + assert_eq!( + batch_tag_revision(&root), + revision_before, + "审计失败发生在 revision 推进之前,照既有语义不推进" + ); + fs::remove_dir_all(root).ok(); +} diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasPanelView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasPanelView.tsx index 5ac80692d..abb2dedf2 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasPanelView.tsx +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasPanelView.tsx @@ -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({ > 清空选择 + {batchTags ? ( + + ) : null} + + + ))} + + ); +} + +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(); + 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' ? ( + + ) : ( + + ); +} + +/** 单素材标签编辑:既有增删标签行为,一次一份素材。 */ +function ResourceSingleClassificationPanel({ projectPath, projectId, asset, onClose, onSaved, -}: ResourceClassificationPanelProps) { +}: ResourceSingleClassificationPanelProps) { /** * 本面板只编辑标签:素材类型(功能分类)在「设置素材类型」面板里单独设置。 * @@ -182,31 +292,12 @@ export function ResourceClassificationPanel({ 工具条上。曾长在这里的类型 chip 只改本地 state、不落盘,保存又只能借道标签的 「添加」,导致"改了类型没生效"。 */} - {tags.length > 0 ? ( -
    - {tags.map((tag) => ( - // 单点使用,先不抽到 packages/shared。若第二处出现带删除按钮的标签 pill, - // 抽到 `packages/shared` 做 `PlatformRemovableTagPill`,不要复制这份实现。 -
  • - - {tag} - - -
  • - ))} -
- ) : null} + `删除标签 ${tag}`} + tags={tags} + onRemove={removeTag} + /> ); } + +/** + * 批量追加标签:编辑对象是打开面板时冻结的整组素材 ID。 + * + * 面板里**只有待追加草稿** —— 各素材已有标签的并集既不显示、也不进入提交值, + * 因为「把并集分发给每一项」会给每份素材都补上别人的标签。保存就是一次 + * `add_local_project_resource_tags`,绝不逐素材循环调用单素材写入命令。 + */ +function ResourceBatchClassificationPanel({ + projectPath, + projectId, + assetIds, + onClose, + onSaved, +}: ResourceBatchClassificationPanelProps) { + /** + * 冻结目标集:只取第一帧。宿主在打开时已经快照了一份,这里再冻一次, + * 保证「面板已开、用户又改了画布选中或资源面板筛选」时这一批的写入对象不变。 + */ + const [targetAssetIds] = useState(() => dedupeResourceAssetIds(assetIds)); + const [draftTags, setDraftTags] = useState([]); + const [tagDraft, setTagDraft] = useState(''); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(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( + '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 ( + +
+
+

批量追加标签

+

{`已选 ${targetAssetIds.length} 项素材`}

+
+ +
+
+ {/* + 只渲染待追加草稿:素材原有标签既不展示也不参与提交。删除按钮在这里删的是 + 草稿,不是已落盘的标签 —— 批量删除既有标签不在本次范围。 + */} + `移除待追加标签 ${tag}`} + tags={draftTags} + onRemove={removeDraftTag} + // 保存在飞时不许改草稿:新输入的标签不会被这次提交带上,成功后又会被清空, + // 用户会以为"改了但没保存"。锁住输入与删除,语义才是"这一批正在写"。 + disabled={saving} + /> + setTagDraft(event.currentTarget.value)} + onBlur={commitTagDraft} + onKeyDown={(event) => { + if ( + event.key === 'Enter' || + event.key === ',' || + event.key === ',' + ) { + event.preventDefault(); + commitTagDraft(); + } + }} + /> + {error ? ( +

+ {error} +

+ ) : null} +
+
+ void appendDraftAndSave()} + disabled={saving || !hasTagsToAppend} + > + 追加标签 + +
+
+ ); +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 8f8f0fbd1..be29e6474 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -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(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) ? ( 引用 ) : null} - {selectedResource?.manifestAssetId ? ( + {resourceTagEditorEntry ? ( } - onClick={() => - setResourceClassificationAssetId( - selectedResource.manifestAssetId, - ) + // 多选无法整批写入(混入版本 / 未登记素材或超限)时 + // 入口禁用,并把原因挂在 title 上;绝不静默只改首项。 + title={ + resourceTagEditorEntry.disabledReason ?? + '编辑标签' } + disabled={ + resourceTagEditorEntry.disabledReason !== + null + } + icon={} + onClick={() => { + if ( + resourceTagEditorEntry.mode === 'batch' + ) { + openResourceBatchTags(); + return; + } + setResourceClassificationAssetId( + resourceTagEditorEntry.assetId, + ); + }} > 编辑标签 @@ -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 ? ( + setResourceBatchTagsTarget(null)} + onSaved={(result) => + void handleResourceBatchTagsSaved(resourceBatchTagsTarget, result) + } + /> + ) : null} {/* 「设置素材类型」:与标签面板并列的独立入口(素材类型 = 功能分类)。 面板自己是"选中即落盘",宿主这里负责保存成功后收起面板 —— 用户必须马上在画布上 diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceBatchTagTargetModel.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceBatchTagTargetModel.ts new file mode 100644 index 000000000..d7829366a --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceBatchTagTargetModel.ts @@ -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(); + 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 }; +} diff --git a/apps/ai-game-creator-shell/tests/resourceBatchTagTargetModel.test.ts b/apps/ai-game-creator-shell/tests/resourceBatchTagTargetModel.test.ts new file mode 100644 index 000000000..d15cbe4cc --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceBatchTagTargetModel.test.ts @@ -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); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceBatchTagsIntegration.test.tsx b/apps/ai-game-creator-shell/tests/resourceBatchTagsIntegration.test.tsx new file mode 100644 index 000000000..aee260a75 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceBatchTagsIntegration.test.tsx @@ -0,0 +1,680 @@ +/** @vitest-environment jsdom */ +import { useState } from 'react'; +import { beforeEach } from 'vitest'; + +import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; +import { + createGameCreationAppManifest, + expect, + findResourceSelectButton, + fireEvent, + ProjectDevelopmentView, + render, + screen, + vi, + waitFor, + within, +} from './appSurface/harness'; + +const PROJECT_PATH = '/tmp/batch-tags-project'; +const PROJECT_ID = 'batch-tags-project'; +const OTHER_PROJECT_PATH = '/tmp/batch-tags-other'; +const OTHER_PROJECT_ID = 'batch-tags-other'; + +const disk = { + manifest: null as GameCreationAppManifest | null, + revision: 3, +}; + +function createFixtureManifest( + projectId = PROJECT_ID, + name = '批量标签项目', +): GameCreationAppManifest { + const manifest = createGameCreationAppManifest(projectId, name); + manifest.assets = [ + { + id: 'asset-hero', + kind: 'character', + category: 'character', + mediaType: 'image/png', + localPath: 'assets/hero.png', + source: { kind: 'uploaded' }, + }, + { + id: 'asset-npc', + kind: 'character', + category: 'character', + mediaType: 'image/png', + localPath: 'assets/npc.png', + source: { kind: 'uploaded' }, + }, + ]; + // 一个正式项目版本:它在投影里是「未登记素材」,用来钉混合选择不能部分写入。 + manifest.versions = [ + { + versionId: 'version-1', + parentVersionId: null, + projectRevision: 1, + resourceBindings: [], + createdReason: 'initial', + createdAt: 1, + }, + ]; + return manifest; +} + +function graphFor(manifest: GameCreationAppManifest) { + const resourceIds = manifest.assets.map((asset) => `asset:${asset.id}`); + return { + resourceIds, + referenceEdges: [], + taskFlows: [], + connectionIndex: resourceIds.map((resourceId) => ({ + resourceId, + upstreamReferenceResourceIds: [], + downstreamReferenceResourceIds: [], + referenceEdgeIds: [], + taskFlowIds: [], + })), + producerAssignments: [], + dependencyDepths: resourceIds.map((resourceId) => ({ + resourceId, + dependencyDepth: 0, + })), + unresolvedReferenceResourceIds: [], + cyclicResourceIds: [], + cyclicTaskIds: [], + producerMappingTruncated: false, + }; +} + +type BatchTagWrite = { + projectPath: string; + expectedProjectId: string; + expectedProjectRevision: number; + assetIds: string[]; + tags: string[]; +}; + +function appendTagsToDisk(input: BatchTagWrite) { + const base = disk.manifest; + if (!base) throw new Error('missing manifest fixture'); + disk.revision += 1; + const assets = base.assets.map((asset) => + input.assetIds.includes(asset.id) + ? ({ + ...asset, + tags: [ + ...(asset.tags ?? []), + ...input.tags.filter((tag) => !(asset.tags ?? []).includes(tag)), + ], + } as (typeof base.assets)[number]) + : asset, + ); + disk.manifest = { ...base, assets }; + return { + assets: assets.filter((asset) => input.assetIds.includes(asset.id)), + committedProjectRevision: disk.revision, + }; +} + +/** + * 真宿主接线:壳持有清单状态,画布是它的消费者,写入后用 + * `onManifestChange(projectPath, next)` 回写这一份状态。 + * + * 项目切换按「同一份工作台实例换 props」模拟(不卸载、不换 key): + * 这正是迟到回包必须被身份门禁拦住的场景。 + */ +function BatchTagsHost({ + projectPath, + projectId, +}: { + projectPath: string; + projectId: string; +}) { + const [scope, setScope] = useState(() => ({ + projectPath, + projectId, + manifest: createFixtureManifest(projectId), + })); + if (scope.projectPath !== projectPath || scope.projectId !== projectId) { + setScope({ projectPath, projectId, manifest: createFixtureManifest(projectId) }); + } + disk.manifest = scope.manifest; + + return ( + undefined} + onProjectsOpen={() => undefined} + supervisor={
} + onManifestChange={(path, next) => { + manifestChanges.push({ path, projectId: next.projectId }); + // 壳按当前项目身份收敛:只有当前项目的回写才落到工作台状态里。 + setScope((current) => + current.projectPath === path + ? { ...current, manifest: next } + : current, + ); + }} + /> + ); +} + +const manifestChanges: Array<{ path: string; projectId: string }> = []; + +function installHostTauri( + options: { + deferBatchWrite?: boolean; + failBatchWriteWith?: string; + /** 写入已提交后的那次整份 manifest 回读挂在飞,用来测"回读期间切项目"。 */ + deferManifestReadAfterWrite?: boolean; + failManifestReadAfterWriteWith?: string; + } = {}, +) { + const batchWrites: BatchTagWrite[] = []; + const classificationWrites: Array> = []; + let manifestReads = 0; + let batchWriteCompleted = false; + let deferredManifestRead: (() => void) | null = null; + let resolveDeferredBatchWrite: (() => void) | null = null; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'get_local_game_project_revision') { + return { revision: disk.revision }; + } + if (command === 'get_local_game_manifest') { + manifestReads += 1; + const snapshot = disk.manifest; + if (!snapshot) throw new Error('missing manifest fixture'); + if (batchWriteCompleted && options.failManifestReadAfterWriteWith) { + throw new Error(options.failManifestReadAfterWriteWith); + } + if ( + batchWriteCompleted && + options.deferManifestReadAfterWrite && + deferredManifestRead === null + ) { + return await new Promise((resolve) => { + deferredManifestRead = () => resolve(snapshot); + }); + } + return snapshot; + } + if (command === 'add_local_project_resource_tags') { + const input = (args?.input ?? {}) as BatchTagWrite; + batchWrites.push(structuredClone(input)); + if (options.failBatchWriteWith) { + throw new Error(options.failBatchWriteWith); + } + if (options.deferBatchWrite) { + return await new Promise((resolve) => { + resolveDeferredBatchWrite = () => resolve(appendTagsToDisk(input)); + }); + } + const committed = appendTagsToDisk(input); + batchWriteCompleted = true; + return committed; + } + if (command === 'update_local_project_resource_classification') { + const input = (args?.input ?? {}) as { + assetId?: string; + category?: string; + tags?: string[]; + }; + classificationWrites.push(structuredClone(input)); + const base = disk.manifest; + if (!base) throw new Error('missing manifest fixture'); + disk.revision += 1; + const nextManifest: GameCreationAppManifest = { + ...base, + assets: base.assets.map((asset) => + asset.id === input.assetId + ? ({ + ...asset, + category: input.category, + tags: input.tags ?? [], + } as (typeof base.assets)[number]) + : asset, + ), + }; + disk.manifest = nextManifest; + const asset = nextManifest.assets.find( + (entry) => entry.id === input.assetId, + ); + if (!asset) throw new Error(`missing asset ${String(input.assetId)}`); + return { asset, committedProjectRevision: disk.revision }; + } + if (command === 'read_local_project_resource_graph') { + return graphFor(disk.manifest!); + } + if (command === 'read_local_project_resource_canvas_layout') { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: PROJECT_ID, + mode: args?.mode, + revision: 0, + positions: [], + updatedAt: 0, + }; + } + if (command === 'list_pending_local_project_resource_edits') { + return []; + } + if (command === 'read_local_project_resource_document') { + return { + path: String(args?.relativePath ?? ''), + mediaType: 'text/markdown', + byteLen: 1, + content: '', + }; + } + if ( + command === 'read_local_project_image_preview' || + command === 'read_local_project_text_preview' + ) { + return { + path: String(args?.relativePath ?? ''), + mediaType: 'image/png', + byteLen: 1, + dataUrl: 'data:image/png;base64,AA==', + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { + core: { invoke: invoke as never }, + event: { listen: (async () => () => undefined) as never }, + }; + return { + invoke, + batchWrites, + classificationWrites, + manifestReads: () => manifestReads, + resolveDeferredBatchWrite: () => resolveDeferredBatchWrite?.(), + hasDeferredManifestRead: () => deferredManifestRead !== null, + resolveDeferredManifestRead: () => deferredManifestRead?.(), + }; +} + +async function openCategoryPage(name: string) { + fireEvent.click(await screen.findByRole('button', { name })); +} + +async function openResourcePanel() { + fireEvent.click(screen.getByRole('button', { name: '资源面板' })); + return within(await screen.findByRole('dialog', { name: '资源面板' })); +} + +/** + * 在资源面板里按顺序累加选中项(`onToggleEntry` 就是画布那份共享选中集的 append 入口), + * 关闭面板后画布上留下的就是同一份多选。 + */ +async function selectPanelEntriesInOrder( + labels: readonly string[], + options: { clearFirst?: boolean } = {}, +) { + const panel = await openResourcePanel(); + if (options.clearFirst) { + fireEvent.click(panel.getByRole('button', { name: '清空选择' })); + } + for (const label of labels) { + fireEvent.click(panel.getByRole('button', { name: `选择 ${label}` })); + } + fireEvent.click(panel.getByRole('button', { name: '关闭资源面板' })); + await waitFor(() => + expect(screen.queryByRole('dialog', { name: '资源面板' })).toBeNull(), + ); +} + +beforeEach(() => { + disk.manifest = null; + disk.revision = 3; + manifestChanges.length = 0; +}); + +describe('多选素材批量追加标签(真实工作台)', () => { + it('资源面板入口按完整选中集打开批量面板,一次写入整组而不是首项', async () => { + const { batchWrites, classificationWrites, manifestReads } = + installHostTauri(); + render(); + + await openCategoryPage('打开角色与对象'); + const panel = await openResourcePanel(); + fireEvent.click(panel.getByRole('button', { name: '全选' })); + + const readsBefore = manifestReads(); + fireEvent.click( + await waitFor(() => panel.getByRole('button', { name: '批量标签(2)' })), + ); + + // 标签面板打开时资源面板先让位:两层都监听 Escape 时同一次按键会连标签面板一起关掉。 + expect(screen.queryByRole('dialog', { name: '资源面板' })).toBeNull(); + const dialog = await screen.findByRole('dialog', { + name: '批量追加标签', + }); + expect(within(dialog).getByText('已选 2 项素材')).not.toBeNull(); + // 只显示待追加草稿:既有标签不进面板,也不会被当成提交值。 + expect(within(dialog).queryByRole('list', { name: '已有标签' })).toBeNull(); + + fireEvent.change( + within(dialog).getByPlaceholderText('新增标签,多个用逗号分隔'), + { target: { value: '主角, 配角' } }, + ); + fireEvent.click(within(dialog).getByRole('button', { name: '追加标签' })); + + await waitFor(() => expect(batchWrites).toHaveLength(1)); + expect(batchWrites[0]).toEqual({ + projectPath: PROJECT_PATH, + expectedProjectId: PROJECT_ID, + expectedProjectRevision: 3, + assetIds: ['asset-hero', 'asset-npc'], + tags: ['主角', '配角'], + }); + // 绝不能退化成单素材写入(那正是"只改首项")。 + expect(classificationWrites).toHaveLength(0); + + // 成功后用原生返回的 revision 回读整份 manifest,两项都带上了新标签。 + await waitFor(() => expect(manifestReads()).toBeGreaterThan(readsBefore)); + await waitFor(() => { + expect( + disk.manifest?.assets.map((asset) => [asset.id, asset.tags]), + ).toEqual([ + ['asset-hero', ['主角', '配角']], + ['asset-npc', ['主角', '配角']], + ]); + }); + }); + + it('混合选择含正式项目版本时入口禁用并给出原因,不会只写已登记的两项', async () => { + const { batchWrites } = installHostTauri(); + render(); + + await openCategoryPage('打开所有资源'); + const panel = await openResourcePanel(); + fireEvent.click(panel.getByRole('button', { name: '全选' })); + + const button = (await waitFor(() => + panel.getByRole('button', { name: '批量标签' }), + )) as HTMLButtonElement; + expect(button.disabled).toBe(true); + expect(panel.getByText(/批量标签不可用:.*未登记素材/u)).not.toBeNull(); + + fireEvent.click(button); + expect(screen.queryByRole('dialog', { name: '批量追加标签' })).toBeNull(); + expect(batchWrites).toHaveLength(0); + }); + + it('单选仍走单素材面板:既有增删标签行为不变,选项不给批量入口', async () => { + const { batchWrites, classificationWrites } = installHostTauri(); + render(); + + await openCategoryPage('打开角色与对象'); + fireEvent.click(await findResourceSelectButton('hero.png')); + const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' }); + fireEvent.click(within(toolbar).getByRole('button', { name: '编辑标签' })); + + const dialog = await screen.findByRole('dialog', { name: '编辑素材标签' }); + fireEvent.change( + within(dialog).getByPlaceholderText('新增标签,多个用逗号分隔'), + { target: { value: '主角' } }, + ); + fireEvent.click(within(dialog).getByRole('button', { name: '添加' })); + + await waitFor(() => expect(classificationWrites).toHaveLength(1)); + expect(classificationWrites[0]).toMatchObject({ + assetId: 'asset-hero', + category: 'character', + tags: ['主角'], + }); + expect(batchWrites).toHaveLength(0); + }); + + it('原生拒绝整批写入时不留部分结果,面板保留待追加草稿并报出可读原因', async () => { + // 原生按 CAS 冲突拒绝本批写入:整批不写、面板报可读原因、草稿留着可直接重试。 + const { batchWrites } = installHostTauri({ + failBatchWriteWith: 'project-revision-conflict', + }); + render(); + + await openCategoryPage('打开角色与对象'); + const panel = await openResourcePanel(); + fireEvent.click(panel.getByRole('button', { name: '全选' })); + fireEvent.click( + await waitFor(() => panel.getByRole('button', { name: '批量标签(2)' })), + ); + const dialog = await screen.findByRole('dialog', { + name: '批量追加标签', + }); + + fireEvent.change( + within(dialog).getByPlaceholderText('新增标签,多个用逗号分隔'), + { target: { value: '春节' } }, + ); + fireEvent.click(within(dialog).getByRole('button', { name: '追加标签' })); + + await waitFor(() => + expect( + within(dialog).getByRole('alert').textContent, + ).toBe('项目已被其它操作改动,请刷新后重试'), + ); + // 整批只发出一次请求,失败后 revision 不推进、草稿保留(重试不用重填)。 + expect(batchWrites).toHaveLength(1); + expect(disk.revision).toBe(3); + expect( + within(dialog) + .getByRole('list', { name: '待追加标签' }) + .textContent, + ).toContain('春节'); + }); + + it('切项目关闭批量面板并丢弃迟到回包,不把旧项目清单回写到新项目', async () => { + const { resolveDeferredBatchWrite } = installHostTauri({ + deferBatchWrite: true, + }); + const view = render( + , + ); + + await openCategoryPage('打开角色与对象'); + const panel = await openResourcePanel(); + fireEvent.click(panel.getByRole('button', { name: '全选' })); + fireEvent.click( + await waitFor(() => panel.getByRole('button', { name: '批量标签(2)' })), + ); + const dialog = await screen.findByRole('dialog', { + name: '批量追加标签', + }); + fireEvent.change( + within(dialog).getByPlaceholderText('新增标签,多个用逗号分隔'), + { target: { value: '春节' } }, + ); + fireEvent.click(within(dialog).getByRole('button', { name: '追加标签' })); + + // 写入还没回来时切项目:面板必须关掉。 + view.rerender( + , + ); + await waitFor(() => + expect( + screen.queryByRole('dialog', { name: '批量追加标签' }), + ).toBeNull(), + ); + + const changesBeforeLateWrite = manifestChanges.length; + resolveDeferredBatchWrite(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // 迟到回包不得触发旧项目的 manifest 回写(更没有盖到新项目上)。 + expect(manifestChanges.length).toBe(changesBeforeLateWrite); + expect(manifestChanges.map((change) => change.path)).not.toContain( + PROJECT_PATH, + ); + }); + + it('整份 manifest 回读期间切项目:回读完成后仍要判断有效性,旧项目清单不回写', async () => { + const { + batchWrites, + hasDeferredManifestRead, + resolveDeferredManifestRead, + } = installHostTauri({ deferManifestReadAfterWrite: true }); + const view = render( + , + ); + + await openCategoryPage('打开角色与对象'); + const panel = await openResourcePanel(); + fireEvent.click(panel.getByRole('button', { name: '全选' })); + fireEvent.click( + await waitFor(() => panel.getByRole('button', { name: '批量标签(2)' })), + ); + const dialog = await screen.findByRole('dialog', { + name: '批量追加标签', + }); + fireEvent.change( + within(dialog).getByPlaceholderText('新增标签,多个用逗号分隔'), + { target: { value: '春节' } }, + ); + fireEvent.click(within(dialog).getByRole('button', { name: '追加标签' })); + + // 写入已经提交,回读整份清单还挂在飞 —— 此刻切项目正是最危险的时间窗: + // 提交回包前的核对早就过去了,回来的是旧项目的完整清单。 + await waitFor(() => expect(batchWrites).toHaveLength(1)); + await waitFor(() => expect(hasDeferredManifestRead()).toBe(true)); + + const oldProjectChangesBeforeSwitch = () => + manifestChanges.filter((change) => change.path === PROJECT_PATH).length; + const oldProjectChangesAtSwitch = oldProjectChangesBeforeSwitch(); + + view.rerender( + , + ); + await waitFor(() => + expect( + screen.queryByRole('dialog', { name: '批量追加标签' }), + ).toBeNull(), + ); + + resolveDeferredManifestRead(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // 回读结果属于已经离开的旧项目:不得触发旧路径的 manifest 回写。 + expect(oldProjectChangesBeforeSwitch()).toBe(oldProjectChangesAtSwitch); + }); + + it('原生写入成功但整份 manifest 回读失败时,说明已保存、只是刷新失败', async () => { + const { batchWrites } = installHostTauri({ + failManifestReadAfterWriteWith: '读取项目清单超时', + }); + render(); + + await openCategoryPage('打开角色与对象'); + const panel = await openResourcePanel(); + fireEvent.click(panel.getByRole('button', { name: '全选' })); + fireEvent.click( + await waitFor(() => panel.getByRole('button', { name: '批量标签(2)' })), + ); + const dialog = await screen.findByRole('dialog', { + name: '批量追加标签', + }); + fireEvent.change( + within(dialog).getByPlaceholderText('新增标签,多个用逗号分隔'), + { target: { value: '春节' } }, + ); + fireEvent.click(within(dialog).getByRole('button', { name: '追加标签' })); + + await waitFor(() => expect(batchWrites).toHaveLength(1)); + // 写入已在原生侧提交:提示必须是「已保存、刷新失败」,不能谎报保存失败。 + await waitFor(() => + expect( + screen.getByText(/标签已保存,但刷新失败:读取项目清单超时/u), + ).not.toBeNull(), + ); + // 已在磁盘上的写入确实生效(只是宿主没拿到新清单)。 + expect( + disk.manifest?.assets.map((asset) => asset.tags), + ).toEqual([['春节'], ['春节']]); + }); + + it('画布多选 2 份真实素材时,「编辑标签」打开的就是批量模式并按整组写入', async () => { + const { batchWrites, classificationWrites } = installHostTauri(); + render(); + + // 画布与资源面板共用同一份选中集:这里按顺序累加 hero、npc, + // 关掉面板后画布上的多选就是这两份已登记素材。 + await openCategoryPage('打开所有资源'); + await selectPanelEntriesInOrder(['hero.png', 'npc.png']); + + const toolbar = await screen.findByRole('toolbar', { + name: '图片工具栏', + }); + fireEvent.click(within(toolbar).getByRole('button', { name: '编辑标签' })); + + const dialog = await screen.findByRole('dialog', { + name: '批量追加标签', + }); + expect(within(dialog).getByText('已选 2 项素材')).not.toBeNull(); + fireEvent.change( + within(dialog).getByPlaceholderText('新增标签,多个用逗号分隔'), + { target: { value: '春节' } }, + ); + fireEvent.click(within(dialog).getByRole('button', { name: '追加标签' })); + + await waitFor(() => expect(batchWrites).toHaveLength(1)); + // 整批两份都在写入对象里,而不是只改首项。 + expect(batchWrites[0]?.assetIds).toEqual(['asset-hero', 'asset-npc']); + expect(classificationWrites).toHaveLength(0); + }); + + it('首项是项目版本时工具条不整条消失:批量入口禁用并给出原因(两种选择顺序一致)', async () => { + const { batchWrites } = installHostTauri(); + render(); + + // 版本(未登记素材)排在第一项:整份选择集不能整批写入, + // 但工具条必须还在,把"为什么不能批量写"显示出来。 + await openCategoryPage('打开所有资源'); + await selectPanelEntriesInOrder(['版本 1', 'hero.png', 'npc.png']); + + const toolbar = await screen.findByRole('toolbar', { + name: '图片工具栏', + }); + const versionFirst = within(toolbar).getByRole('button', { + name: '编辑标签', + }) as HTMLButtonElement; + expect(versionFirst.disabled).toBe(true); + expect(versionFirst.title).toContain('未登记素材'); + + fireEvent.click(versionFirst); + expect(screen.queryByRole('dialog', { name: '批量追加标签' })).toBeNull(); + expect(batchWrites).toHaveLength(0); + + // 反向顺序(已登记素材在前、版本在后)给出同一份禁用原因: + // 入口判据只看整份选择集,不取决于哪一项被先选中。 + await selectPanelEntriesInOrder(['hero.png', 'npc.png', '版本 1'], { + clearFirst: true, + }); + + const assetFirstToolbar = await screen.findByRole('toolbar', { + name: '图片工具栏', + }); + const assetFirst = within(assetFirstToolbar).getByRole('button', { + name: '编辑标签', + }) as HTMLButtonElement; + expect(assetFirst.disabled).toBe(true); + expect(assetFirst.title).toBe(versionFirst.title); + fireEvent.click(assetFirst); + expect(screen.queryByRole('dialog', { name: '批量追加标签' })).toBeNull(); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasPanelBatchTags.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasPanelBatchTags.test.tsx new file mode 100644 index 000000000..5f36e9b5c --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceCanvasPanelBatchTags.test.tsx @@ -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 { + 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( + , + ); + 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(); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceClassificationPanel.test.tsx b/apps/ai-game-creator-shell/tests/resourceClassificationPanel.test.tsx index 2df832dce..427ab3f43 100644 --- a/apps/ai-game-creator-shell/tests/resourceClassificationPanel.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceClassificationPanel.test.tsx @@ -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('编辑标签'); expect(viewSource).not.toContain('label="分类与标签"'); }); diff --git a/apps/ai-game-creator-shell/tests/resourceClassificationPanelBatchTags.test.tsx b/apps/ai-game-creator-shell/tests/resourceClassificationPanelBatchTags.test.tsx new file mode 100644 index 000000000..b9bcb32e5 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceClassificationPanelBatchTags.test.tsx @@ -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, +) { + 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[]) => ( + + ); + 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) { + 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(''); + }); +}); diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index 36f6c9fa7..2c1d633cf 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -63,6 +63,7 @@ - 所有资源卡在卡面显示正式资源名称,沿用生成命名和用户重命名后的资源投影;长名称单行省略,实际可交互的卡片入口提供完整名称提示。来源、完整路径、任务和媒体类型等详细字段继续进入搜索索引和中央详情;卡片入口的可访问名称必须包含稳定可辨识的资源名与类别。文档卡仅展示居中文档图标和名称,正文在独立预览中展示,不把正文摘要铺在卡面。 - 显式“整理画布”重排当前栏目全部资源(包含手动坐标和被筛选隐藏的资源),其他栏目不变;“所有资源”页作用于全部可展示资源。重排可一次撤销,恢复原坐标及手动标记。自动协调仍保留手动坐标,不因新增素材自行重排。 - 当前画布可见资源框选后可成组移动,保持相对位置;松手统一保存且一次撤销。取消手势还原拖动前布局,切项目清理选择,不将隐藏或跨栏目残留选择带入操作。 +- 多选已登记素材后,可从选中工具栏“编辑标签”或资源面板“批量标签”入口统一追加标签。面板明确实际目标数量,保存对象在打开时冻结,保留每项原标签及素材类型;不把已有标签并集覆盖到每项。混合未登记资源、超过 200 项、任何一项标签越界或项目版本冲突时整批拒绝,不静默跳过。一次保存更新整批素材,失败保留待追加标签;切项目不将迟到结果写入新项目。单素材编辑保留原有增删标签行为。 - 卡片外层是非交互容器;“打开详情”与“播放 / 暂停”必须是可分别键盘聚焦的同级按钮,禁止在 `