AGC 画布验收问题修复与栏目画布底部工具栏
- 合并资源画布命名筛选与条件筛选为单一筛选浮层,Dock 只留放大镜入口 - 修复「编辑素材标签」面板标签多时不可见且不可滚:加高度上界与标签区独立滚动 - 替换素材新增点选替换模式,并在资源卡标注会话内替换血缘 - 新增栏目画布底部工具栏,按功能画布分流图片类生成、音频生成与上传入口 - 新增 Tauri IPC generate_local_project_asset,收口图片类无源生成的 kind 与提示词目录 - 同步 PRD、AGC 验收用例、决策日志、踩坑记录与待解决事项文档
This commit is contained in:
@@ -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();
|
||||
|
||||
+210
@@ -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>
|
||||
);
|
||||
}
|
||||
+259
@@ -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>
|
||||
);
|
||||
}
|
||||
+46
-21
@@ -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>
|
||||
|
||||
+484
@@ -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;
|
||||
}
|
||||
|
||||
+116
@@ -160,6 +160,60 @@ export function projectResourcesByManifestAssetId(
|
||||
return resourceByAssetId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 本次会话的替换血缘:源素材 A → 替换素材 B,两端都是 **manifest 资产 id**
|
||||
* (与替换写入载荷同一个 id 空间,不是展示层投影 id,也不是显示名)。
|
||||
*/
|
||||
export type ProjectResourceReplacementLineage = {
|
||||
sourceResourceId: string;
|
||||
replacementResourceId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 一张资源卡上的血缘标注:挂在哪张卡由标注表的 key 决定,这里只说"对面是谁"。
|
||||
*
|
||||
* `resourceId` 是对面那张资源的稳定 id(manifest 资产 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 };
|
||||
}
|
||||
|
||||
@@ -6186,36 +6186,7 @@ iframe.preview-frame {
|
||||
}
|
||||
|
||||
/*
|
||||
* 资源搜索浮层。
|
||||
*
|
||||
* 搜索条不再常驻:原来它和筛选条一起在管理区顶部占一条独立排布带(管理区 padding-top
|
||||
* 加画本场景下移),栏目标题栏被挤到那条带下面仍然和搜索框叠在同一条水平带上。现在常态
|
||||
* 只有右下角 Dock 里的搜索按钮,Ctrl/Cmd+F(macOS 为 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
Reference in New Issue
Block a user