AGC 画布验收问题修复与栏目画布底部工具栏
Project CI / Repository checks (pull_request) Successful in 2m55s
Project CI / Frontend tests (pull_request) Successful in 3m59s
Project CI / Backend tests (pull_request) Successful in 6m50s
Project CI / Native shell tests (pull_request) Failing after 14m3s

- 合并资源画布命名筛选与条件筛选为单一筛选浮层,Dock 只留放大镜入口
- 修复「编辑素材标签」面板标签多时不可见且不可滚:加高度上界与标签区独立滚动
- 替换素材新增点选替换模式,并在资源卡标注会话内替换血缘
- 新增栏目画布底部工具栏,按功能画布分流图片类生成、音频生成与上传入口
- 新增 Tauri IPC generate_local_project_asset,收口图片类无源生成的 kind 与提示词目录
- 同步 PRD、AGC 验收用例、决策日志、踩坑记录与待解决事项文档
This commit is contained in:
2026-09-13 14:07:03 +08:00
parent aa0fcc1d71
commit 55fab1776a
33 changed files with 4898 additions and 544 deletions
@@ -2063,16 +2063,14 @@ fn bridge_image_generation_kind(arguments: &Value) -> Result<String, String> {
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("image");
if !matches!(
kind,
"image" | "character" | "icon-spec" | "ui-prototype" | "publication-material"
) {
return Err(
"工具参数 kind 只允许 image、character、icon-spec、ui-prototype 或 publication-material"
.to_string(),
);
}
Ok(kind.to_string())
normalize_platform_art_asset_generation_kind(kind)
.map(str::to_string)
.ok_or_else(|| {
format!(
"工具参数 kind 只允许 {}",
PLATFORM_ART_ASSET_GENERATION_KINDS.join("")
)
})
}
async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value) -> Value {
@@ -183,7 +183,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value {
}),
json!({
"name": "agc_generate_image",
"description": "按原网站图片画布能力生成一张新图片:普通插画、角色立绘、统一视觉规范图游戏 UI 设计图都可使用。仅在用户明确要求生成新图时调用;游戏美术包是另一个专用工具,不是本工具的限制。客户端负责登录态授权、计费、幂等账本、下载校验、manifest/revision 登记和本地预览,不需要用户提供 API Key、Token、URL 或 .env。",
"description": "按原网站图片画布能力生成一张新图片:普通插画、角色立绘、统一视觉规范图游戏 UI 设计图或透明游戏素材图集都可使用。仅在用户明确要求生成新图时调用;游戏美术包是另一个专用工具,不是本工具的限制。客户端负责登录态授权、计费、幂等账本、下载校验、manifest/revision 登记和本地预览,不需要用户提供 API Key、Token、URL 或 .env。",
"inputSchema": {
"type": "object",
"properties": {
@@ -191,13 +191,13 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value {
"type": "string",
"minLength": 1,
"maxLength": DIRECT_TOOLS_MCP_MAX_IMAGE_PROMPT_CHARS,
"description": "完整图片描述;普通图片、角色、规范图UI 设计图均可"
"description": "完整图片描述;普通图片、角色、规范图UI 设计图或透明图集均可"
},
"kind": {
"type": "string",
"enum": ["image", "character", "icon-spec", "ui-prototype", "publication-material"],
"enum": PLATFORM_ART_ASSET_GENERATION_KINDS,
"default": "image",
"description": "image=普通新图,character=角色图,icon-spec=视觉规范图ui-prototype=完整 UI 设计图publication-material=发布宣传图"
"description": "image=普通新图,character=角色图,spec/icon-spec=统一视觉规范图(spec 是服务端同义词,客户端统一登记为 icon-spec),ui-prototype=完整 UI 设计图,art-spritesheet=透明游戏素材图集(项目须已有 icon-spec 规范图)publication-material=发布宣传图"
},
"aspectRatio": {
"type": "string",
@@ -966,15 +966,10 @@ async fn call_agc_generate_image(arguments: &Value) -> Value {
return mcp_tool_result(error, Vec::new(), true);
}
if let Some(kind) = arguments.get("kind") {
if !kind.is_string()
|| ![
"image",
"character",
"icon-spec",
"ui-prototype",
"publication-material",
]
.contains(&kind.as_str().unwrap_or_default())
if !kind
.as_str()
.and_then(normalize_platform_art_asset_generation_kind)
.is_some()
{
return mcp_tool_result(
"工具参数 kind 不是受支持的图片生成类型".to_string(),
@@ -1879,16 +1874,6 @@ mod tests {
.iter()
.find(|tool| tool["name"] == "agc_generate_image")
.expect("image tool");
assert_eq!(
image_tool["inputSchema"]["properties"]["kind"]["enum"],
json!([
"image",
"character",
"icon-spec",
"ui-prototype",
"publication-material"
])
);
assert_eq!(image_tool["inputSchema"]["required"], json!(["prompt"]));
assert!(image_tool["description"]
.as_str()
@@ -1964,6 +1949,27 @@ mod tests {
assert!(!specs.to_string().contains("apiKey"));
}
#[tokio::test]
async fn generate_image_rejects_kinds_outside_the_shared_catalog_before_the_bridge() {
// 目录外的 kind 必须在本地拒绝:既不下发 bridge,也不产生任何计费副作用。
let unsupported_kind = call_agc_generate_image(&json!({
"prompt": "像素月光主角",
"kind": "game-art"
}))
.await;
assert_eq!(unsupported_kind["isError"], json!(true));
assert!(unsupported_kind["content"][0]["text"]
.as_str()
.is_some_and(|text| text.contains("kind")));
let oversized_prompt = call_agc_generate_image(&json!({
"prompt": "x".repeat(DIRECT_TOOLS_MCP_MAX_IMAGE_PROMPT_CHARS + 1),
"kind": "art-spritesheet"
}))
.await;
assert_eq!(oversized_prompt["isError"], json!(true));
}
#[test]
fn semantic_resource_tools_reject_unreviewed_or_inconsistent_arguments() {
assert!(validate_registered_assets_arguments(&json!({
@@ -66,10 +66,11 @@ pub(crate) use canvas_generation::{
build_platform_art_asset_prompt, editor_api_key_is_configured, generate_platform_art_asset_at,
generate_platform_art_asset_with_options_at,
generate_platform_art_asset_with_required_slices_at, maybe_generate_platform_art_asset_step,
needs_platform_art_asset_generation, platform_art_asset_art_spec,
platform_art_asset_output_extension_matches, prepare_platform_art_asset_output_path,
project_canvas_asset_media_types, role_has_canvas_assets, suggested_canvas_tool_call,
PlatformArtAssetGenerationOptions,
needs_platform_art_asset_generation, normalize_platform_art_asset_generation_kind,
platform_art_asset_art_spec, platform_art_asset_output_extension_matches,
prepare_platform_art_asset_output_path, project_canvas_asset_media_types,
role_has_canvas_assets, suggested_canvas_tool_call, PlatformArtAssetGenerationOptions,
PLATFORM_ART_ASSET_GENERATION_KINDS,
};
#[allow(unused_imports)]
pub(crate) use draft_validation::{
@@ -429,6 +429,40 @@ impl Default for PlatformArtAssetGenerationOptions {
}
}
/// 「无源生成图片类素材」参数化通道放行的 kind 目录。
///
/// GUI 侧 `generate_local_project_asset` 与 agent 侧 `agc_generate_image` 共用这一份目录,
/// 两条调用路径不允许各写一套白名单;目录外的 kind 一律拒绝,不做兜底猜测。
///
/// `spec` 只是平台图片生成的 generation kind 名称,客户端真正验证过的规范图类型是
/// `icon-spec``build_platform_art_asset_prompt`、`platform_art_asset_art_spec` 与
/// `canonical_art_spec_reference_at` 都只认 `icon-spec`,登记成 `spec` 的图既拿不到规范图
/// 提示词,也无法作为 UI 原型与透明图集的权威参考。因此 `spec` 在
/// `normalize_platform_art_asset_generation_kind` 里统一收口到 `icon-spec`。
pub(crate) const PLATFORM_ART_ASSET_GENERATION_KINDS: &[&str] = &[
"image",
"character",
"spec",
"icon-spec",
"ui-prototype",
"art-spritesheet",
"publication-material",
];
/// 把外部传入的 kind 收口到 `PlatformArtAssetGenerationOptions::asset_kind` 的权威取值。
/// 返回 `None` 表示该 kind 未被验证过,调用方必须拒绝。
pub(crate) fn normalize_platform_art_asset_generation_kind(kind: &str) -> Option<&'static str> {
let kind = kind.trim();
let canonical = PLATFORM_ART_ASSET_GENERATION_KINDS
.iter()
.find(|candidate| **candidate == kind)?;
Some(if *canonical == "spec" {
"icon-spec"
} else {
canonical
})
}
pub(in crate::agent) fn recover_persisted_visual_generation_options(
root: &Path,
pending: &AgentRuntimePendingToolAction,
@@ -7826,6 +7860,69 @@ mod canvas_generation_tests {
}
}
#[test]
fn generation_kind_catalog_normalizes_spec_onto_the_verified_icon_spec() {
// 目录就是两条调用路径共同的可生成集合,必须逐字固定。
assert_eq!(
PLATFORM_ART_ASSET_GENERATION_KINDS,
&[
"image",
"character",
"spec",
"icon-spec",
"ui-prototype",
"art-spritesheet",
"publication-material"
]
);
for kind in ["image", "character", "ui-prototype", "art-spritesheet"] {
assert_eq!(
normalize_platform_art_asset_generation_kind(kind),
Some(kind),
"{kind} 必须原样落到 assetKind"
);
}
// `spec` 只是服务端 generation kind;客户端验证过的规范图类型是 icon-spec,
// 提示词与规范图引用都只认它,所以这里必须收口而不是放行原值。
assert_eq!(
normalize_platform_art_asset_generation_kind("spec"),
Some("icon-spec")
);
assert_eq!(
normalize_platform_art_asset_generation_kind("icon-spec"),
Some("icon-spec")
);
assert_eq!(
normalize_platform_art_asset_generation_kind(" art-spritesheet "),
Some("art-spritesheet")
);
// 未验证的 kind 一律拒绝,不做兜底猜测。
for kind in ["game-art", "game-background", "UI", "asset", "scene", ""] {
assert_eq!(
normalize_platform_art_asset_generation_kind(kind),
None,
"{kind} 不在已审核目录内,必须被拒绝"
);
}
}
#[test]
fn generation_kind_catalog_binds_art_spritesheet_to_a_spec_board_prompt() {
// 放行 art-spritesheet 必须真的走到图集提示词与图集请求合同,
// 否则新 IPC 只是加了一个能通过校验但不生成图集的 kind。
let options = PlatformArtAssetGenerationOptions {
asset_kind: "art-spritesheet".to_string(),
..PlatformArtAssetGenerationOptions::default()
};
assert!(
build_platform_art_asset_prompt("原创收集玩法", &[], &options).contains("素材图集")
);
assert_eq!(
platform_art_asset_art_spec(&options)["assetType"],
serde_json::json!("art")
);
}
#[test]
fn external_http_error_summary_keeps_validation_details_without_sensitive_context() {
let body = serde_json::json!({
@@ -4413,6 +4413,306 @@ pub(crate) async fn generate_platform_art_asset(
Ok(generated.asset)
}
/// 栏目画布底部工具栏使用的「无源生成图片类素材」入参边界。
/// kind 目录由 `canvas_generation::PLATFORM_ART_ASSET_GENERATION_KINDS` 统一维护,
/// 这里只收口比例、尺寸与文本长度,且与 agent 侧同一条通道保持同一量级。
const LOCAL_PROJECT_ASSET_MAX_PROMPT_CHARS: usize = 32_000;
const LOCAL_PROJECT_ASSET_MAX_ASSET_NAME_CHARS: usize = 120;
const LOCAL_PROJECT_ASSET_MAX_OUTPUT_PATH_CHARS: usize = 512;
const LOCAL_PROJECT_ASSET_ASPECT_RATIOS: &[&str] = &["1:1", "2:3", "3:2", "9:16", "16:9"];
const LOCAL_PROJECT_ASSET_IMAGE_SIZES: &[&str] = &["0.5K", "1K", "2K"];
const LOCAL_PROJECT_ASSET_DEFAULT_ASSET_NAME: &str = "AI 生成素材";
/// 已通过校验、可直接交给参数化生成通道的一次请求。
#[derive(Debug, Eq, PartialEq)]
pub(crate) struct LocalProjectAssetGenerationRequest {
pub(crate) root: PathBuf,
pub(crate) prompt: String,
pub(crate) options: PlatformArtAssetGenerationOptions,
}
fn local_project_asset_prompt(prompt: &str) -> Result<String, String> {
let prompt = prompt.trim();
if prompt.is_empty() {
return Err("生成要求不能为空".to_string());
}
// 工具栏是多行输入框,换行与制表符是合法正文;其余控制字符一律拒绝。
if prompt.chars().count() > LOCAL_PROJECT_ASSET_MAX_PROMPT_CHARS
|| prompt
.chars()
.any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t'))
{
return Err("生成要求超出安全边界".to_string());
}
Ok(prompt.to_string())
}
fn local_project_asset_single_line(
value: Option<&str>,
max_chars: usize,
label: &str,
) -> Result<Option<String>, String> {
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(None);
};
if value.chars().count() > max_chars || value.chars().any(char::is_control) {
return Err(format!("{label}超出安全边界"));
}
Ok(Some(value.to_string()))
}
fn local_project_asset_choice(
value: Option<&str>,
allowed: &[&str],
default: &str,
label: &str,
) -> Result<String, String> {
match value.map(str::trim).filter(|value| !value.is_empty()) {
None => Ok(default.to_string()),
Some(value) if allowed.contains(&value) => Ok(value.to_string()),
Some(value) => Err(format!("{label}不受支持:{value}")),
}
}
/// 收口 GUI 入参并装配参数化生成通道的 options。
///
/// 这里不做输出路径的 assets/ 归属与防覆盖校验:那是生成通道内
/// `prepare_platform_art_asset_output_path_for_mode` 的职责,重复一份只会产生第二个口径。
pub(crate) fn prepare_local_project_asset_generation(
project_path: &str,
kind: &str,
prompt: &str,
aspect_ratio: Option<&str>,
image_size: Option<&str>,
asset_name: Option<&str>,
output_path: Option<&str>,
) -> Result<LocalProjectAssetGenerationRequest, String> {
let project_path = project_path.trim();
if project_path.is_empty() {
return Err("项目路径不能为空".to_string());
}
let asset_kind = normalize_platform_art_asset_generation_kind(kind)
.ok_or_else(|| format!("素材类型不受支持:{}", kind.trim()))?;
Ok(LocalProjectAssetGenerationRequest {
root: PathBuf::from(project_path),
prompt: local_project_asset_prompt(prompt)?,
options: PlatformArtAssetGenerationOptions {
output_path: local_project_asset_single_line(
output_path,
LOCAL_PROJECT_ASSET_MAX_OUTPUT_PATH_CHARS,
"输出路径",
)?,
aspect_ratio: local_project_asset_choice(
aspect_ratio,
LOCAL_PROJECT_ASSET_ASPECT_RATIOS,
"1:1",
"图片比例",
)?,
image_size: local_project_asset_choice(
image_size,
LOCAL_PROJECT_ASSET_IMAGE_SIZES,
"1K",
"图片尺寸",
)?,
asset_kind: asset_kind.to_string(),
asset_label: local_project_asset_single_line(
asset_name,
LOCAL_PROJECT_ASSET_MAX_ASSET_NAME_CHARS,
"素材名称",
)?
.unwrap_or_else(|| LOCAL_PROJECT_ASSET_DEFAULT_ASSET_NAME.to_string()),
replace_existing: false,
slice_count: None,
},
})
}
/// 栏目画布底部工具栏的「无源生成图片类素材」IPC。
///
/// 与 `generate_platform_art_asset` 一样只返回 `UploadLocalAssetResult`id/localPath/
/// absolutePath/manifestPath),前端据此拿到登记后的资源身份并用 manifestPath 刷新 manifest。
/// 生成、计费、幂等账本、下载校验、manifest/revision 登记与本地预览全部转调
/// `generate_platform_art_asset_with_options_at`,本命令不复制任何生成逻辑。
///
/// 与 agent 侧参数化通道一致,这里同时要求 `asset.register`:生成结果必定写入 manifest。
#[tauri::command]
pub(crate) async fn generate_local_project_asset(
project_path: String,
kind: String,
prompt: String,
aspect_ratio: Option<String>,
image_size: Option<String>,
asset_name: Option<String>,
output_path: Option<String>,
) -> Result<UploadLocalAssetResult, String> {
let request = prepare_local_project_asset_generation(
&project_path,
&kind,
&prompt,
aspect_ratio.as_deref(),
image_size.as_deref(),
asset_name.as_deref(),
output_path.as_deref(),
)?;
enforce_project_permission_policy(&request.root, "canvas.asset_generate")?;
enforce_project_permission_policy(&request.root, "asset.register")?;
let generated = generate_platform_art_asset_with_options_at(
&request.root,
&request.prompt,
&[],
&request.options,
)
.await?;
Ok(generated.asset)
}
#[cfg(test)]
mod local_project_asset_generation_tests {
use super::*;
fn prepare(kind: &str, prompt: &str) -> Result<LocalProjectAssetGenerationRequest, String> {
prepare_local_project_asset_generation("/tmp/project", kind, prompt, None, None, None, None)
}
#[test]
fn toolbar_kinds_all_reach_the_shared_generation_channel() {
for kind in [
"image",
"character",
"spec",
"icon-spec",
"ui-prototype",
"art-spritesheet",
] {
let request = prepare(kind, "像素月光厨房主角").expect("toolbar kind is supported");
assert_eq!(request.root, PathBuf::from("/tmp/project"));
assert_eq!(request.prompt, "像素月光厨房主角");
// `spec` 只放行到权威类型;其余 kind 原样进入参数化通道。
let expected = if kind == "spec" { "icon-spec" } else { kind };
assert_eq!(request.options.asset_kind, expected);
assert!(!request.options.replace_existing);
assert_eq!(request.options.slice_count, None);
}
}
#[test]
fn omitted_options_fall_back_to_the_channel_defaults() {
let request = prepare("image", "像素月光厨房主角").expect("defaults");
assert_eq!(request.options.aspect_ratio, "1:1");
assert_eq!(request.options.image_size, "1K");
assert_eq!(request.options.asset_label, "AI 生成素材");
assert_eq!(request.options.output_path, None);
let explicit = prepare_local_project_asset_generation(
" /tmp/project ",
" art-spritesheet ",
" 像素图集 ",
Some("16:9"),
Some("2K"),
Some(" 主角图集 "),
Some(" assets/hero.png "),
)
.expect("explicit options");
assert_eq!(explicit.root, PathBuf::from("/tmp/project"));
assert_eq!(explicit.prompt, "像素图集");
assert_eq!(explicit.options.asset_kind, "art-spritesheet");
assert_eq!(explicit.options.aspect_ratio, "16:9");
assert_eq!(explicit.options.image_size, "2K");
assert_eq!(explicit.options.asset_label, "主角图集");
assert_eq!(
explicit.options.output_path.as_deref(),
Some("assets/hero.png")
);
}
#[test]
fn toolbar_prompts_keep_multi_line_text_but_reject_other_control_characters() {
let multiline =
prepare("character", "第一行要求\n第二行要求\t制表符").expect("multi-line prompt");
assert!(multiline.prompt.contains('\n'));
let error = prepare("character", "带控制字符\u{7}的要求").expect_err("control character");
assert_eq!(error, "生成要求超出安全边界");
}
#[test]
fn invalid_toolbar_arguments_are_rejected_before_any_generation() {
assert_eq!(
prepare_local_project_asset_generation("", "image", "要求", None, None, None, None)
.expect_err("empty project path"),
"项目路径不能为空"
);
assert_eq!(
prepare("image", " ").expect_err("empty prompt"),
"生成要求不能为空"
);
assert_eq!(
prepare("game-art", "要求").expect_err("unverified kind"),
"素材类型不受支持:game-art"
);
assert_eq!(
prepare(
"spec",
&"x".repeat(LOCAL_PROJECT_ASSET_MAX_PROMPT_CHARS + 1)
)
.expect_err("oversized prompt"),
"生成要求超出安全边界"
);
assert_eq!(
prepare_local_project_asset_generation(
"/tmp/project",
"image",
"要求",
Some("4:3"),
None,
None,
None
)
.expect_err("unsupported ratio"),
"图片比例不受支持:4:3"
);
assert_eq!(
prepare_local_project_asset_generation(
"/tmp/project",
"image",
"要求",
None,
Some("4K"),
None,
None
)
.expect_err("unsupported size"),
"图片尺寸不受支持:4K"
);
assert_eq!(
prepare_local_project_asset_generation(
"/tmp/project",
"image",
"要求",
None,
None,
Some("\u{7}名字"),
None
)
.expect_err("control character in asset name"),
"素材名称超出安全边界"
);
assert_eq!(
prepare_local_project_asset_generation(
"/tmp/project",
"image",
"要求",
None,
None,
None,
Some(&"a".repeat(LOCAL_PROJECT_ASSET_MAX_OUTPUT_PATH_CHARS + 1))
)
.expect_err("oversized output path"),
"输出路径超出安全边界"
);
}
}
#[tauri::command]
pub(crate) fn open_canvas_project(
app: tauri::AppHandle,
@@ -2656,6 +2656,7 @@ fn main() {
generate_ui_design_code,
ensure_ui_design_resource_for_prototype,
generate_platform_art_asset,
generate_local_project_asset,
open_canvas_project,
get_game_creation_agent_capabilities,
get_limited_local_commands,
@@ -2520,6 +2520,224 @@ async fn generate_platform_art_asset_downloads_and_registers_external_image() {
fs::remove_dir_all(config_dir).ok();
}
#[tokio::test]
async fn generate_local_project_asset_command_registers_the_requested_toolbar_kind() {
let root = unique_project_path();
let config_dir = unique_project_path();
let base_url = spawn_mock_external_canvas_generation_api_server(None);
let _platform_session = crate::platform_session::install_test_platform_session(
"toolbar-asset-user",
"editor-toolbar-key",
&base_url,
);
init_local_game_project_at(&root, "project-toolbar-asset", "未命名游戏原型")
.expect("project init");
fs::create_dir_all(&config_dir).expect("runtime config dir");
fs::write(
config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME),
serde_json::json!({
"editorApi": {
"baseUrl": base_url,
"apiKey": "editor-toolbar-key"
}
})
.to_string(),
)
.expect("write runtime config");
let _guard = use_test_runtime_config_dir(config_dir.clone());
let asset = generate_local_project_asset(
root.to_string_lossy().into_owned(),
"image".to_string(),
"透明 PNG 像素月光主角".to_string(),
Some("16:9".to_string()),
Some("2K".to_string()),
Some("工具栏图片".to_string()),
None,
)
.await
.expect("toolbar asset generation");
assert!(asset.local_path.starts_with("assets/canvas-generated/"));
assert_eq!(
fs::read(&asset.absolute_path).expect("read generated toolbar asset bytes"),
valid_test_png_bytes()
);
assert!(Path::new(&asset.manifest_path).is_file());
let manifest: Value =
serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap())
.expect("manifest json");
let entry = manifest["assets"]
.as_array()
.expect("manifest assets")
.iter()
.find(|entry| entry["id"].as_str() == Some(asset.id.as_str()))
.expect("registered toolbar asset");
// 工具栏请求的 kind 必须一路落到 manifest,而不是退回旧 IPC 的 game-art 默认值。
assert_eq!(entry["kind"], "image");
assert_eq!(entry["mediaType"], "image/png");
assert_eq!(entry["localPath"], serde_json::json!(asset.local_path));
assert_eq!(entry["source"]["kind"], "canvas");
assert_eq!(entry["source"]["resourceId"], "resource-1");
assert_eq!(entry["source"]["assetObjectId"], "asset-object-1");
fs::remove_dir_all(root).ok();
fs::remove_dir_all(config_dir).ok();
}
#[tokio::test]
async fn generate_local_project_asset_command_maps_spec_onto_the_verified_icon_spec_channel() {
let root = unique_project_path();
let config_dir = unique_project_path();
let base_url = spawn_mock_external_canvas_generation_api_server(None);
let _platform_session = crate::platform_session::install_test_platform_session(
"toolbar-spec-user",
"editor-toolbar-spec-key",
&base_url,
);
init_local_game_project_at(&root, "project-toolbar-spec", "未命名游戏原型")
.expect("project init");
fs::create_dir_all(&config_dir).expect("runtime config dir");
fs::write(
config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME),
serde_json::json!({
"editorApi": {
"baseUrl": base_url,
"apiKey": "editor-toolbar-spec-key"
}
})
.to_string(),
)
.expect("write runtime config");
let _guard = use_test_runtime_config_dir(config_dir.clone());
let asset = generate_local_project_asset(
root.to_string_lossy().into_owned(),
"spec".to_string(),
"原创收集玩法的统一视觉规范".to_string(),
None,
None,
Some("视觉规范图".to_string()),
None,
)
.await
.expect("toolbar spec generation");
let manifest: Value =
serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap())
.expect("manifest json");
let entry = manifest["assets"]
.as_array()
.expect("manifest assets")
.iter()
.find(|entry| entry["id"].as_str() == Some(asset.id.as_str()))
.expect("registered toolbar spec asset");
// spec 必须收口到客户端验证过的 icon-spec,否则规范图无法作为 UI 原型与透明图集的权威参考。
assert_eq!(entry["kind"], "icon-spec");
assert_eq!(entry["source"]["generationKind"], "spec");
assert_eq!(
entry["source"]["generationRoute"],
"/api/external/v1/editor/images/generations"
);
// 规范图不引用任何既有资源,manifest 因此不写 referenceResourceIds 字段。
assert!(
entry["source"].get("referenceResourceIds").is_none(),
"规范图不应带参考图引用:{}",
entry["source"]
);
fs::remove_dir_all(root).ok();
fs::remove_dir_all(config_dir).ok();
}
#[tokio::test]
async fn generate_local_project_asset_command_generates_art_spritesheet_from_the_registered_spec() {
let root = unique_project_path();
let config_dir = unique_project_path();
let base_url = spawn_mock_external_canvas_generation_api_server(None);
let _platform_session = crate::platform_session::install_test_platform_session(
"toolbar-spritesheet-user",
"editor-toolbar-spritesheet-key",
&base_url,
);
init_local_game_project_at(&root, "project-toolbar-spritesheet", "未命名游戏原型")
.expect("project init");
fs::create_dir_all(&config_dir).expect("runtime config dir");
fs::write(
config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME),
serde_json::json!({
"editorApi": {
"baseUrl": base_url,
"apiKey": "editor-toolbar-spritesheet-key"
}
})
.to_string(),
)
.expect("write runtime config");
let _guard = use_test_runtime_config_dir(config_dir.clone());
// 透明图集必须有权威规范图:没有已登记的 icon-spec 时必须明确失败,不能静默降级成普通图。
let missing_reference = generate_local_project_asset(
root.to_string_lossy().into_owned(),
"art-spritesheet".to_string(),
"原创收集玩法素材图集".to_string(),
None,
None,
None,
None,
)
.await
.expect_err("art-spritesheet requires a registered icon-spec");
assert!(
missing_reference.contains("assets/art-spec.png"),
"{missing_reference}"
);
register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec");
bind_canvas_visual_asset_fixture_to_current_editor(
&root,
"assets/art-spec.png",
"canvas-project-1",
);
let asset = generate_local_project_asset(
root.to_string_lossy().into_owned(),
"art-spritesheet".to_string(),
"原创收集玩法素材图集".to_string(),
Some("1:1".to_string()),
Some("1K".to_string()),
Some("游戏首版图集".to_string()),
None,
)
.await
.expect("toolbar spritesheet generation");
assert!(asset.local_path.starts_with("assets/canvas-generated/"));
let manifest: Value =
serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap())
.expect("manifest json");
let entry = manifest["assets"]
.as_array()
.expect("manifest assets")
.iter()
.find(|entry| entry["id"].as_str() == Some(asset.id.as_str()))
.expect("registered toolbar spritesheet asset");
assert_eq!(entry["kind"], "art-spritesheet");
assert_eq!(
entry["source"]["generationRoute"],
"/api/external/v1/editor/icon-spritesheets/generations"
);
assert_eq!(entry["source"]["generationKind"], "icon-spritesheet");
assert_eq!(
entry["source"]["referenceResourceIds"],
serde_json::json!(["resource-icon-spec"])
);
fs::remove_dir_all(root).ok();
fs::remove_dir_all(config_dir).ok();
}
#[tokio::test]
async fn platform_art_generation_step_falls_back_without_leaking_editor_key() {
let root = unique_project_path();
@@ -0,0 +1,210 @@
import { Sparkles, X } from 'lucide-react';
import { type FormEvent, useState } from 'react';
import { PlatformActionButton } from '../../../../../packages/shared/src/components/PlatformActionButton';
import { PlatformSegmentedTabs } from '../../../../../packages/shared/src/components/PlatformSegmentedTabs';
import { PlatformTextField } from '../../../../../packages/shared/src/components/PlatformTextField';
import { resolveEditorImageSizeLabel } from '../../../../../src/components/image-editor/ImageCanvasGenerationModel';
import { ThemedModal } from '../../components/modal/ThemedModal';
import { resourceEditPromptMaxLength } from '../../view/project-development/resourceEditModel';
import {
RESOURCE_CANVAS_ASSET_ASPECT_RATIOS,
RESOURCE_CANVAS_ASSET_IMAGE_SIZES,
type ResourceCanvasAssetToolAction,
} from './resourceCanvasBottomToolbarModel';
import { ResourcePromptPolishSlot } from './ResourcePromptPolishSlot';
export type ResourceCanvasAssetGenerationSubmitInput = {
kind: ResourceCanvasAssetToolAction['assetKind'];
prompt: string;
assetName: string;
aspectRatio: string;
imageSize: string;
};
export type ResourceCanvasAssetGenerationPanelViewProps = {
action: ResourceCanvasAssetToolAction;
onSubmit: (input: ResourceCanvasAssetGenerationSubmitInput) => Promise<void>;
onClose: () => void;
};
function assetGenerationErrorMessage(error: unknown) {
if (typeof error === 'string' && error.trim()) return error;
if (error instanceof Error && error.message) return error.message;
return '生成素材失败';
}
/**
* 栏目画布底部工具栏的图片类生成浮层(生成图片 / 生成规范 / 生成角色形象 / 生成图标素材 /
* 生成 UI 设计图共用)。
*
* 形态是独立弹层(`ThemedModal`,与既有「生成素材」面板同一套宿主 chrome),不在任何现有
* 面板下面追加内容;提交链路与结果定位由宿主 `index.tsx` 负责,面板只持有草稿与失败状态。
*
* 比例 / 尺寸选项来自网页端美术画布的纯模型(`ImageCanvasGenerationModel.ts`)经本地 IPC
* 白名单收窄后的子集:网页端面板会渲染 `4:3`,而本地通道明确拒绝它,照搬就是一个点了必
* 失败的选项。模型档位不由本面板提供——本地 IPC 没有 `model` 入参,渲染一个改不了请求的
* 模型选择器就是假控件。
*/
export function ResourceCanvasAssetGenerationPanelView({
action,
onSubmit,
onClose,
}: ResourceCanvasAssetGenerationPanelViewProps) {
const [prompt, setPrompt] = useState('');
const [assetName, setAssetName] = useState(action.assetName);
const [aspectRatio, setAspectRatio] = useState(action.aspectRatio);
const [imageSize, setImageSize] = useState(action.imageSize);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// 提示词上限复用资源编辑模型的同一份口径:图片类入口默认 32000,与 Rust
// `LOCAL_PROJECT_ASSET_MAX_PROMPT_CHARS` 一致,不在面板里另抄常量。
const promptMaxLength = resourceEditPromptMaxLength('image-reference');
const canSubmit =
!submitting && prompt.trim().length > 0 && assetName.trim().length > 0;
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const normalizedPrompt = prompt.trim();
const normalizedAssetName = assetName.trim();
if (!normalizedPrompt || !normalizedAssetName || submitting) {
return;
}
setSubmitting(true);
setError(null);
try {
await onSubmit({
kind: action.assetKind,
prompt: normalizedPrompt,
assetName: normalizedAssetName,
aspectRatio,
imageSize,
});
} catch (submitError) {
// 成功路径由宿主卸载面板;失败保留草稿,用户可直接用同一份输入重试。
setError(assetGenerationErrorMessage(submitError));
} finally {
setSubmitting(false);
}
}
return (
<ThemedModal
open
ariaLabel={action.label}
closeOnBackdrop={!submitting}
closeOnEscape={!submitting}
onClose={() => {
if (!submitting) {
onClose();
}
}}
panelClassName="game-approval-dialog game-resource-generation-dialog"
>
<header>
<div>
<h2>{action.label}</h2>
</div>
<button
type="button"
aria-label={`关闭${action.label}`}
disabled={submitting}
onClick={onClose}
>
<X size={16} aria-hidden="true" />
</button>
</header>
<form className="game-resource-generation-form" onSubmit={submit}>
<label>
<span></span>
<PlatformTextField
aria-label="素材名称"
maxLength={120}
disabled={submitting}
value={assetName}
onChange={(event) => setAssetName(event.currentTarget.value)}
/>
</label>
<label>
<span></span>
<PlatformTextField
variant="textarea"
aria-label="生成提示词"
rows={6}
autoFocus
disabled={submitting}
maxLength={promptMaxLength}
placeholder={action.promptPlaceholder}
value={prompt}
onChange={(event) => setPrompt(event.currentTarget.value)}
/>
</label>
{action.adjustableDimensions ? (
<div className="resource-canvas-asset-generation-dimensions">
<PlatformSegmentedTabs
items={RESOURCE_CANVAS_ASSET_ASPECT_RATIOS.map((ratio) => ({
id: ratio,
label: ratio,
ariaLabel: `${action.label}比例 ${ratio}`,
}))}
activeId={aspectRatio}
ariaLabel={`${action.label}比例`}
columns="threeToSix"
gap="sm"
size="compact"
disabled={submitting}
onChange={setAspectRatio}
/>
<PlatformSegmentedTabs
items={RESOURCE_CANVAS_ASSET_IMAGE_SIZES.map((size) => ({
id: size,
label: size,
ariaLabel: `${action.label}尺寸 ${size}`,
}))}
activeId={imageSize}
ariaLabel={`${action.label}尺寸`}
columns="three"
gap="sm"
size="compact"
disabled={submitting}
onChange={setImageSize}
/>
</div>
) : (
<span
className="resource-canvas-asset-generation-fixed-spec"
aria-label={`${action.label}固定规格 ${aspectRatio} ${imageSize}`}
>
{resolveEditorImageSizeLabel({ aspectRatio, imageSize })}
</span>
)}
<ResourcePromptPolishSlot
subject={`素材生成提示词(${action.label}`}
editKind="image-reference"
prompt={prompt}
disabled={submitting}
applyPrompt={setPrompt}
/>
{error ? (
<p className="game-resource-generation-error" role="alert">
{error}
</p>
) : null}
<div className="game-resource-generation-actions">
<PlatformActionButton
type="button"
tone="secondary"
disabled={submitting}
onClick={onClose}
>
</PlatformActionButton>
<PlatformActionButton type="submit" disabled={!canSubmit}>
<Sparkles size={15} aria-hidden="true" />
{submitting ? '生成中…' : action.label}
</PlatformActionButton>
</div>
</form>
</ThemedModal>
);
}
@@ -0,0 +1,259 @@
import {
CanvasChromeButton,
CanvasToolbar,
} from '@genarrative/image-canvas-react';
import {
AppWindow,
ClipboardList,
Image as ImageIcon,
LayoutGrid,
Music,
Upload,
UserRound,
Volume2,
} from 'lucide-react';
import {
type ChangeEvent,
type ReactNode,
useMemo,
useRef,
useState,
} from 'react';
import type { ProjectResourceCanvasCategory } from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { useImageCanvasFloatingOptionDismiss } from '../../../../../src/components/image-editor/useImageCanvasFloatingOptionDismiss';
import type {
ResourceCanvasBottomTool,
ResourceCanvasBottomToolAction,
ResourceCanvasBottomToolActionId,
} from './resourceCanvasBottomToolbarModel';
const RESOURCE_CANVAS_BOTTOM_TOOL_ICONS: Record<
ResourceCanvasBottomToolActionId,
ReactNode
> = {
'generate-image': <ImageIcon className="h-4 w-4" aria-hidden="true" />,
'generate-spec-icon': <LayoutGrid className="h-4 w-4" aria-hidden="true" />,
'generate-spec-character': (
<UserRound className="h-4 w-4" aria-hidden="true" />
),
'generate-spec-custom': (
<ClipboardList className="h-4 w-4" aria-hidden="true" />
),
'generate-character': <UserRound className="h-4 w-4" aria-hidden="true" />,
'generate-icon-spritesheet': (
<LayoutGrid className="h-4 w-4" aria-hidden="true" />
),
'generate-ui-prototype': <AppWindow className="h-4 w-4" aria-hidden="true" />,
'generate-sound-effect': <Volume2 className="h-4 w-4" aria-hidden="true" />,
'generate-background-music': <Music className="h-4 w-4" aria-hidden="true" />,
'upload-asset': <Upload className="h-4 w-4" aria-hidden="true" />,
};
export type ResourceCanvasBottomToolbarViewProps = {
category: ProjectResourceCanvasCategory;
tools: readonly ResourceCanvasBottomTool[];
/** 不可用原因;返回 null 表示该动作可用。永远不落成原生 disabled。 */
resolveBlockedReason: (
action: ResourceCanvasBottomToolAction,
) => string | null;
/** 上传入口的不可用原因(客户端桥 / 项目未就绪与生成入口同一口径)。 */
uploadBlockedReason: string | null;
onSelectAction: (action: ResourceCanvasBottomToolAction) => void;
onUploadFiles: (files: readonly File[]) => void;
uploading?: boolean;
};
/**
* 栏目画布底部工具栏。
*
* 只画外壳、分流与「不可用原因」:动作语义全在 `resourceCanvasBottomToolbarModel`
* 生成 / 上传的副作用全在宿主。面板一律是独立浮层(由宿主挂载),这里不开任何内嵌内容。
*
* 位置选在画布左下角:右下角是既有的缩放 / 撤销 Dock`right:14px;bottom:14px`),
* 左下角是画布上唯一一块两者都不占的稳定空位,1280×720 横屏下不与 Dock、也不与
* 栏目页的画布内容抢位置。
*/
export function ResourceCanvasBottomToolbarView({
category,
tools,
resolveBlockedReason,
uploadBlockedReason,
onSelectAction,
onUploadFiles,
uploading = false,
}: ResourceCanvasBottomToolbarViewProps) {
const [openMenuToolId, setOpenMenuToolId] = useState<string | null>(null);
const [blockedNotice, setBlockedNotice] = useState<string | null>(null);
const menuRef = useRef<HTMLSpanElement | null>(null);
const uploadInputRef = useRef<HTMLInputElement | null>(null);
const menuTool = useMemo(
() => tools.find((tool) => tool.id === openMenuToolId) ?? null,
[openMenuToolId, tools],
);
useImageCanvasFloatingOptionDismiss({
isOpen: menuTool !== null,
boundaryRefs: [menuRef],
onDismiss: () => setOpenMenuToolId(null),
});
function runAction(action: ResourceCanvasBottomToolAction) {
setOpenMenuToolId(null);
const blockedReason = resolveBlockedReason(action);
if (blockedReason) {
setBlockedNotice(blockedReason);
return;
}
setBlockedNotice(null);
onSelectAction(action);
}
function openUploadPicker() {
if (uploadBlockedReason) {
setBlockedNotice(uploadBlockedReason);
return;
}
setBlockedNotice(null);
uploadInputRef.current?.click();
}
function actionButton(action: ResourceCanvasBottomToolAction) {
const blockedReason = resolveBlockedReason(action);
return (
<CanvasChromeButton
key={action.id}
label={action.label}
icon={RESOURCE_CANVAS_BOTTOM_TOOL_ICONS[action.id]}
className={
blockedReason
? 'game-resource-bottom-toolbar-action is-blocked'
: 'game-resource-bottom-toolbar-action'
}
aria-disabled={blockedReason ? true : undefined}
onClick={() => runAction(action)}
>
{action.label}
</CanvasChromeButton>
);
}
function uploadControl() {
return (
<span key="upload" className="game-resource-bottom-toolbar-upload">
<CanvasChromeButton
label="上传"
icon={RESOURCE_CANVAS_BOTTOM_TOOL_ICONS['upload-asset']}
className={
uploadBlockedReason
? 'game-resource-bottom-toolbar-action is-blocked'
: 'game-resource-bottom-toolbar-action'
}
aria-disabled={uploadBlockedReason ? true : undefined}
aria-busy={uploading}
onClick={openUploadPicker}
>
{uploading ? '上传中…' : '上传'}
</CanvasChromeButton>
<input
ref={uploadInputRef}
type="file"
multiple
aria-label="上传素材文件"
className="game-resource-bottom-toolbar-upload-input"
onChange={(event: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.currentTarget.files ?? []);
// 同一个文件连续选两次也要触发:清空 value,避免 change 被浏览器吞掉。
event.currentTarget.value = '';
if (files.length > 0) {
onUploadFiles(files);
}
}}
/>
</span>
);
}
return (
<div
className="game-resource-bottom-toolbar"
data-resource-bottom-toolbar={category}
>
<CanvasToolbar
label="栏目生成工具"
surface="floating"
className="game-resource-bottom-toolbar-strip"
>
{tools.map((tool) => {
if (tool.id === 'upload') return uploadControl();
if (tool.menu) {
const menuItems = tool.menu;
return (
<span
key={tool.id}
ref={menuRef}
className="game-resource-bottom-toolbar-menu"
>
<CanvasChromeButton
label={tool.label}
icon={
<ClipboardList className="h-4 w-4" aria-hidden="true" />
}
className="game-resource-bottom-toolbar-action"
expanded={openMenuToolId === tool.id}
aria-haspopup="menu"
onClick={() =>
setOpenMenuToolId((current) =>
current === tool.id ? null : tool.id,
)
}
>
{tool.label}
</CanvasChromeButton>
{openMenuToolId === tool.id ? (
<span
role="menu"
aria-label={tool.label}
className="game-resource-bottom-toolbar-menu-items"
>
{menuItems.map((action) => {
const blockedReason = resolveBlockedReason(action);
return (
<button
key={action.id}
type="button"
role="menuitem"
className={`game-resource-bottom-toolbar-menu-item${
blockedReason ? ' is-blocked' : ''
}`}
aria-disabled={blockedReason ? true : undefined}
onClick={() => runAction(action)}
>
{RESOURCE_CANVAS_BOTTOM_TOOL_ICONS[action.id]}
<span>{action.label}</span>
</button>
);
})}
</span>
) : null}
</span>
);
}
return tool.action ? actionButton(tool.action) : null;
})}
</CanvasToolbar>
{blockedNotice ? (
<p
className="game-resource-bottom-toolbar-notice"
role="alert"
data-resource-bottom-toolbar-notice={category}
>
<span>{blockedNotice}</span>
<button type="button" onClick={() => setBlockedNotice(null)}>
</button>
</p>
) : null}
</div>
);
}
@@ -27,14 +27,24 @@ export type ResourceCanvasGenerationSubmitInput = {
};
export type ResourceCanvasGenerationPanelViewProps = {
/**
* 本入口允许生成的素材类型。
*
* 音频画布的「生成背景音乐 / 生成音效」已由栏目画布底部工具栏承载,既有「生成素材」
* 入口因此只保留视频;工具栏入口各自只放行自己那一种。只放行一种时不再渲染类型选择器
* (一个只有一个选项的分段控件是噪音),标题与提交文案直接跟该类走。
*/
kinds?: readonly ResourceCanvasGenerationKind[];
initialKind?: ResourceCanvasGenerationKind;
onSubmit: (input: ResourceCanvasGenerationSubmitInput) => Promise<void>;
onClose: () => void;
};
const RESOURCE_GENERATION_KIND_ITEMS = RESOURCE_CANVAS_GENERATION_OPTIONS.map(
(option) => ({ id: option.kind, label: option.label }),
);
const RESOURCE_GENERATION_ALL_KIND_ITEMS =
RESOURCE_CANVAS_GENERATION_OPTIONS.map((option) => ({
id: option.kind,
label: option.label,
}));
function resourceGenerationErrorMessage(error: unknown) {
if (typeof error === 'string' && error.trim()) return error;
@@ -50,12 +60,25 @@ function resourceGenerationErrorMessage(error: unknown) {
* 面板只持有草稿、类型选择与失败重试状态。
*/
export function ResourceCanvasGenerationPanelView({
initialKind = RESOURCE_CANVAS_GENERATION_DEFAULT_KIND,
kinds = RESOURCE_CANVAS_GENERATION_OPTIONS.map((option) => option.kind),
initialKind,
onSubmit,
onClose,
}: ResourceCanvasGenerationPanelViewProps) {
const [kind, setKind] = useState<ResourceCanvasGenerationKind>(initialKind);
const allowedOptions = RESOURCE_CANVAS_GENERATION_OPTIONS.filter((option) =>
kinds.includes(option.kind),
);
const allowedKindItems = RESOURCE_GENERATION_ALL_KIND_ITEMS.filter((item) =>
kinds.includes(item.id),
);
const fallbackKind =
allowedOptions[0]?.kind ?? RESOURCE_CANVAS_GENERATION_DEFAULT_KIND;
const [kind, setKind] = useState<ResourceCanvasGenerationKind>(
initialKind && kinds.includes(initialKind) ? initialKind : fallbackKind,
);
const option = resourceCanvasGenerationOption(kind);
const panelTitle =
allowedOptions.length === 1 ? option.generationLabel : '生成素材';
const [prompt, setPrompt] = useState('');
const [assetName, setAssetName] = useState(option.assetName);
const [attempted, setAttempted] = useState(false);
@@ -101,7 +124,7 @@ export function ResourceCanvasGenerationPanelView({
return (
<ThemedModal
open
ariaLabel="生成素材"
ariaLabel={panelTitle}
closeOnBackdrop={!submitting}
closeOnEscape={!submitting}
onClose={() => {
@@ -113,30 +136,32 @@ export function ResourceCanvasGenerationPanelView({
>
<header>
<div>
<h2></h2>
<h2>{panelTitle}</h2>
</div>
<button
type="button"
aria-label="关闭生成素材"
aria-label={`关闭${panelTitle}`}
disabled={submitting}
onClick={onClose}
>
<X size={16} aria-hidden="true" />
</button>
</header>
<PlatformSegmentedTabs
items={RESOURCE_GENERATION_KIND_ITEMS}
activeId={kind}
ariaLabel="生成素材类型"
columns="three"
gap="sm"
size="compact"
disabled={inputLocked}
onChange={(nextKind) => {
setKind(nextKind);
setAssetName(resourceCanvasGenerationOption(nextKind).assetName);
}}
/>
{allowedKindItems.length > 1 ? (
<PlatformSegmentedTabs
items={allowedKindItems}
activeId={kind}
ariaLabel="生成素材类型"
columns="three"
gap="sm"
size="compact"
disabled={inputLocked}
onChange={(nextKind) => {
setKind(nextKind);
setAssetName(resourceCanvasGenerationOption(nextKind).assetName);
}}
/>
) : null}
<form className="game-resource-generation-form" onSubmit={submit}>
<label>
<span></span>
@@ -0,0 +1,484 @@
import type {
GameCreationAppAssetManifestEntry,
ProjectResourceCanvasCategory,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import {
EDITOR_IMAGE_DIMENSION_OPTIONS,
IMAGE_MODEL_NANOBANANA2,
} from '../../../../../src/components/image-editor/ImageCanvasGenerationModel';
import { isResourceBookAllTarget } from '../../view/project-development/resourceBookModel';
import type { ResourceCanvasGenerationKind } from './resourceCanvasGenerationModel';
/**
* 栏目画布底部工具栏的工具矩阵。
*
* 事实源是用户 2026-09-13 拍板的矩阵:功能画布=资源栏目,按 `category` 分流。未命中
* 表格的栏目(文档 / 待归类 / 项目版本 / 「所有资源」页 / 资源总览)一律返回空数组 ——
* 工具栏不渲染,而不是渲染一个空壳。
*
* 明确不做:生成视频(视频能力保留在既有「生成素材」浮层入口)、宣发素材、生成游戏场景,
* 以及画布级「选择工具 / 抓手工具」(AGC 现无画布级工具模式)。
*/
/**
* `generate_local_project_asset` 放行的无源生成类型。
*
* 与 Rust `PLATFORM_ART_ASSET_GENERATION_KINDS` 一一对应(`publication-material` 属宣发
* 素材,本轮不做,因此不在这里)。`spec` / `icon-spec` 都会在 Rust 侧收口到 `icon-spec`
* 但两个入口携带的 `kind` 字符串不同,账本与 manifest 的 `source.generationKind` 也不同,
* 所以这里保留原值,不做前端归并。
*/
export type ResourceCanvasGeneratedAssetKind =
| 'image'
| 'character'
| 'spec'
| 'icon-spec'
| 'ui-prototype'
| 'art-spritesheet';
/** 权威规范图的落点;Rust `AGENT_RUNTIME_ART_SPEC_PATH`。 */
export const RESOURCE_CANVAS_ICON_SPEC_LOCAL_PATH = 'assets/art-spec.png';
/** 工具栏入口的路由:图片类生成 / 既有音频生成 / 上传。 */
export type ResourceCanvasBottomToolRoute = 'asset' | 'audio' | 'upload';
export type ResourceCanvasBottomToolActionId =
| 'generate-image'
| 'generate-spec-icon'
| 'generate-spec-character'
| 'generate-spec-custom'
| 'generate-character'
| 'generate-icon-spritesheet'
| 'generate-ui-prototype'
| 'generate-sound-effect'
| 'generate-background-music'
| 'upload-asset';
type ResourceCanvasBottomToolActionBase = {
id: ResourceCanvasBottomToolActionId;
/** 一次动作的完整文案,既是按钮文案也是面板标题与提交文案。 */
label: string;
/** 新建素材的默认名称。 */
assetName: string;
promptPlaceholder: string;
/**
* 面板是否暴露比例 / 尺寸选择。
*
* 规范类是权威口径资产(比例由 Rust 提示词与 artSpec 固定),沿用网页端「生成规范」
* 的既有形态:固定档只读展示,不给点了不生效的假控件。
*/
adjustableDimensions: boolean;
aspectRatio: string;
imageSize: string;
};
/** 图片类生成入口:走 `generate_local_project_asset`。 */
export type ResourceCanvasAssetToolAction =
ResourceCanvasBottomToolActionBase & {
route: 'asset';
assetKind: ResourceCanvasGeneratedAssetKind;
audioKind: null;
/** 生成前置:项目里必须已有登记并绑定当前账号的权威规范图。 */
requiresIconSpecReference: boolean;
/**
* 产出物本身就是权威规范图。
*
* 缺前置时这条入口按 `RESOURCE_CANVAS_ICON_SPEC_LOCAL_PATH` 落盘,让「生成图标素材 /
* UI 设计图」的前置条件真的能被工具栏自身满足;否则这两条入口会永久不可用。
*/
writesIconSpecReference: boolean;
};
/** 音频入口:复用既有 `derive_local_project_resource` 的无源生成链路。 */
export type ResourceCanvasAudioToolAction =
ResourceCanvasBottomToolActionBase & {
route: 'audio';
assetKind: null;
audioKind: ResourceCanvasGenerationKind;
};
/** 上传入口:复用既有 `upload_local_asset`。 */
export type ResourceCanvasUploadToolAction =
ResourceCanvasBottomToolActionBase & {
route: 'upload';
assetKind: null;
audioKind: null;
};
export type ResourceCanvasBottomToolAction =
| ResourceCanvasAssetToolAction
| ResourceCanvasAudioToolAction
| ResourceCanvasUploadToolAction;
export type ResourceCanvasBottomTool = {
id: string;
label: string;
/** 无二级菜单:点一下直接开面板。 */
action?: ResourceCanvasBottomToolAction;
/** 二级菜单:先展开菜单,选中项再开面板。 */
menu?: readonly ResourceCanvasBottomToolAction[];
};
/**
* 与 Rust `LOCAL_PROJECT_ASSET_ASPECT_RATIOS` / `LOCAL_PROJECT_ASSET_IMAGE_SIZES` 同口径的
* IPC 白名单。
*
* 网页端的图片比例含 `4:3`,本地 IPC 明确拒绝(`图片比例不受支持:4:3`);这里不是抄一份
* 选项表,而是把**主站纯模型**给出的比例 / 尺寸裁到本地通道真正接受的子集。
*/
const RESOURCE_CANVAS_ASSET_ASPECT_RATIO_WHITELIST: readonly string[] = [
'1:1',
'2:3',
'3:2',
'9:16',
'16:9',
];
const RESOURCE_CANVAS_ASSET_IMAGE_SIZE_WHITELIST: readonly string[] = [
'0.5K',
'1K',
'2K',
];
const MAIN_STATION_IMAGE_DIMENSIONS =
EDITOR_IMAGE_DIMENSION_OPTIONS[IMAGE_MODEL_NANOBANANA2];
/** 面板可呈现的比例选项:主站纯模型 ∩ 本地 IPC 白名单,顺序沿用主站。 */
export const RESOURCE_CANVAS_ASSET_ASPECT_RATIOS: readonly string[] =
MAIN_STATION_IMAGE_DIMENSIONS.aspectRatios.filter((ratio) =>
RESOURCE_CANVAS_ASSET_ASPECT_RATIO_WHITELIST.includes(ratio),
);
/** 面板可呈现的尺寸选项:主站纯模型 ∩ 本地 IPC 白名单,顺序沿用主站。 */
export const RESOURCE_CANVAS_ASSET_IMAGE_SIZES: readonly string[] =
MAIN_STATION_IMAGE_DIMENSIONS.imageSizes.filter((size) =>
RESOURCE_CANVAS_ASSET_IMAGE_SIZE_WHITELIST.includes(size),
);
const RESOURCE_CANVAS_ASSET_DEFAULT_ASPECT_RATIO =
RESOURCE_CANVAS_ASSET_ASPECT_RATIOS[0] ?? '1:1';
const RESOURCE_CANVAS_ASSET_DEFAULT_IMAGE_SIZE =
RESOURCE_CANVAS_ASSET_IMAGE_SIZES.find((size) => size === '1K') ??
RESOURCE_CANVAS_ASSET_IMAGE_SIZES[0] ??
'1K';
const generateImageAction: ResourceCanvasAssetToolAction = {
id: 'generate-image',
route: 'asset',
label: '生成图片',
assetKind: 'image',
audioKind: null,
assetName: 'AI 生成图片',
promptPlaceholder: '今天想生成什么画面?',
adjustableDimensions: true,
aspectRatio: RESOURCE_CANVAS_ASSET_DEFAULT_ASPECT_RATIO,
imageSize: RESOURCE_CANVAS_ASSET_DEFAULT_IMAGE_SIZE,
requiresIconSpecReference: false,
writesIconSpecReference: false,
};
const generateSpecIconAction: ResourceCanvasAssetToolAction = {
id: 'generate-spec-icon',
route: 'asset',
label: '图标规范',
assetKind: 'icon-spec',
audioKind: null,
assetName: '图标规范',
promptPlaceholder: '描述这套图标规范要覆盖的玩法、界面与美术风格',
adjustableDimensions: false,
aspectRatio: RESOURCE_CANVAS_ASSET_DEFAULT_ASPECT_RATIO,
imageSize: RESOURCE_CANVAS_ASSET_DEFAULT_IMAGE_SIZE,
requiresIconSpecReference: false,
writesIconSpecReference: true,
};
const generateSpecCharacterAction: ResourceCanvasAssetToolAction = {
id: 'generate-spec-character',
route: 'asset',
label: '角色规范',
assetKind: 'spec',
audioKind: null,
assetName: '角色规范',
promptPlaceholder: '描述角色的外形、服装、动作与画风要求',
adjustableDimensions: false,
aspectRatio: RESOURCE_CANVAS_ASSET_DEFAULT_ASPECT_RATIO,
imageSize: RESOURCE_CANVAS_ASSET_DEFAULT_IMAGE_SIZE,
requiresIconSpecReference: false,
writesIconSpecReference: false,
};
const generateSpecCustomAction: ResourceCanvasAssetToolAction = {
id: 'generate-spec-custom',
route: 'asset',
label: '自定义规范',
assetKind: 'spec',
audioKind: null,
assetName: '自定义规范',
promptPlaceholder: '描述这张规范图要约束的视觉范围与要求',
adjustableDimensions: false,
aspectRatio: RESOURCE_CANVAS_ASSET_DEFAULT_ASPECT_RATIO,
imageSize: RESOURCE_CANVAS_ASSET_DEFAULT_IMAGE_SIZE,
requiresIconSpecReference: false,
writesIconSpecReference: false,
};
const generateCharacterAction: ResourceCanvasAssetToolAction = {
id: 'generate-character',
route: 'asset',
label: '生成角色形象',
assetKind: 'character',
audioKind: null,
assetName: 'AI 生成角色',
promptPlaceholder: '你希望角色如何设计?',
adjustableDimensions: true,
aspectRatio: RESOURCE_CANVAS_ASSET_DEFAULT_ASPECT_RATIO,
imageSize: RESOURCE_CANVAS_ASSET_DEFAULT_IMAGE_SIZE,
requiresIconSpecReference: false,
writesIconSpecReference: false,
};
const generateIconSpritesheetAction: ResourceCanvasAssetToolAction = {
id: 'generate-icon-spritesheet',
route: 'asset',
label: '生成图标素材',
assetKind: 'art-spritesheet',
audioKind: null,
assetName: 'AI 生成图标素材',
promptPlaceholder: '描述需要哪些图标素材,例如:各种敌人头像',
adjustableDimensions: true,
aspectRatio: RESOURCE_CANVAS_ASSET_DEFAULT_ASPECT_RATIO,
imageSize: RESOURCE_CANVAS_ASSET_DEFAULT_IMAGE_SIZE,
requiresIconSpecReference: true,
writesIconSpecReference: false,
};
const generateUiPrototypeAction: ResourceCanvasAssetToolAction = {
id: 'generate-ui-prototype',
route: 'asset',
label: '生成 UI 设计图',
assetKind: 'ui-prototype',
audioKind: null,
assetName: 'AI 生成 UI 设计图',
promptPlaceholder: '描述这张界面要承载的玩法与操作',
adjustableDimensions: true,
// 网页端 UI 设计图面板的默认档就是 16:9 · 1K;这里沿用同一默认值。
aspectRatio: '16:9',
imageSize: RESOURCE_CANVAS_ASSET_DEFAULT_IMAGE_SIZE,
requiresIconSpecReference: true,
writesIconSpecReference: false,
};
const GENERATE_BACKGROUND_MUSIC_ACTION: ResourceCanvasAudioToolAction = {
id: 'generate-background-music',
route: 'audio',
label: '生成背景音乐',
assetKind: null,
audioKind: 'background-music',
assetName: '新背景音乐',
promptPlaceholder: '描述想生成的背景音乐风格、情绪与乐器',
adjustableDimensions: false,
aspectRatio: RESOURCE_CANVAS_ASSET_DEFAULT_ASPECT_RATIO,
imageSize: RESOURCE_CANVAS_ASSET_DEFAULT_IMAGE_SIZE,
};
const GENERATE_SOUND_EFFECT_ACTION: ResourceCanvasAudioToolAction = {
id: 'generate-sound-effect',
route: 'audio',
label: '生成音效',
assetKind: null,
audioKind: 'sound-effect',
assetName: '新音效',
promptPlaceholder: '描述想生成的音效,例如:木门缓慢推开时的吱呀声',
adjustableDimensions: false,
aspectRatio: RESOURCE_CANVAS_ASSET_DEFAULT_ASPECT_RATIO,
imageSize: RESOURCE_CANVAS_ASSET_DEFAULT_IMAGE_SIZE,
};
export const RESOURCE_CANVAS_UPLOAD_TOOL_ACTION: ResourceCanvasUploadToolAction =
{
id: 'upload-asset',
route: 'upload',
label: '上传',
assetKind: null,
audioKind: null,
assetName: '',
promptPlaceholder: '',
adjustableDimensions: false,
aspectRatio: RESOURCE_CANVAS_ASSET_DEFAULT_ASPECT_RATIO,
imageSize: RESOURCE_CANVAS_ASSET_DEFAULT_IMAGE_SIZE,
};
const GENERATE_SPEC_UI_INTERACTION_TOOL: ResourceCanvasBottomTool = {
id: 'generate-spec',
label: '生成规范',
menu: [generateSpecIconAction, generateSpecCustomAction],
};
const GENERATE_SPEC_CHARACTER_TOOL: ResourceCanvasBottomTool = {
id: 'generate-spec',
label: '生成规范',
menu: [generateSpecCharacterAction, generateSpecCustomAction],
};
const GENERATE_SPEC_SCENE_TOOL: ResourceCanvasBottomTool = {
id: 'generate-spec',
label: '生成规范',
menu: [generateSpecCustomAction],
};
const UPLOAD_TOOL: ResourceCanvasBottomTool = {
id: 'upload',
label: '上传',
action: RESOURCE_CANVAS_UPLOAD_TOOL_ACTION,
};
const RESOURCE_CANVAS_BOTTOM_TOOLS_BY_CATEGORY: Partial<
Record<ProjectResourceCanvasCategory, readonly ResourceCanvasBottomTool[]>
> = {
'ui-interaction': [
{ id: 'generate-image', label: '生成图片', action: generateImageAction },
GENERATE_SPEC_UI_INTERACTION_TOOL,
{
id: 'generate-icon-spritesheet',
label: '生成图标素材',
action: generateIconSpritesheetAction,
},
{
id: 'generate-ui-prototype',
label: '生成 UI 设计图',
action: generateUiPrototypeAction,
},
UPLOAD_TOOL,
],
character: [
{ id: 'generate-image', label: '生成图片', action: generateImageAction },
GENERATE_SPEC_CHARACTER_TOOL,
{
id: 'generate-character',
label: '生成角色形象',
action: generateCharacterAction,
},
UPLOAD_TOOL,
],
scene: [
{ id: 'generate-image', label: '生成图片', action: generateImageAction },
GENERATE_SPEC_SCENE_TOOL,
UPLOAD_TOOL,
],
audio: [
{
id: 'generate-background-music',
label: '生成背景音乐',
action: GENERATE_BACKGROUND_MUSIC_ACTION,
},
{
id: 'generate-sound-effect',
label: '生成音效',
action: GENERATE_SOUND_EFFECT_ACTION,
},
UPLOAD_TOOL,
],
};
/**
* 栏目 → 有序工具项。
*
* 未命中表格的栏目(含渲染层特殊值 `'all'`=「所有资源」页、`'version'`、`'document'`、
* `'unclassified'`)以及 `null` 一律返回空数组:工具栏整体不渲染。
*/
export function resolveResourceCanvasBottomTools(
category: ProjectResourceCanvasCategory | 'all' | null | undefined,
): readonly ResourceCanvasBottomTool[] {
if (category === null || category === undefined) return [];
if (isResourceBookAllTarget(category)) return [];
return RESOURCE_CANVAS_BOTTOM_TOOLS_BY_CATEGORY[category] ?? [];
}
/** 把一个工具的二级菜单摊平成动作列表;无二级菜单时就是它自己那一个动作。 */
export function resourceCanvasBottomToolActions(
tool: ResourceCanvasBottomTool,
): readonly ResourceCanvasBottomToolAction[] {
if (tool.menu && tool.menu.length > 0) return tool.menu;
return tool.action ? [tool.action] : [];
}
/**
* 项目里是否已有权威规范图。
*
* 判据与 Rust `canonical_art_spec_reference_at` 逐字对齐:`localPath` 必须是
* `assets/art-spec.png`、`kind` 必须是 `icon-spec`、媒体类型必须是图片、来源必须是画布。
* 前端放宽任何一条,都会让「入口可用但提交必失败」重新出现。
*/
export function projectHasIconSpecReference(
assets: readonly Pick<
GameCreationAppAssetManifestEntry,
'kind' | 'localPath' | 'mediaType' | 'source'
>[],
): boolean {
return assets.some(
(asset) =>
asset.localPath === RESOURCE_CANVAS_ICON_SPEC_LOCAL_PATH &&
asset.kind === 'icon-spec' &&
asset.mediaType.startsWith('image/') &&
asset.source?.kind === 'canvas',
);
}
export type ResourceCanvasBottomToolAvailability = {
available: boolean;
/** 不可用的原因;可用时为 null。永远给可点击的原因说明,不用原生 disabled。 */
blockedReason: string | null;
};
/**
* 图片类入口的输出路径策略。
*
* 「图标规范」是权威规范图本身:项目里还没有登记 `assets/art-spec.png` 时,这条入口必须
* 写进那个精确落点,否则「生成图标素材 / UI 设计图」的前置条件永远无法由工具栏满足。
* 已有权威规范图时不再传 `outputPath` —— Rust 侧 `replace_existing` 固定为 false,指向
* 已存在文件的写入会被硬拒绝(`outputPath 已存在,禁止静默覆盖`),所以这条入口改为生成
* 一张新的普通图标规范资产。其它入口一律不指定落点,沿用生成通道的默认目录。
*/
export function resourceCanvasAssetGenerationOutputPath(
action: ResourceCanvasAssetToolAction,
hasIconSpecReference: boolean,
): string | null {
if (!action.writesIconSpecReference || hasIconSpecReference) {
return null;
}
return RESOURCE_CANVAS_ICON_SPEC_LOCAL_PATH;
}
/**
* 入口可用性。
*
* 三条判据互不重叠,且都给得出可执行的原因:客户端桥、项目就绪、规范图前置。
*/
export function resolveResourceCanvasBottomToolAvailability(
action: ResourceCanvasBottomToolAction,
input: {
hasRuntimeInvoke: boolean;
projectPath: string;
projectId: string;
hasIconSpecReference: boolean;
},
): ResourceCanvasBottomToolAvailability {
if (!input.hasRuntimeInvoke) {
return {
available: false,
blockedReason: '该能力需要在客户端内执行',
};
}
if (!input.projectPath.trim() || !input.projectId.trim()) {
return { available: false, blockedReason: '项目尚未就绪,请稍候重试' };
}
if (
action.route === 'asset' &&
action.requiresIconSpecReference &&
!input.hasIconSpecReference
) {
return {
available: false,
blockedReason:
'需要先完成并登记图标规范(assets/art-spec.png);请先用「生成规范 → 图标规范」生成',
};
}
return { available: true, blockedReason: null };
}
@@ -1083,3 +1083,201 @@
justify-content: flex-end;
gap: 8px;
}
/*
* 「点选替换」提示条:点选态下贴在资源画布顶端居中浮出。
*
* 与右下角缩放控件同一层语义(都在管理区之上,`position: absolute` 锚在
* `.game-resource-book-manager` 上,而不是挂在会随视口缩放 / 平移的场景里),
* 配色沿用工作台提示条那一套(`.game-resource-live-notice`)。
*
* 它是**会话级**提示:会话期间常驻,不跟随某张卡,所以层级高于场景(20)与缩放(40);
* 场景根是 `pointer-events: none`,这里必须显式收回指针事件,「取消」才点得动。
*/
.game-resource-canvas-pick-hint {
position: absolute;
top: 14px;
left: 50%;
z-index: 50;
display: inline-flex;
align-items: center;
gap: 10px;
max-width: min(92vw, 34rem);
padding: 8px 8px 8px 12px;
border: 1px solid #edc7b5;
border-radius: 10px;
background: rgb(255 247 241 / 96%);
box-shadow: 0 6px 18px rgb(112 70 52 / 14%);
color: #8d5b45;
font-size: 11px;
line-height: 1.4;
transform: translateX(-50%);
pointer-events: auto;
}
.game-resource-canvas-pick-hint-text {
flex: 1 1 auto;
min-width: 0;
}
.game-resource-canvas-pick-hint-error {
flex: 0 1 auto;
min-width: 0;
color: #b3261e;
font-weight: 700;
}
.game-resource-canvas-pick-hint button {
flex: 0 0 auto;
border: 1px solid #dc9b7d;
border-radius: 8px;
background: #fff;
color: #9b5537;
font-size: 11px;
cursor: pointer;
}
/* 栏目画布底部工具栏:栏目页(view=child)左下角的画布 chrome。
右下角是既有的缩放 / 撤销 Dock`right:14px; bottom:14px; z-index:40`),左下角是画布上
唯一一块两者都不占的稳定空位:1280×720 横屏下既不压住 Dock,也不与栏目页画布内容抢位。 */
.game-resource-bottom-toolbar {
position: absolute;
z-index: 40;
bottom: 14px;
left: 14px;
max-width: calc(100% - 28px);
}
/* 共享 chrome 的 `.genarrative-image-canvas__toolbar` 是横向滚动条(`overflow-x:auto`),
会把它自己弹出的二级菜单(`bottom:100%+6px`)裁掉。这里改成换行、不裁切:
「生成规范」的二级菜单必须可见,窄屏用换行兜底而不是横向滚动。 */
.game-resource-bottom-toolbar-strip {
max-width: 100%;
overflow: visible;
flex-wrap: wrap;
}
.game-resource-bottom-toolbar-action {
white-space: nowrap;
}
/* 不可用但可点击:给「原因说明」而不是原生 disabled。 */
.game-resource-bottom-toolbar-action.is-blocked {
opacity: 0.55;
cursor: help;
}
.game-resource-bottom-toolbar-menu {
position: relative;
display: inline-flex;
}
.game-resource-bottom-toolbar-menu-items {
position: absolute;
z-index: 1;
bottom: calc(100% + 6px);
left: 0;
display: grid;
min-width: 8.5rem;
gap: 2px;
box-sizing: border-box;
border: 1px solid
var(
--image-canvas-brand-border-soft,
var(--platform-surface-border, rgba(226, 203, 184, 0.82))
);
border-radius: 0.5rem;
background: var(--platform-panel-fill, #ffffff);
padding: 0.28rem;
box-shadow: 0 18px 38px rgba(15, 23, 42, 0.16);
}
.game-resource-bottom-toolbar-menu-item {
display: flex;
align-items: center;
gap: 0.42rem;
border: 0;
border-radius: 0.38rem;
background: transparent;
padding: 0.4rem 0.55rem;
color: var(--platform-text-strong, #334155);
font: inherit;
font-size: 0.78rem;
font-weight: 700;
text-align: left;
white-space: nowrap;
cursor: pointer;
}
.game-resource-bottom-toolbar-menu-item:hover,
.game-resource-bottom-toolbar-menu-item:focus-visible {
background: var(--image-canvas-brand-soft, rgba(234, 204, 179, 0.28));
color: var(--image-canvas-brand-accent-strong, #6f2f21);
}
.game-resource-bottom-toolbar-menu-item.is-blocked {
opacity: 0.55;
cursor: help;
}
.game-resource-bottom-toolbar-upload {
position: relative;
display: inline-flex;
}
.game-resource-bottom-toolbar-upload-input {
display: none;
}
/* 不可用的原因说明:贴着工具栏上沿,不遮挡画布。 */
.game-resource-bottom-toolbar-notice {
position: absolute;
bottom: calc(100% + 8px);
left: 0;
display: flex;
width: max-content;
max-width: min(360px, calc(100vw - 40px));
align-items: center;
gap: 8px;
margin: 0;
box-sizing: border-box;
border: 1px solid #f0c8ad;
border-radius: 10px;
background: #fff8f4;
padding: 8px 10px;
color: #8c4b2c;
font-size: 11px;
line-height: 1.5;
box-shadow: 0 12px 26px rgba(15, 23, 42, 0.12);
}
.game-resource-bottom-toolbar-notice button {
flex: 0 0 auto;
border: 1px solid #dc9b7d;
border-radius: 8px;
background: #fff;
color: #9b5537;
font-size: 11px;
cursor: pointer;
}
/* 图片类生成面板的比例 / 尺寸选择:网页端是弹层里的参数簇,AGC 浮层里直接平铺两行,
窄屏换行,不做内部滚动。 */
.resource-canvas-asset-generation-dimensions {
display: grid;
gap: 10px;
min-width: 0;
}
/* 固定档规范图只读展示当前规格,不给点了不生效的控件。 */
.resource-canvas-asset-generation-fixed-spec {
justify-self: start;
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 1.05rem;
background: #f8fafc;
padding: 0.42rem 0.72rem;
color: #475569;
font-size: 0.76rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
@@ -160,6 +160,60 @@ export function projectResourcesByManifestAssetId(
return resourceByAssetId;
}
/**
* 本次会话的替换血缘:源素材 A → 替换素材 B,两端都是 **manifest 资产 id**
* (与替换写入载荷同一个 id 空间,不是展示层投影 id,也不是显示名)。
*/
export type ProjectResourceReplacementLineage = {
sourceResourceId: string;
replacementResourceId: string;
};
/**
* 一张资源卡上的血缘标注:挂在哪张卡由标注表的 key 决定,这里只说"对面是谁"。
*
* `resourceId` 是对面那张资源的稳定 idmanifest 资产 id),DOM 判据用的就是它;
* `label` 只是给人看的显示名。
*/
export type ResourceReplacementLineageBadge = {
/** `replaced-by` = 挂在源素材上("已被 X 替换");`replacement-of` = 挂在替换素材上("替换自 X")。 */
kind: 'replaced-by' | 'replacement-of';
resourceId: string;
label: string;
};
/**
* 血缘 → 资源卡标注表:源素材卡挂「已被 … 替换」,替换素材卡挂「替换自 …」。
*
* 显示名走资源投影(与候选弹窗共用 `projectResourcesByManifestAssetId` 这条身份 → 资源口径),
* 稳定 id 用血缘原值。任一端已不在资源列表里(被删除 / 换了项目)就整条不产出标注 ——
* 宁可没有标注,也不显示一个指不到东西的关系。
*
* 只吃**一条**血缘:同会话内再次替换会覆盖上一条,这里不做历史链。
*/
export function resourceReplacementLineageBadges(
lineage: ProjectResourceReplacementLineage | null,
resources: readonly ProjectResource[],
): Map<string, ResourceReplacementLineageBadge> {
const badges = new Map<string, ResourceReplacementLineageBadge>();
if (!lineage) return badges;
const resourceByAssetId = projectResourcesByManifestAssetId(resources);
const source = resourceByAssetId.get(lineage.sourceResourceId);
const replacement = resourceByAssetId.get(lineage.replacementResourceId);
if (!source || !replacement) return badges;
badges.set(lineage.sourceResourceId, {
kind: 'replaced-by',
resourceId: lineage.replacementResourceId,
label: replacement.label,
});
badges.set(lineage.replacementResourceId, {
kind: 'replacement-of',
resourceId: lineage.sourceResourceId,
label: source.label,
});
return badges;
}
/**
* 候选 → 弹窗素材。
*
@@ -236,3 +290,65 @@ export function resourceVersionReplacementErrorMessage(error: unknown): string {
}
return message;
}
/** 画布点选态点到的资源卡,能不能作为这次替换的目标。 */
export type ResourceReplacementPickResolution =
| { status: 'replace'; replacementResourceId: string }
| { status: 'rejected'; message: string };
/**
* 「点选替换」:把画布上点到的那张卡解析成写入目标,或给出「为什么不能替换」。
*
* 判据与候选弹窗**同源**,不重算兼容性:目标必须是 manifest 资产、必须在后端给出的权威
* 候选里(源素材已被后端排除)、且硬门禁通过(`resourceReplacementBlockedReason`)。
* 拒绝文案也走同一条翻译路径(`resourceVersionReplacementErrorMessage` 认的就是 Rust 那几种
* 错误形状),所以点选与列表两条入口对同一件事永远说同一句话。
*/
export function resolveResourceReplacementPick({
manifestAssetId,
sourceResourceId,
candidates,
}: {
/** 点到的那张资源卡的 manifest 资产身份;没有身份(未登记)时为 `null`。 */
manifestAssetId: string | null;
sourceResourceId: string;
candidates: readonly LocalProjectVersionReplacementCandidate[];
}): ResourceReplacementPickResolution {
if (!manifestAssetId) {
return {
status: 'rejected',
message: resourceVersionReplacementErrorMessage(
new Error('项目资源不存在:未登记资源'),
),
};
}
if (manifestAssetId === sourceResourceId) {
return {
status: 'rejected',
message: resourceVersionReplacementErrorMessage(
new Error('替换素材与源素材相同'),
),
};
}
const candidate = candidates.find(
(entry) => entry.resourceId === manifestAssetId,
);
if (!candidate) {
return {
status: 'rejected',
message: resourceVersionReplacementErrorMessage(
new Error(`项目资源不存在:${manifestAssetId}`),
),
};
}
const blockedReason = resourceReplacementBlockedReason(candidate);
if (blockedReason) {
return {
status: 'rejected',
message: resourceVersionReplacementErrorMessage(
new Error(`resource-replacement-incompatible${blockedReason}`),
),
};
}
return { status: 'replace', replacementResourceId: manifestAssetId };
}
+54 -45
View File
@@ -6186,36 +6186,7 @@ iframe.preview-frame {
}
/*
* 资源搜索浮层。
*
* 搜索条不再常驻:原来它和筛选条一起在管理区顶部占一条独立排布带(管理区 padding-top
* 加画本场景下移),栏目标题栏被挤到那条带下面仍然和搜索框叠在同一条水平带上。现在常态
* 只有右下角 Dock 里的搜索按钮,Ctrl/Cmd+FmacOS 为 Cmd+F)或点按钮才把浮层叫出来,
* Esc / 点外部收起(口径见 `useImageCanvasFloatingOptionDismiss`)。
*
* 浮层贴着右下角 Dock 向上展开:`bottom: 58px` = Dock 的 `bottom: 14px` + Dock 高度
* `34px` + 10px 间隙,所以它永远不会落到栏目标题栏(管理区顶边 min-height 42px)那条带上。
* `z-index: 40` 高于画本场景的 20,浮在资源卡与栏目标题栏之上。
*/
.game-resource-search {
position: absolute;
right: 14px;
bottom: 58px;
z-index: 40;
display: flex;
align-items: center;
gap: 8px;
width: min(320px, calc(100% - 28px));
padding: 0 10px;
border: 1px solid #ecdcd4;
border-radius: 10px;
background: #fff;
box-shadow: 0 6px 18px rgb(112 70 52 / 12%);
color: #a08073;
}
/*
* 搜索条件生效时(浮层收起也生效)Dock 上的搜索按钮保持高亮:筛选条件不会随浮层一起
* 有筛选条件时(浮层收起也生效)Dock 上的放大镜按钮保持高亮:筛选条件不会随浮层一起
* 消失,按钮必须能看出"当前有搜索",否则用户会以为资源丢了。复用 Dock 按钮的 hover 色。
*/
.game-resource-book-zoom-button.is-active {
@@ -6224,9 +6195,10 @@ iframe.preview-frame {
}
/*
* 筛选浮层:与搜索浮层同一个右下角锚点、同一套向上展开口径(`bottom: 58px` =
* Dock 的 `bottom: 14px` + Dock 高度 `34px` + 10px 间隙,`z-index: 40` 高于画本场景
* 的 20),所以两者永远不会落到栏目标题栏那条带上,也不会互相遮挡视线。
* 资源筛选浮层,也是画布唯一的搜索入口:贴着右下角 Dock 向上展开`bottom: 58px` =
* Dock 的 `bottom: 14px` + Dock 高度 `34px` + 10px 间隙,所以它永远不会落到栏目标题栏
* (管理区顶边 min-height 42px)那条带上;`z-index: 40` 高于画本场景的 20,浮在资源卡与
* 栏目标题栏之上。
*
* 定位留在宿主:共享的 `PlatformFilterPanel` 不自带 position,由这层给出锚点。
*/
@@ -6242,18 +6214,6 @@ iframe.preview-frame {
overflow-y: auto;
}
.game-resource-search input {
width: 100%;
min-width: 0;
height: 34px;
padding: 0;
border: 0;
outline: 0;
background: transparent;
color: #50382f;
font-size: 12px;
}
/*
* 浮层提示(工作台状态提示、附件导入失败):不再占管理区顶部的排布带高度,改成绝对
* 定位的浮层,落在栏目标题栏(管理区顶边 min-height 42px)下面 16px 处居中。
@@ -6311,13 +6271,31 @@ iframe.preview-frame {
pointer-events: auto;
}
/*
* 「编辑素材标签」是三段式:标题、标签区、底部「添加」。
*
* 面板本体 `display: grid`(见 `.game-approval-dialog`),这里把它切成
* `auto / minmax(0, 1fr) / auto` 三行,标题与底部常驻、只有中间一行伸缩;
* `max-height` 兜住上界。少了任何一半,标签一多整块面板就顶出视口,
* 靠上的标签既看不见也滚不到(外层遮罩是不安全居中,全链没有可滚容器)。
*/
.game-resource-classification-dialog {
width: min(480px, 100%);
max-height: min(720px, calc(100dvh - 40px));
grid-template-rows: auto minmax(0, 1fr) auto;
}
/*
* 滚动落在 body 这一行:`min-height: 0` 是网格项能被 `1fr` 压缩的前提,
* 否则内容高度会顶回轨道、`overflow-y` 永远不触发。
*/
.game-resource-classification-body {
display: grid;
gap: 10px;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
/*
@@ -7075,6 +7053,37 @@ iframe.preview-frame {
transform-origin: top right;
}
/* 替换血缘标注(本次会话内有效):源素材卡「已被 … 替换」/ 替换素材卡「替换自 …」。
左下角是卡片上唯一空闲的角(右上角标是类型、右下是媒体播放钮),用「当前版本」同一支橙色
把这条关系与光环联系起来。 */
.game-resource-card-lineage-badge {
position: absolute;
bottom: 8px;
left: 8px;
z-index: 2;
display: inline-flex;
/* 右下角可能有媒体播放钮(34px + 9px),血缘角标不越过去。 */
max-width: calc(100% - 52px);
align-items: center;
min-width: 0;
padding: 4px 8px;
overflow: hidden;
border: 1px solid rgb(255 255 255 / 72%);
border-radius: 999px;
background: rgb(196 105 62 / 90%);
color: #fff;
font-size: 10px;
font-weight: 900;
line-height: 1;
letter-spacing: 0.02em;
pointer-events: none;
text-overflow: ellipsis;
white-space: nowrap;
box-shadow: 0 8px 18px rgb(96 62 47 / 20%);
transform: scale(var(--genarrative-image-canvas-inverse-scale, 1));
transform-origin: bottom left;
}
.game-resource-card-media-control {
transform: scale(var(--genarrative-image-canvas-inverse-scale, 1));
transform-origin: bottom right;
@@ -30,7 +30,7 @@ function resourceFilterTagChipClassName(active: boolean) {
type ResourceFilterPanelProps = {
onClose: () => void;
/** 关键词与画布搜索浮层共用同一份状态,这里只转发。 */
/** 关键词:右下角放大镜与 Ctrl/Cmd+F 叫出的就是这一个面板,状态由宿主持有。 */
keyword: string;
onKeywordChange: (value: string) => void;
/** 区域即画布当前栏目;本面板不改动栏目,只把选择交给宿主。 */
@@ -46,9 +46,10 @@ type ResourceFilterPanelProps = {
};
/**
* 资源画布筛选浮层
* 资源画布筛选浮层,也是画布唯一的搜索入口:右下角 Dock 的放大镜按钮与
* `Ctrl/Cmd+F` 都叫出这一个面板,不存在第二个只放关键词的浮层。
*
* 领域规则都留在这一层:区域取值就是画布栏目、关键词与搜索浮层共用一份状态、
* 领域规则都留在这一层:区域取值就是画布栏目、关键词与筛选条件共用宿主那一份状态、
* 标签用 AND 语义。共享层只提供浮层外壳与表单原语,不知道这些概念。
*/
export function ResourceFilterPanel({
@@ -121,13 +122,26 @@ export function ResourceFilterPanel({
label="查找素材"
controlId={`${idPrefix}-keyword`}
>
{/*
`autoFocus`:面板只在打开时挂载,所以「打开即聚焦关键词」就是既有
搜索浮层的落点语义(PRD「若资源已经被后台删除…把焦点落到资源搜索框」)。
`Escape` 的 preventDefault`type=search` 的原生行为是 Esc 清空输入框,
这里只让浮层收起——关闭不等于清除关键词(浮层自己的 Esc 在 document
阶段截断并收起面板)。
*/}
<PlatformTextField
id={`${idPrefix}-keyword`}
type="search"
aria-label="查找素材"
placeholder="名称、路径或类型"
density="compact"
autoFocus
value={keyword}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
}
}}
onChange={(event) => onKeywordChange(event.currentTarget.value)}
/>
</PlatformFilterPanelField>
File diff suppressed because it is too large Load Diff
@@ -366,15 +366,15 @@ function queryResourceSelectButton(label: string) {
}
/**
* 叫出资源搜索浮层并返回搜索框。
* 叫出画布唯一的筛选面板(右下角 Dock 的放大镜按钮)并返回关键词输入框。
*
* 搜索条改成「临时叫出」的浮层之后,输入框只在浮层打开时存在于 DOM:用例必须走产品
* 入口(右下角 Dock 的搜索按钮)打开它。`getByLabelText` 在浮层没打开时会直接抛错,
* 所以「搜索框在真实 UI 里点不到」那类假绿不会再出现
* 面板只在打开时存在于 DOM:用例必须走产品入口打开它,`getByLabelText` 在面板没打开时
* 会直接抛错,所以「输入框在真实 UI 里点不到」那类假绿不会再出现。关键词 / 所在区域 /
* 自定义标签三个字段都在这一次打开的面板里(`Ctrl/Cmd+F` 叫出的是同一个)
*/
function openResourceSearch() {
function openResourceFilterPanel() {
fireEvent.click(screen.getByRole('button', { name: '搜索资源' }));
return screen.getByLabelText('搜索项目资源') as HTMLInputElement;
return screen.getByLabelText('查找素材') as HTMLInputElement;
}
function emptyProjectPolicy() {
@@ -1352,7 +1352,7 @@ export {
mockRoleAgentReply,
nativeClipboardMock,
openMainProject,
openResourceSearch,
openResourceFilterPanel,
pickProjectFromLauncher,
ProjectDevelopmentView,
projectSupervisorResponseStream,
File diff suppressed because it is too large Load Diff
@@ -5,6 +5,12 @@ import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import {
declaration,
parseStyleSheet,
resolveDeclarations,
} from './styleCascade';
const STYLES_PATH = resolve(
process.cwd(),
'apps/ai-game-creator-shell/src/styles.css',
@@ -14,231 +20,6 @@ const VIEW_PATH = resolve(
'apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx',
);
/* ============================================================
声明级层叠求值器。
jsdom 不加载 styles.css,所以"外框有没有把输入区包住"这类几何只能在声明上验;
但只读"第一条匹配到的规则"会被后面的同权重规则悄悄顶掉——本用例要防的就是这种事
`bottom: 156px` 那条就是这样把上一轮修的 `bottom: 0` 变成死声明的),
所以这里按真实层叠(媒体查询是否命中 -> 特异性 -> 源码顺序)算出最终生效的值。
============================================================ */
type StyleRule = {
selectors: string[];
declarations: Map<string, string>;
media: string | null;
order: number;
};
function parseStyleSheet(css: string): StyleRule[] {
const rules: StyleRule[] = [];
const source = css.replace(/\/\*[\s\S]*?\*\//gu, (comment) =>
comment.replace(/[^\n]/gu, ' '),
);
const pushDeclarationBlock = (
selectorText: string,
body: string,
media: string | null,
) => {
const selectors = splitSelectorList(selectorText);
if (selectors.length === 0) {
return;
}
const declarations = new Map<string, string>();
for (const chunk of body.split(';')) {
const separator = chunk.indexOf(':');
if (separator < 0) {
continue;
}
const property = chunk.slice(0, separator).trim();
const value = chunk
.slice(separator + 1)
.trim()
.replace(/\s+/gu, ' ');
if (property) {
declarations.set(property, value);
}
}
rules.push({ selectors, declarations, media, order: rules.length });
};
const readBlock = (text: string, start: number) => {
let depth = 1;
let index = start;
while (index < text.length && depth > 0) {
if (text[index] === '{') {
depth += 1;
} else if (text[index] === '}') {
depth -= 1;
}
index += 1;
}
return { body: text.slice(start, index - 1), end: index };
};
let cursor = 0;
let buffer = '';
while (cursor < source.length) {
const char = source[cursor]!;
if (char === '{') {
const prelude = buffer.trim().replace(/\s+/gu, ' ');
buffer = '';
const { body, end } = readBlock(source, cursor + 1);
cursor = end;
if (prelude.startsWith('@media')) {
// 媒体查询体内只陈述"顶层选择器 + 声明",这里就够了:查询用的选择器都在顶层。
let inner = 0;
let innerBuffer = '';
while (inner < body.length) {
if (body[inner] === '{') {
const innerSelector = innerBuffer.trim().replace(/\s+/gu, ' ');
innerBuffer = '';
const { body: innerBody, end: innerEnd } = readBlock(
body,
inner + 1,
);
pushDeclarationBlock(innerSelector, innerBody, prelude);
inner = innerEnd;
continue;
}
innerBuffer += body[inner];
inner += 1;
}
continue;
}
if (prelude.startsWith('@')) {
continue;
}
pushDeclarationBlock(prelude, body, null);
continue;
}
if (char === '}') {
buffer = '';
cursor += 1;
continue;
}
buffer += char;
cursor += 1;
}
return rules;
}
function splitSelectorList(selectorText: string): string[] {
const selectors: string[] = [];
let depth = 0;
let current = '';
for (const char of selectorText) {
if (char === '(' || char === '[') {
depth += 1;
} else if (char === ')' || char === ']') {
depth -= 1;
}
if (char === ',' && depth === 0) {
if (current.trim()) {
selectors.push(current.trim().replace(/\s+/gu, ' '));
}
current = '';
continue;
}
current += char;
}
if (current.trim()) {
selectors.push(current.trim().replace(/\s+/gu, ' '));
}
return selectors;
}
/** 特异性:只区分 id / 类(含属性与伪类)/ 元素,够排序本文件里的选择器。 */
function specificity(selector: string): number {
let rest = selector;
let classLike = 0;
// `:has(...)` 的权重按它参数里最高的那条选择器算,这里等价于再加一个类。
const hasParts = rest.match(/:has\([^)]*\)/gu) ?? [];
for (const part of hasParts) {
classLike += 1;
const inner = Math.max(
...splitSelectorList(part.slice(5, -1)).map((one) => specificity(one)),
);
classLike += Math.floor(inner / 100) % 100;
}
rest = rest.replace(/:has\([^)]*\)/gu, ' ');
const ids = (rest.match(/#[\w-]+/gu) ?? []).length;
classLike += (rest.match(/\.[\w-]+/gu) ?? []).length;
classLike += (rest.match(/\[[^\]]*\]/gu) ?? []).length;
classLike += (rest.match(/:(?!:)[\w-]+/gu) ?? []).length;
const types =
(rest.match(/(?:^|[\s>+~])([a-zA-Z][\w-]*)/gu) ?? []).length +
(rest.match(/::[\w-]+/gu) ?? []).length;
return ids * 10000 + classLike * 100 + types;
}
function mediaMatches(media: string, viewportWidth: number): boolean {
const conditions = media.match(/\((?:min|max)-width:\s*\d+px\)/gu) ?? [];
if (conditions.length === 0) {
// 只认识宽度条件:命中了别的媒体特性(例如 prefers-reduced-motion)就说明这条规则
// 的生效与否不是本用例能判定的,直接报错,避免悄悄算错一个几何值。
throw new Error(`测试求值器不认识这个媒体查询:${media}`);
}
for (const condition of conditions) {
const parsed = /\((min|max)-width:\s*(\d+)px\)/u.exec(condition)!;
const limit = Number(parsed[2]);
if (parsed[1] === 'min' && viewportWidth < limit) {
return false;
}
if (parsed[1] === 'max' && viewportWidth > limit) {
return false;
}
}
return true;
}
/**
* 按层叠算出元素最终生效的声明。
* `elementSelectors` 是这个元素在 DOM 上会命中的全部选择器(含媒体查询里那几条)。
*/
function resolveDeclarations(
rules: StyleRule[],
elementSelectors: readonly string[],
viewportWidth: number,
): Map<string, string> {
const winners = new Map<string, { value: string; rank: [number, number] }>();
for (const rule of rules) {
const matched = rule.selectors
.filter((selector) => elementSelectors.includes(selector))
.map((selector) => specificity(selector));
if (matched.length === 0) {
continue;
}
if (rule.media && !mediaMatches(rule.media, viewportWidth)) {
continue;
}
const rank: [number, number] = [Math.max(...matched), rule.order];
for (const [property, value] of rule.declarations) {
const current = winners.get(property);
if (
!current ||
rank[0] > current.rank[0] ||
(rank[0] === current.rank[0] && rank[1] > current.rank[1])
) {
winners.set(property, { value, rank });
}
}
}
return new Map(
Array.from(winners, ([property, winner]) => [property, winner.value]),
);
}
function declaration(
declarations: Map<string, string>,
property: string,
): string {
const value = declarations.get(property);
expect(value, `缺少生效声明 ${property}`).toBeDefined();
return value!;
}
function lengthPx(rawValue: string, label: string): number {
const value = rawValue.trim();
// CSS 里 0 可以不带单位,其余一律要求 px(本文件不写 rem/em 几何)。
@@ -20,7 +20,7 @@ import {
createGameCreationAppManifest,
findResourceSelectButton,
fireEvent,
openResourceSearch,
openResourceFilterPanel,
queryResourceSelectButton,
render,
screen,
@@ -985,7 +985,8 @@ describe('project resource live canvas integration', () => {
render(<DerivedWorkbench />);
fireEvent.click(await screen.findByRole('button', { name: '生成素材' }));
const panel = await screen.findByRole('dialog', { name: '生成素材' });
// 「生成素材」入口只保留视频:音频入口已由栏目画布底部工具栏承载。
const panel = await screen.findByRole('dialog', { name: '生成视频' });
fireEvent.change(within(panel).getByLabelText('生成提示词'), {
target: { value: '一段片头动画,镜头缓慢推进' },
});
@@ -1015,7 +1016,7 @@ describe('project resource live canvas integration', () => {
expect(deriveCalls[0]).not.toHaveProperty('accessToken');
expect(deriveCalls[1]?.operationId).toBe(operationId);
expect(deriveCalls[1]?.idempotencyKey).toBe(deriveCalls[0]?.idempotencyKey);
expect(screen.queryByRole('dialog', { name: '生成素材' })).toBeNull();
expect(screen.queryByRole('dialog', { name: '生成视频' })).toBeNull();
// 产出物是新素材:画布定位并选中新卡片。
expect(
(await findResourceSelectButton(`${operationId}-rules.md`)).getAttribute(
@@ -1042,7 +1043,7 @@ describe('project resource live canvas integration', () => {
'把角色头发设定改为红色',
);
// 搜索条件在提交前就存在,新素材(文档分类)不会命中它:走"被当前搜索隐藏"分支。
const search = openResourceSearch();
const search = openResourceFilterPanel();
fireEvent.change(search, { target: { value: 'source-art' } });
fireEvent.click(within(panel).getByRole('button', { name: '修改' }));
await waitFor(() => expect(deriveCalls).toHaveLength(1));
@@ -1052,11 +1053,11 @@ describe('project resource live canvas integration', () => {
await screen.findByText('新资源已保存,但被当前搜索条件隐藏'),
).not.toBeNull();
// 提交时点了快速编辑面板,搜索浮层按「点外部」收起;重新叫出来读到的仍是被保留的条件。
expect(openResourceSearch().value).toBe('source-art');
expect(openResourceFilterPanel().value).toBe('source-art');
fireEvent.click(screen.getByRole('button', { name: '清除搜索并定位' }));
// 清空只由这个显式动作发起:再叫出浮层读到的已经是空值。
expect(openResourceSearch().value).toBe('');
expect(openResourceFilterPanel().value).toBe('');
const operationId = String(deriveCalls[0]?.operationId);
expect(
(await findResourceSelectButton(`${operationId}-rules.md`)).getAttribute(
@@ -0,0 +1,451 @@
// @vitest-environment jsdom
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import { afterEach, describe, expect, test, vi } from 'vitest';
import {
EDITOR_IMAGE_DIMENSION_OPTIONS,
IMAGE_MODEL_NANOBANANA2,
} from '../../../src/components/image-editor/ImageCanvasGenerationModel';
import { ResourceCanvasAssetGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView';
import {
projectHasIconSpecReference,
resolveResourceCanvasBottomToolAvailability,
resolveResourceCanvasBottomTools,
RESOURCE_CANVAS_ASSET_ASPECT_RATIOS,
RESOURCE_CANVAS_ASSET_IMAGE_SIZES,
RESOURCE_CANVAS_ICON_SPEC_LOCAL_PATH,
resourceCanvasAssetGenerationOutputPath,
type ResourceCanvasAssetToolAction,
type ResourceCanvasBottomTool,
resourceCanvasBottomToolActions,
} from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel';
import { ResourceCanvasBottomToolbarView } from '../src/features/resource-canvas/ResourceCanvasBottomToolbarView';
afterEach(cleanup);
function toolLabels(tools: readonly ResourceCanvasBottomTool[]) {
return tools.map((tool) => tool.label);
}
function assetActionOf(
category: 'ui-interaction' | 'character' | 'scene' | 'audio',
label: string,
): ResourceCanvasAssetToolAction {
const tools = resolveResourceCanvasBottomTools(category);
const action = tools
.flatMap((tool) => [...resourceCanvasBottomToolActions(tool)])
.find((candidate) => candidate.label === label);
if (!action || action.route !== 'asset') {
throw new Error(`${category} 栏目缺少图片类入口 ${label}`);
}
return action;
}
function menuLabels(category: 'ui-interaction' | 'character' | 'scene') {
const tool = resolveResourceCanvasBottomTools(category).find(
(candidate) => candidate.id === 'generate-spec',
);
return tool?.menu?.map((item) => item.label) ?? [];
}
describe('resourceCanvasBottomToolbarModel', () => {
test('四个栏目各自呈现用户拍板的工具集,顺序与矩阵一致', () => {
expect(
toolLabels(resolveResourceCanvasBottomTools('ui-interaction')),
).toEqual([
'生成图片',
'生成规范',
'生成图标素材',
'生成 UI 设计图',
'上传',
]);
expect(toolLabels(resolveResourceCanvasBottomTools('character'))).toEqual([
'生成图片',
'生成规范',
'生成角色形象',
'上传',
]);
expect(toolLabels(resolveResourceCanvasBottomTools('scene'))).toEqual([
'生成图片',
'生成规范',
'上传',
]);
expect(toolLabels(resolveResourceCanvasBottomTools('audio'))).toEqual([
'生成背景音乐',
'生成音效',
'上传',
]);
});
test('二级菜单按栏目分流,且不含生成视频 / 宣发素材 / 生成游戏场景', () => {
expect(menuLabels('ui-interaction')).toEqual(['图标规范', '自定义规范']);
expect(menuLabels('character')).toEqual(['角色规范', '自定义规范']);
expect(menuLabels('scene')).toEqual(['自定义规范']);
const everyAction = (
['ui-interaction', 'character', 'scene', 'audio'] as const
).flatMap((category) =>
resolveResourceCanvasBottomTools(category).flatMap((tool) => [
...resourceCanvasBottomToolActions(tool),
]),
);
for (const forbidden of [
'生成视频',
'宣发素材',
'生成游戏场景',
'选择工具',
'抓手工具',
]) {
expect(everyAction.map((action) => action.label)).not.toContain(
forbidden,
);
}
});
test('不渲染工具栏的栏目一律返回空数组', () => {
for (const category of [
'document',
'unclassified',
'version',
'all',
null,
undefined,
] as const) {
expect(resolveResourceCanvasBottomTools(category)).toEqual([]);
}
});
test('比例与尺寸选项来自网页端纯模型并裁到本地 IPC 白名单', () => {
const mainStationRatios =
EDITOR_IMAGE_DIMENSION_OPTIONS[IMAGE_MODEL_NANOBANANA2].aspectRatios;
// 选项不是另抄的一份常量:网页端纯模型的每一项都还在,只是裁掉了本地通道拒绝的 4:3。
expect(mainStationRatios).toEqual(
expect.arrayContaining([...RESOURCE_CANVAS_ASSET_ASPECT_RATIOS]),
);
expect(RESOURCE_CANVAS_ASSET_ASPECT_RATIOS).not.toContain('4:3');
expect(RESOURCE_CANVAS_ASSET_ASPECT_RATIOS).toEqual([
'1:1',
'3:2',
'2:3',
'9:16',
'16:9',
]);
expect(RESOURCE_CANVAS_ASSET_IMAGE_SIZES).toEqual(['0.5K', '1K', '2K']);
expect(assetActionOf('ui-interaction', '生成图片')).toMatchObject({
aspectRatio: '1:1',
imageSize: '1K',
adjustableDimensions: true,
});
// 网页端 UI 设计图面板的默认档是 16:9 · 1K,工具栏沿用同一默认值。
expect(assetActionOf('ui-interaction', '生成 UI 设计图')).toMatchObject({
aspectRatio: '16:9',
imageSize: '1K',
});
});
test('入口可用性给可执行的原因,而不是静默不可用', () => {
const iconSpritesheet = assetActionOf('ui-interaction', '生成图标素材');
expect(
resolveResourceCanvasBottomToolAvailability(iconSpritesheet, {
hasRuntimeInvoke: false,
projectPath: '/tmp/project',
projectId: 'project-1',
hasIconSpecReference: true,
}).blockedReason,
).toContain('客户端');
expect(
resolveResourceCanvasBottomToolAvailability(iconSpritesheet, {
hasRuntimeInvoke: true,
projectPath: ' ',
projectId: 'project-1',
hasIconSpecReference: true,
}).blockedReason,
).toContain('项目尚未就绪');
const blocked = resolveResourceCanvasBottomToolAvailability(
iconSpritesheet,
{
hasRuntimeInvoke: true,
projectPath: '/tmp/project',
projectId: 'project-1',
hasIconSpecReference: false,
},
);
expect(blocked.available).toBe(false);
expect(blocked.blockedReason).toContain('assets/art-spec.png');
expect(blocked.blockedReason).toContain('图标规范');
expect(
resolveResourceCanvasBottomToolAvailability(
assetActionOf('ui-interaction', '生成图片'),
{
hasRuntimeInvoke: true,
projectPath: '/tmp/project',
projectId: 'project-1',
hasIconSpecReference: false,
},
),
).toEqual({ available: true, blockedReason: null });
});
test('权威规范图判据与 Rust 同口径:精确落点 + icon-spec + 画布来源', () => {
const canvasIconSpec = {
kind: 'icon-spec',
localPath: RESOURCE_CANVAS_ICON_SPEC_LOCAL_PATH,
mediaType: 'image/png',
source: { kind: 'canvas' },
};
expect(projectHasIconSpecReference([canvasIconSpec])).toBe(true);
expect(
projectHasIconSpecReference([
{ ...canvasIconSpec, localPath: 'assets/icon-spec.png' },
]),
).toBe(false);
expect(
projectHasIconSpecReference([{ ...canvasIconSpec, kind: 'spec' }]),
).toBe(false);
expect(
projectHasIconSpecReference([
{ ...canvasIconSpec, source: { kind: 'generated' } },
]),
).toBe(false);
expect(
projectHasIconSpecReference([
{ ...canvasIconSpec, mediaType: 'application/octet-stream' },
]),
).toBe(false);
});
test('图标规范入口在缺前置时写进权威落点,已有前置时不再覆盖', () => {
const iconSpec = assetActionOf('ui-interaction', '图标规范');
expect(resourceCanvasAssetGenerationOutputPath(iconSpec, false)).toBe(
RESOURCE_CANVAS_ICON_SPEC_LOCAL_PATH,
);
expect(resourceCanvasAssetGenerationOutputPath(iconSpec, true)).toBeNull();
expect(
resourceCanvasAssetGenerationOutputPath(
assetActionOf('ui-interaction', '生成图标素材'),
false,
),
).toBeNull();
expect(
resourceCanvasAssetGenerationOutputPath(
assetActionOf('character', '生成角色形象'),
false,
),
).toBeNull();
});
});
describe('ResourceCanvasBottomToolbarView', () => {
function renderToolbar(overrides: {
category: 'ui-interaction' | 'character' | 'audio';
blockedLabels?: readonly string[];
uploadBlockedReason?: string | null;
onSelectAction?: (action: unknown) => void;
onUploadFiles?: (files: readonly File[]) => void;
}) {
const tools = resolveResourceCanvasBottomTools(overrides.category);
const onSelectAction = overrides.onSelectAction ?? vi.fn();
const onUploadFiles = overrides.onUploadFiles ?? vi.fn();
render(
<ResourceCanvasBottomToolbarView
category={overrides.category}
tools={tools}
resolveBlockedReason={(action) =>
(overrides.blockedLabels ?? []).includes(action.label)
? '需要先完成并登记图标规范(assets/art-spec.png'
: null
}
uploadBlockedReason={overrides.uploadBlockedReason ?? null}
onSelectAction={onSelectAction}
onUploadFiles={onUploadFiles}
/>,
);
return { onSelectAction, onUploadFiles };
}
test('按栏目渲染工具,二级菜单只在展开时出现', () => {
renderToolbar({ category: 'ui-interaction' });
expect(
document.querySelector('[data-resource-bottom-toolbar]'),
).not.toBeNull();
expect(
document
.querySelector('[data-resource-bottom-toolbar]')
?.getAttribute('data-resource-bottom-toolbar'),
).toBe('ui-interaction');
for (const label of [
'生成图片',
'生成规范',
'生成图标素材',
'生成 UI 设计图',
]) {
expect(screen.getByRole('button', { name: label })).not.toBeNull();
}
expect(screen.queryByRole('menuitem', { name: '图标规范' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '生成规范' }));
expect(screen.getByRole('menuitem', { name: '图标规范' })).not.toBeNull();
expect(screen.getByRole('menuitem', { name: '自定义规范' })).not.toBeNull();
});
test('不可用的入口保持可点击,只给原因说明', () => {
const { onSelectAction } = renderToolbar({
category: 'ui-interaction',
blockedLabels: ['生成图标素材'],
});
const blockedButton = screen.getByRole('button', { name: '生成图标素材' });
expect((blockedButton as HTMLButtonElement).disabled).toBe(false);
expect(blockedButton.getAttribute('aria-disabled')).toBe('true');
fireEvent.click(blockedButton);
expect(onSelectAction).not.toHaveBeenCalled();
const notice = screen.getByRole('alert');
expect(notice.textContent).toContain('assets/art-spec.png');
// 原因说明可以关掉,关掉后不再占位。
fireEvent.click(screen.getByRole('button', { name: '知道了' }));
expect(screen.queryByRole('alert')).toBeNull();
});
test('二级菜单项命中同一套可用性判据,可用时把动作交给宿主', () => {
const { onSelectAction } = renderToolbar({ category: 'character' });
fireEvent.click(screen.getByRole('button', { name: '生成规范' }));
fireEvent.click(screen.getByRole('menuitem', { name: '角色规范' }));
expect(onSelectAction).toHaveBeenCalledWith(
expect.objectContaining({
route: 'asset',
label: '角色规范',
assetKind: 'spec',
}),
);
// 选中后菜单收起,不会残留一层挡住画布。
expect(screen.queryByRole('menuitem', { name: '角色规范' })).toBeNull();
});
test('音频栏目只呈现背景音乐与音效,并把选择交给宿主', () => {
const { onSelectAction } = renderToolbar({ category: 'audio' });
fireEvent.click(screen.getByRole('button', { name: '生成音效' }));
expect(onSelectAction).toHaveBeenCalledWith(
expect.objectContaining({ route: 'audio', audioKind: 'sound-effect' }),
);
expect(screen.queryByRole('button', { name: '生成视频' })).toBeNull();
});
test('上传走宿主回调,桥不可用时同样只给原因', () => {
const { onUploadFiles } = renderToolbar({ category: 'audio' });
const input = screen.getByLabelText('上传素材文件') as HTMLInputElement;
const file = new File(['x'], 'bgm.mp3', { type: 'audio/mpeg' });
fireEvent.change(input, { target: { files: [file] } });
expect(onUploadFiles).toHaveBeenCalledWith([file]);
cleanup();
renderToolbar({
category: 'audio',
uploadBlockedReason: '该能力需要在客户端内执行',
});
fireEvent.click(screen.getByRole('button', { name: '上传' }));
expect(screen.getByRole('alert').textContent).toContain('客户端');
});
});
describe('ResourceCanvasAssetGenerationPanelView', () => {
test('固定档入口只展示当前规格,提交载荷逐字正确', async () => {
const onSubmit = vi.fn(async () => undefined);
render(
<ResourceCanvasAssetGenerationPanelView
action={assetActionOf('ui-interaction', '图标规范')}
onSubmit={onSubmit}
onClose={() => undefined}
/>,
);
const panel = screen.getByRole('dialog', { name: '图标规范' });
expect(panel.textContent).toContain('1:1·1K');
expect(
screen.queryByRole('button', { name: '图标规范比例 1:1' }),
).toBeNull();
fireEvent.change(screen.getByLabelText('生成提示词'), {
target: { value: '像素月光厨房的统一视觉规范' },
});
fireEvent.click(screen.getByRole('button', { name: '图标规范' }));
await waitFor(() =>
expect(onSubmit).toHaveBeenCalledWith({
kind: 'icon-spec',
prompt: '像素月光厨房的统一视觉规范',
assetName: '图标规范',
aspectRatio: '1:1',
imageSize: '1K',
}),
);
});
test('可调档入口复用网页端比例选项,切换后进入载荷', async () => {
const onSubmit = vi.fn(async () => undefined);
render(
<ResourceCanvasAssetGenerationPanelView
action={assetActionOf('ui-interaction', '生成 UI 设计图')}
onSubmit={onSubmit}
onClose={() => undefined}
/>,
);
// 本地通道拒绝 4:3,面板不得把它渲染成可点选项。
expect(
screen.queryByRole('button', { name: '生成 UI 设计图比例 4:3' }),
).toBeNull();
fireEvent.click(
screen.getByRole('button', { name: '生成 UI 设计图比例 9:16' }),
);
fireEvent.click(
screen.getByRole('button', { name: '生成 UI 设计图尺寸 2K' }),
);
fireEvent.change(screen.getByLabelText('生成提示词'), {
target: { value: '横屏单屏界面' },
});
fireEvent.click(screen.getByRole('button', { name: '生成 UI 设计图' }));
await waitFor(() =>
expect(onSubmit).toHaveBeenCalledWith({
kind: 'ui-prototype',
prompt: '横屏单屏界面',
assetName: 'AI 生成 UI 设计图',
aspectRatio: '9:16',
imageSize: '2K',
}),
);
});
test('空提示词不提交,失败保留草稿并可原样重试', async () => {
const onSubmit = vi
.fn<(input: unknown) => Promise<void>>()
.mockRejectedValueOnce(new Error('图片比例不受支持:4:3'))
.mockResolvedValueOnce(undefined);
render(
<ResourceCanvasAssetGenerationPanelView
action={assetActionOf('character', '生成角色形象')}
onSubmit={onSubmit}
onClose={() => undefined}
/>,
);
const submit = screen.getByRole('button', { name: '生成角色形象' });
expect((submit as HTMLButtonElement).disabled).toBe(true);
fireEvent.change(screen.getByLabelText('生成提示词'), {
target: { value: '披风猫骑士' },
});
fireEvent.click(submit);
expect(await screen.findByRole('alert')).not.toBeNull();
expect(screen.getByRole('alert').textContent).toContain(
'图片比例不受支持:4:3',
);
// 失败不锁输入:同一份草稿再点一次就是同一个请求。
expect(
(screen.getByLabelText('生成提示词') as HTMLTextAreaElement).disabled,
).toBe(false);
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(2));
expect(onSubmit.mock.calls[1]?.[0]).toEqual(onSubmit.mock.calls[0]?.[0]);
});
});
@@ -152,6 +152,25 @@ describe('ResourceCanvasGenerationPanelView', () => {
);
expect(screen.getByRole('button', { name: '生成背景音乐' })).not.toBeNull();
});
test('只放行一种类型时不再渲染类型选择器,标题与默认名称跟该类走', () => {
render(
<ResourceCanvasGenerationPanelView
kinds={['background-music']}
initialKind="background-music"
onSubmit={async () => undefined}
onClose={() => undefined}
/>,
);
const panel = screen.getByRole('dialog', { name: '生成背景音乐' });
// 一个只有一个选项的分段控件是噪音:单类型入口不渲染它。
expect(within(panel).queryByRole('button', { name: '视频' })).toBeNull();
expect(within(panel).queryByRole('button', { name: '音效' })).toBeNull();
expect(
(within(panel).getByLabelText('素材名称') as HTMLInputElement).value,
).toBe('新背景音乐');
});
});
describe('生成素材弹窗的提示词润色', () => {
@@ -15,6 +15,11 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
import type { GameCreationAppAssetManifestEntry } from '../../../packages/shared/src/contracts/gameCreationApp';
import { ResourceClassificationPanel } from '../src/view/project-development/ResourceClassificationPanel';
import {
declaration,
parseStyleSheet,
resolveDeclarations,
} from './styleCascade';
const asset: GameCreationAppAssetManifestEntry = {
id: 'asset-hero',
@@ -735,3 +740,65 @@ describe('ResourceClassificationPanel 不再承载删除素材', () => {
);
});
});
/**
* 面板的高度契约:标题与底部「添加」常驻,只有标签区自己滚动。
*
* jsdom 没有布局引擎,所以这里用声明级层叠求值器(与 `chatDialogFrameLayout.test.ts`
* 同一只)算出真机上最终生效的声明——只搜文件里"出现过 max-height"会被后面的同权重
* 规则顶掉,也看不出 `1fr` 轨道是否真的可压缩。
*
* 变异验证:删掉面板的 `max-height`、或删掉 body 的 `min-height: 0`、或把
* `grid-template-rows` 的中间轨道改回 `auto`,本用例必须失败。
*/
describe('ResourceClassificationPanel 标签区可滚动', () => {
const styles = readFileSync(
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
'utf8',
);
const rules = parseStyleSheet(styles);
test('面板有高度上界,中间一行可收缩并自行滚动', () => {
// 面板在 DOM 上同时命中 `.game-approval-dialog`(给出 `display: grid`)与
// `.game-resource-classification-dialog`,选择器身份就是这两条。
const dialog = resolveDeclarations(
rules,
['.game-approval-dialog', '.game-resource-classification-dialog'],
1440,
);
expect(declaration(dialog, 'display')).toBe('grid');
// 上界:没有它,标签一多面板就长出视口,遮罩又是不安全居中,靠上的标签看不见也滚不到。
expect(declaration(dialog, 'max-height')).toBe(
'min(720px, calc(100dvh - 40px))',
);
// 三段轨道:header 常驻 / body 可压缩可滚动 / footer 常驻。
expect(declaration(dialog, 'grid-template-rows')).toBe(
'auto minmax(0, 1fr) auto',
);
const body = resolveDeclarations(
rules,
['.game-resource-classification-body'],
1440,
);
expect(declaration(body, 'min-height')).toBe('0');
expect(declaration(body, 'overflow-y')).toBe('auto');
expect(declaration(body, 'overscroll-behavior')).toBe('contain');
expect(declaration(body, 'scrollbar-gutter')).toBe('stable');
// 三条轨道按 DOM 顺序落到 header / body / footer 上。
installInvoke(async () => undefined);
renderPanel();
const panel = document.querySelector(
'.game-resource-classification-dialog',
);
expect(panel).not.toBeNull();
expect(
Array.from(panel!.children).map((child) =>
child.classList.contains('game-resource-classification-body')
? 'body'
: child.tagName.toLowerCase(),
),
).toEqual(['header', 'body', 'footer']);
});
});
@@ -163,4 +163,28 @@ describe('ResourceFilterPanel', () => {
fireEvent.keyDown(document, { key: 'Escape' });
expect(document.activeElement).toBe(triggerRef.current);
});
it('关键词字段吃掉 Escape 的默认行为,其他按键照旧放行', () => {
renderPanel({ keyword: '主角' });
const input = screen.getByLabelText('查找素材') as HTMLInputElement;
// `type=search` 的原生行为是 Esc 清空输入框(WKWebView 上真会清);
// 不 preventDefault 就等于"按 Esc 顺手把筛选条件删了"。
const escape = new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true,
});
input.dispatchEvent(escape);
expect(escape.defaultPrevented).toBe(true);
const enter = new KeyboardEvent('keydown', {
key: 'Enter',
bubbles: true,
cancelable: true,
});
input.dispatchEvent(enter);
expect(enter.defaultPrevented).toBe(false);
expect(input.value).toBe('主角');
});
});
@@ -8,6 +8,7 @@ import {
expect,
findResourceSelectButton,
fireEvent,
getResourceSelectButton,
it,
ProjectDevelopmentView,
React,
@@ -176,7 +177,8 @@ const REPLACEMENT_RESULT = {
type RenderOptions = {
replacementCandidates?: () => Promise<unknown>;
replacementWrite?: () => Promise<unknown>;
/** 写入桩;吃 IPC 入参,便于用例按"这次换的是谁"返回对应的 `replacement` 记录。 */
replacementWrite?: (args?: Record<string, unknown>) => Promise<unknown>;
/** 未被 manifest 登记的附件:用来验证「只有 manifest 资产才有删除素材入口」这一判据。 */
attachments?: Array<{
fileName: string;
@@ -319,7 +321,7 @@ function renderReplacementWorkbench(options: RenderOptions = {}) {
}
if (command === 'replace_local_project_version_resource') {
return options.replacementWrite
? options.replacementWrite()
? options.replacementWrite(args)
: REPLACEMENT_RESULT;
}
if (command === 'read_local_project_asset_references') {
@@ -407,6 +409,64 @@ function classificationWrites(invoke: { mock: { calls: unknown[][] } }) {
);
}
/** 替换写入的调用明细:合法点选必须恰好一次,非法点选必须零次。 */
function replacementWrites(invoke: { mock: { calls: unknown[][] } }) {
return invoke.mock.calls.filter(
([command]) => command === 'replace_local_project_version_resource',
);
}
/** 点选态的提示条;不在点选态时为 `null`。 */
function pickHint() {
return document.querySelector<HTMLElement>('.game-resource-canvas-pick-hint');
}
/**
* 在点选态下点一张资源卡:按下(这一步就是点选)+ 真实浏览器里紧随其后的那一次 `click`。
*
* 之所以把 `click` 也补上:点选态要求"卡片单击不参与选中",而那正是靠**消费掉紧随 pointerdown
* 的那一次 click** 实现的 —— 不补这一下,用例就漏掉了抑制残留这条最可能的回归。
*/
async function pickResourceCard(label: string) {
const selectButton = await findResourceSelectButton(label);
const card = selectButton.closest('.game-resource-card');
if (!card) throw new Error(`资源卡未渲染:${label}`);
fireEvent.pointerDown(selectButton, {
pointerId: 7,
button: 0,
clientX: 40,
clientY: 50,
});
fireEvent.pointerUp(selectButton, { pointerId: 7, clientX: 40, clientY: 50 });
fireEvent.click(card);
return card;
}
/** 资源卡的卡面元素;找不到说明这张卡没渲染。 */
function resourceCardOf(label: string) {
const card = getResourceSelectButton(label).closest('.game-resource-card');
if (!card) throw new Error(`资源卡未渲染:${label}`);
return card;
}
/** 切栏目页:点选会话要跨栏目活着,所以用例走用户那条路(收起资源 → 打开目标栏目)。 */
async function openResourceBookCategory(label: string) {
if (document.querySelector('[data-resource-book-view="child"]')) {
fireEvent.click(await screen.findByRole('button', { name: '收起资源' }));
await waitFor(() =>
expect(
document.querySelector('[data-resource-book-view="main"]'),
).not.toBeNull(),
);
}
fireEvent.click(await screen.findByRole('button', { name: `打开${label}` }));
await waitFor(() =>
expect(
document.querySelector('[data-resource-book-view="child"]'),
).not.toBeNull(),
);
}
/**
* 资源卡预览用的 IntersectionObserver stub。
*
@@ -986,4 +1046,369 @@ describe('版本级资源替换', () => {
).toBe('character');
expect(screen.getByRole('dialog', { name: '编辑素材标签' })).not.toBeNull();
});
/**
* 替换弹窗的「点选替换」入口(PRD §5.3):关掉弹窗、改在画布上点目标素材。
*
* 进入点选必须**卸载弹窗**:弹窗外壳是全屏遮罩,留着它画布上的卡根本点不到。
* 入口本身不改数据:候选读取是打开弹窗时的事,这里一次替换写入都不该有。
*/
it('点选替换:入口关掉弹窗进入点选态,提示条写明退出方式且不写盘', async () => {
const { invoke } = renderReplacementWorkbench();
const toolbar = await selectCardAndOpenToolbar('legacy.png');
fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' }));
const dialog = await screen.findByRole('dialog', {
name: '选择替换素材',
});
fireEvent.click(within(dialog).getByRole('button', { name: '点选替换' }));
expect(screen.queryByRole('dialog', { name: '选择替换素材' })).toBeNull();
const hint = pickHint();
if (!hint) throw new Error('点选提示条未渲染');
expect(hint.textContent).toContain('在画布上点选要替换成的素材');
// 空白处点击不退出这件事必须写在提示条上:用户按直觉点空白才不会以为点坏了。
expect(hint.textContent).toContain('点击空白处不会退出');
expect(within(hint).getByRole('button', { name: '取消' })).not.toBeNull();
expect(replacementWrites(invoke)).toHaveLength(0);
});
/**
* 点选态下合法目标直接提交:写入路径与弹窗确认**完全同一条**(同一个 `confirm` 函数),
* 载荷必须逐字一致,且成功后自动退出点选态。
*
* 同时钉住"一次性抑制"的收尾:退出点选后点**同一张**刚被点选过的卡,单击语义必须完好
* (换选中)。抑制残留就会在这一步被吞掉 —— 这是最容易被写错的一处。
*/
it('点选替换:点中合法候选提交一次且载荷一致,成功后自动退出点选态', async () => {
const { invoke, onManifestChange } = renderReplacementWorkbench();
const toolbar = await selectCardAndOpenToolbar('legacy.png');
fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' }));
const dialog = await screen.findByRole('dialog', {
name: '选择替换素材',
});
fireEvent.click(within(dialog).getByRole('button', { name: '点选替换' }));
act(() => {
observer?.triggerVisible();
});
await pickResourceCard('final.png');
await waitFor(() => expect(replacementWrites(invoke)).toHaveLength(1));
expect(replacementWrites(invoke)[0]?.[1]).toEqual({
input: {
projectPath: PROJECT_PATH,
expectedProjectId: PROJECT_ID,
expectedProjectRevision: EXPECTED_REVISION,
sourceVersionId: SOURCE_VERSION_ID,
sourceResourceId: 'asset-legacy',
replacementResourceId: 'asset-final',
},
});
await waitFor(() =>
expect(onManifestChange).toHaveBeenCalledWith(
PROJECT_PATH,
expect.objectContaining({ projectId: PROJECT_ID }),
expect.objectContaining({ revision: 6, source: 'asset-command' }),
),
);
// 点选那一下自带的 click 不得换选中:源素材仍是选中的那一个。
expect(
getResourceSelectButton('legacy.png').getAttribute('aria-pressed'),
).toBe('true');
await waitFor(() => expect(pickHint()).toBeNull());
// 退出点选后单击语义不变:点同一张卡照常选中自己,抑制没有残留。
const finalCard = (await findResourceSelectButton('final.png')).closest(
'.game-resource-card',
);
if (!finalCard) throw new Error('资源卡未渲染:final.png');
fireEvent.click(finalCard);
await waitFor(() =>
expect(
getResourceSelectButton('final.png').getAttribute('aria-pressed'),
).toBe('true'),
);
expect(
getResourceSelectButton('legacy.png').getAttribute('aria-pressed'),
).toBe('false');
});
/**
* 点选态下点非法目标:说明原因、**留在**点选态、零写入,且点空白既不退出也不清选中。
*
* 四种非法各点一次:源素材本身、不在权威候选里、分类不同的候选(跨栏目点)、
* manifest 里没有的身份(未登记附件)。判据全部来自同一条 `resolveResourceReplacementPick`
* 文案与候选弹窗同源。
*/
it('点选替换:非法目标不提交、留在点选态并说明原因,点空白不退出', async () => {
const { invoke } = renderReplacementWorkbench({
attachments: [
{
fileName: '草稿.png',
mediaType: 'image/png',
localPath: 'uploads/draft.png',
status: 'imported',
},
],
});
const toolbar = await selectCardAndOpenToolbar('legacy.png');
fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' }));
const dialog = await screen.findByRole('dialog', {
name: '选择替换素材',
});
fireEvent.click(within(dialog).getByRole('button', { name: '点选替换' }));
// ① 点的就是源素材本身。
await pickResourceCard('legacy.png');
expect(screen.getByRole('alert').textContent).toBe('替换素材与源素材相同');
expect(pickHint()).not.toBeNull();
expect(
getResourceSelectButton('legacy.png').getAttribute('aria-pressed'),
).toBe('true');
// 空白处左键:既不退出点选,也不清画布选中(清选中会连带收起选中工具条)。
const canvas = screen.getByLabelText('资源依赖视图');
// 指针捕获在 jsdom 里没有实现;这里补桩是为了让"这条按下到底走到哪一步"只由行为断言
// 判定,而不是被一个缺失的 DOM API 提前打断。
Object.defineProperties(canvas, {
setPointerCapture: { configurable: true, value: vi.fn() },
hasPointerCapture: { configurable: true, value: vi.fn(() => true) },
releasePointerCapture: { configurable: true, value: vi.fn() },
});
fireEvent.pointerDown(canvas, {
pointerId: 9,
button: 0,
clientX: 5,
clientY: 5,
});
expect(pickHint()).not.toBeNull();
expect(
getResourceSelectButton('legacy.png').getAttribute('aria-pressed'),
).toBe('true');
// ② 不在权威候选里的素材:候选读取之后才登记的资源不属于这次替换目标,一律不放行
// (判据是"候选里有没有",不在前端另算一遍兼容性)。
await pickResourceCard('late.png');
expect(screen.getByRole('alert').textContent).toBe(
'替换素材未登记或已被删除',
);
expect(pickHint()).not.toBeNull();
// ③ 分类不同的候选:目标在别的栏目,点选会话要跨栏目活着。
await openResourceBookCategory('场景与环境');
expect(pickHint()).not.toBeNull();
act(() => {
observer?.triggerVisible();
});
await pickResourceCard('scene.png');
expect(screen.getByRole('alert').textContent).toBe(
'替换素材不兼容:分类不同',
);
expect(pickHint()).not.toBeNull();
// 非法目标同样不换选中:点选态里卡片单击只用来点选。
expect(
getResourceSelectButton('scene.png').getAttribute('aria-pressed'),
).toBe('false');
// ④ 未登记资源:附件没有 manifest 身份,永远不是合法替换目标。
await openResourceBookCategory('待归类');
act(() => {
observer?.triggerVisible();
});
await pickResourceCard('草稿.png');
expect(screen.getByRole('alert').textContent).toBe(
'替换素材未登记或已被删除',
);
expect(pickHint()).not.toBeNull();
expect(replacementWrites(invoke)).toHaveLength(0);
});
/**
* 点选态的 Esc 只退出点选:资源画布自己的 Esc 挂在 window 上(清画布焦点 = 清选中 +
* 收浮层),提示条这条线必须在 document 上截断它 —— 否则用户按一次 Esc 会连正在替换的
* 选中一起丢。
*
* 判据分两层:window 上的监听器收不到这次 Escape(截断生效),且选中工具条仍在。
* 变异验证:去掉 `event.stopPropagation()`,本用例必须失败。
*/
it('点选替换:Esc 退出点选态且不清画布选中', async () => {
renderReplacementWorkbench();
const toolbar = await selectCardAndOpenToolbar('legacy.png');
fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' }));
const dialog = await screen.findByRole('dialog', {
name: '选择替换素材',
});
fireEvent.click(within(dialog).getByRole('button', { name: '点选替换' }));
expect(pickHint()).not.toBeNull();
const windowEsc = vi.fn();
window.addEventListener('keydown', windowEsc);
try {
fireEvent.keyDown(document, { key: 'Escape' });
} finally {
window.removeEventListener('keydown', windowEsc);
}
expect(windowEsc).not.toHaveBeenCalled();
expect(pickHint()).toBeNull();
// 选中没有跟着被清掉:源素材仍是选中的那一个。
expect(
getResourceSelectButton('legacy.png').getAttribute('aria-pressed'),
).toBe('true');
});
/**
* 替换血缘标注(PRD §5.3 / §7.8):替换成功后,被替换掉的源素材(它已经失去「当前版本」
* 光环,光环不再能说明关系)与替换素材必须互相标明关系,且都给出稳定 id 判据。
*
* 判据分两层:DOM 属性值=两个 manifest 资产 id(不是显示名),卡面文字=可读关系。
* 未参与替换的资源卡两个属性都不带、也没有血缘角标。
*
* 变异验证:只把血缘记进宿主状态、不接到卡面,本用例必须失败。
*/
it('替换成功后两张卡互相标注血缘:稳定 id 判据 + 可读文案', async () => {
const { invoke } = renderReplacementWorkbench();
const toolbar = await selectCardAndOpenToolbar('legacy.png');
fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' }));
const dialog = await screen.findByRole('dialog', {
name: '选择替换素材',
});
fireEvent.click(
within(dialog).getByRole('option', { name: '选择替换素材final.png' }),
);
fireEvent.click(
within(dialog).getByRole('button', { name: '确认选择替换素材' }),
);
await waitFor(() => expect(replacementWrites(invoke)).toHaveLength(1));
await waitFor(() => {
// 源素材 A:标「已被 B 替换」,判据值是 B 的稳定 id。
const sourceCard = resourceCardOf('legacy.png');
expect(sourceCard.getAttribute('data-resource-replaced-by')).toBe(
'asset-final',
);
expect(sourceCard.textContent).toContain('已被 final.png 替换');
expect(
sourceCard.querySelector('[data-resource-lineage="replaced-by"]'),
).not.toBeNull();
// 替换素材 B:标「替换自 A」,判据值是 A 的稳定 id。
const replacementCard = resourceCardOf('final.png');
expect(replacementCard.getAttribute('data-resource-replacement-of')).toBe(
'asset-legacy',
);
expect(replacementCard.textContent).toContain('替换自 legacy.png');
expect(
replacementCard.querySelector(
'[data-resource-lineage="replacement-of"]',
),
).not.toBeNull();
});
// 没参与替换的资源卡:两个属性都不带,也没有血缘角标。
const untouchedCard = resourceCardOf('late.png');
expect(untouchedCard.getAttribute('data-resource-replaced-by')).toBeNull();
expect(
untouchedCard.getAttribute('data-resource-replacement-of'),
).toBeNull();
expect(
untouchedCard.querySelector('.game-resource-card-lineage-badge'),
).toBeNull();
});
/**
* 同会话内再次替换:血缘按最新一次覆盖,只保留当前有效的那一对。
*
* 第二次把刚上位的 `final.png` 换成 `final.webp`:旧关系(legacy → final)必须整条消失
* (legacy 卡上不再有任何替换标注),新关系(final → final.webp)落到卡上。
*
* 变异验证:把血缘累加而不是覆盖(例如存成数组),旧标注会留在 legacy 卡上,本用例失败。
*/
it('再次替换覆盖上一条血缘:旧标注消失,只保留当前有效关系', async () => {
const { invoke } = renderReplacementWorkbench({
// 写入桩按本次请求回填血缘,才能造出"第二次换了别人"这一场景。
replacementWrite: async (args) => {
const input = (args?.input ?? {}) as {
sourceResourceId?: string;
replacementResourceId?: string;
};
return {
versionId: SOURCE_VERSION_ID,
committedProjectRevision: 6,
replacement: {
versionId: SOURCE_VERSION_ID,
sourceResourceId: input.sourceResourceId,
replacementResourceId: input.replacementResourceId,
compatibility: {
categoryEqual: true,
subtypeEqual: true,
sizeSpecEqual: true,
},
warning: null,
},
};
},
});
// 第一次:legacy → final。
const legacyToolbar = await selectCardAndOpenToolbar('legacy.png');
fireEvent.click(
within(legacyToolbar).getByRole('button', { name: '替换素材' }),
);
let dialog = await screen.findByRole('dialog', {
name: '选择替换素材',
});
fireEvent.click(
within(dialog).getByRole('option', { name: '选择替换素材final.png' }),
);
fireEvent.click(
within(dialog).getByRole('button', { name: '确认选择替换素材' }),
);
await waitFor(() =>
expect(
resourceCardOf('legacy.png').getAttribute('data-resource-replaced-by'),
).toBe('asset-final'),
);
// 第二次:final → final.webp(同一个工作台会话内)。
const finalToolbar = await selectCardAndOpenToolbar('final.png');
fireEvent.click(
within(finalToolbar).getByRole('button', { name: '替换素材' }),
);
dialog = await screen.findByRole('dialog', { name: '选择替换素材' });
fireEvent.click(
within(dialog).getByRole('option', { name: '选择替换素材final.webp' }),
);
fireEvent.click(
within(dialog).getByRole('button', { name: '确认选择替换素材' }),
);
await waitFor(() => expect(replacementWrites(invoke)).toHaveLength(2));
await waitFor(() => {
// 新关系:final 被 final.webp 替换。
expect(
resourceCardOf('final.png').getAttribute('data-resource-replaced-by'),
).toBe('asset-webp');
expect(resourceCardOf('final.png').textContent).toContain(
'已被 final.webp 替换',
);
expect(
resourceCardOf('final.webp').getAttribute(
'data-resource-replacement-of',
),
).toBe('asset-final');
});
// 旧关系整条消失:legacy 卡上不再有任何替换标注。
const legacyCard = resourceCardOf('legacy.png');
expect(legacyCard.getAttribute('data-resource-replaced-by')).toBeNull();
expect(legacyCard.getAttribute('data-resource-replacement-of')).toBeNull();
expect(
legacyCard.querySelector('.game-resource-card-lineage-badge'),
).toBeNull();
});
});

Some files were not shown because too many files have changed in this diff Show More