Merge remote-tracking branch 'origin/master' into opt/design_agent
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 5m18s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 5m22s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 5m24s
Project CI / Backend tests (pull_request) Failing after 12s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 5m45s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m39s
Project CI / Repository checks (pull_request) Failing after 19s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m1s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m10s
Project CI / Frontend tests (pull_request) Successful in 4m50s
Project CI / Native shell tests (pull_request) Successful in 7m19s

This commit is contained in:
2026-09-14 21:02:15 +08:00
40 changed files with 8890 additions and 527 deletions
@@ -113,6 +113,11 @@ const allowedUncalledTauriCommands = [
'chat_with_game_creator_agent',
'check_ui_editor_font_glyph_coverage',
'create_ui_design_resource',
// 图片类生成的同步变体:GUI 已改为 `start_local_project_asset_generation` + 项目内任务账本
// (提交即返回、后台生成)。这条命令**没有生产调用方**,只有 Rust 集成测试
// `src/tests/project.rs`)与 `commands.rs` 单测在调;待后续批次删除,或改为转调
// `start_local_project_asset_generation`。
'generate_local_project_asset',
'open_game_creator_launcher_window',
'open_game_creator_workspace_window',
'read_direct_project_conversation',
File diff suppressed because it is too large Load Diff
@@ -1074,6 +1074,102 @@ pub(in crate::agent) fn remove_platform_art_generation_runtime_state_at(
}
}
/// 一次性兼容:升级前 standalone 槽身份只由 `{outputPath, requireSlices}` 派生,
/// 同一项目所有图片类生成共用一个槽;升级后槽身份按精确动作派生,路径随之变化。
///
/// 若旧槽路径上的账本仍然属于本次精确动作(`agentId` 与 `actionFingerprint` 都与
/// 当前上下文一致),就在项目写锁内把它迁移到新身份路径:保留原 `idempotencyKey`
/// 与 `operationId`,避免同一精确动作在升级后二次 POST 计费。旧账本属于其他动作时
/// 原样保留(不迁移、不删除、不阻塞),由对应动作自己的请求迁移。
///
/// 返回 `Ok(false)` 表示没有需要迁移的旧账本。任何身份无法安全解释的情形都失败关闭。
pub(super) fn adopt_legacy_standalone_platform_art_generation_runtime_state_at(
root: &Path,
context: &PlatformArtGenerationRuntimeContext,
legacy_run_id: &str,
) -> Result<bool, String> {
if !is_standalone_platform_art_generation_runtime_context(context)
|| legacy_run_id == context.run_id
|| !is_lowercase_sha256(legacy_run_id.strip_prefix("slot-").unwrap_or_default())
{
return Ok(false);
}
// 与账本创建互斥:迁移必须在同一把项目写锁内完成,否则两个调用可能同时把同一份
// 旧账本迁移到新路径,或与新建账本互相覆盖。
let _claim_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
root,
"canvas.asset_generate.runtime.claim",
)?;
if game_creator_agent_runtime_external_generation_exists(
root,
&context.agent_id,
&context.run_id,
) {
// 新身份账本已经存在:旧账本不属于本次动作的权威状态,保持两边各自的身份。
return Ok(false);
}
let legacy_relative_path =
platform_art_generation_runtime_relative_path(&context.agent_id, legacy_run_id);
let Some(legacy_state) =
read_agent_runtime_json_sidecar_with_max_bytes::<PlatformArtGenerationRuntimeState>(
root,
&legacy_relative_path,
"External Editor 生成账本",
PLATFORM_ART_GENERATION_RUNTIME_MAX_BYTES,
)?
else {
return Ok(false);
};
if legacy_state.agent_id != context.agent_id
|| legacy_state.run_id != legacy_run_id
|| legacy_state.action_fingerprint != context.action_fingerprint
{
// 旧槽里是另一个精确动作的账本:它仍归那个动作所有,本次调用不得消费、改写或删除它。
return Ok(false);
}
let legacy_identity = format!("{}:{legacy_run_id}", context.agent_id);
let legacy_context = PlatformArtGenerationRuntimeContext {
task_id: legacy_identity.clone(),
session_id: legacy_identity.clone(),
run_id: legacy_run_id.to_string(),
action_id: legacy_identity,
..context.clone()
};
let Some(mut migrated) = read_platform_art_generation_runtime_state(root, &legacy_context)?
else {
return Ok(false);
};
migrated.run_id = context.run_id.clone();
migrated.task_id = context.task_id.clone();
migrated.session_id = context.session_id.clone();
migrated.action_id = context.action_id.clone();
migrated.updated_at = unix_timestamp();
write_platform_art_generation_runtime_state(root, &migrated)?;
remove_platform_art_generation_runtime_state_at(root, &context.agent_id, legacy_run_id)?;
Ok(true)
}
#[cfg(test)]
pub(super) fn platform_art_generation_runtime_operation_id_for_test(
state: &PlatformArtGenerationRuntimeState,
) -> Option<&str> {
state.operation_id.as_deref()
}
#[cfg(test)]
pub(super) fn platform_art_generation_runtime_run_id_for_test(
state: &PlatformArtGenerationRuntimeState,
) -> &str {
&state.run_id
}
#[cfg(test)]
pub(super) fn platform_art_generation_runtime_action_fingerprint_for_test(
state: &PlatformArtGenerationRuntimeState,
) -> &str {
&state.action_fingerprint
}
#[cfg(test)]
pub(crate) fn write_platform_art_generation_runtime_accepted_for_test(
root: &Path,
File diff suppressed because it is too large Load Diff
@@ -26,6 +26,12 @@ pub(crate) struct LocalProjectImagePreview {
pub(crate) byte_len: u64,
pub(crate) pixel_width: u32,
pub(crate) pixel_height: u32,
/// 这张图是否**真的**带 alpha 通道,判据见 [`detect_raster_image_has_alpha`]。
///
/// 资源卡只按它决定要不要铺棋盘格底:`data-preview-kind` 只说明「走图片预览分支」,
/// 与这张图有没有透明像素无关 —— 无条件铺底会让「AI 把棋盘格画进像素里」的不透明图
/// 与卡面棋盘格叠成两套,验收时无法区分「真透明底」与「假棋盘格」。
pub(crate) has_alpha: bool,
pub(crate) data_url: String,
}
@@ -102,12 +108,17 @@ pub(crate) fn load_local_project_image_preview_with_cancellation(
false,
)?;
cancellation.check()?;
// 头部级 alpha 判据:只读签名与头部标志(PNG 还会按 chunk 头跳过数据体找 `tRNS`),
// 不做熵解码、不做逐像素扫描,成本不随像素数增长,因此大图与「AI 把棋盘格画进图里」
// 的不透明图都不会因此变慢。
let has_alpha = detect_raster_image_has_alpha(&image.bytes, image.media_type);
Ok(LocalProjectImagePreview {
path: image.relative_path.clone(),
media_type: image.media_type.to_string(),
byte_len: image.byte_len,
pixel_width: image.pixel_width,
pixel_height: image.pixel_height,
has_alpha,
data_url: image.data_url_with_cancellation(cancellation)?,
})
}
@@ -420,6 +431,84 @@ fn detect_raster_image_dimensions(bytes: &[u8], media_type: &str) -> Option<(u32
}
}
/// 头部级 alpha 判据:这张图**有没有 alpha 通道 / 透明像素**,只看签名与头部标志
/// PNG 还会按 chunk 头跳过数据体找 `tRNS`)。
///
/// 为什么必须是头部级而不是像素级:资源卡预览按 8 MiB / 8192 边长 / 3270 万像素上限读取,
/// 逐像素扫描意味着对每张卡都做一次全量 RGBA 解码(真机单栏 51 张、单张均值 591 KB),
/// 成本与「卡面装饰底」的收益完全不成比例;而 alpha 是否存在在容器头部就是确定信息。
///
/// 判据(保守方向一致:判不出就当作不透明,宁可不铺棋盘格):
/// - PNG:颜色类型 4(灰度 + alpha/ 6(真彩 + alpha);0 / 2 / 3 本身没有 alpha 通道,
/// 但可以用 `tRNS` 声明透明色,因此还要在第一个 `IDAT` 之前找一次 `tRNS`
/// - WebP:扩展格式 `VP8X` 的 flags 第 4 位、无损 `VP8L` 位流头的 `alpha_is_used` 位;
/// 简单有损 `VP8 ` 不带 alpha 通道(带 alpha 的有损 WebP 一定走 `VP8X` + `ALPH`);
/// - JPEG:没有 alpha 通道,恒不透明(也绝不为了判 alpha 去扫它的段)。
fn detect_raster_image_has_alpha(bytes: &[u8], media_type: &str) -> bool {
match media_type {
"image/png" => detect_png_has_alpha(bytes),
"image/webp" => detect_webp_has_alpha(bytes),
_ => false,
}
}
fn detect_png_has_alpha(bytes: &[u8]) -> bool {
// 签名 8 字节 + IHDR 长度 4 + "IHDR" 4 + 宽 4 + 高 4 + 位深 1 + 颜色类型 1 = 26。
if bytes.len() < 26 || &bytes[12..16] != b"IHDR" {
return false;
}
if matches!(bytes[25], 4 | 6) {
return true;
}
png_has_transparency_chunk(bytes)
}
/// 按 chunk 头前进并查找 `tRNS`:只读 8 字节 chunk 头并按长度跳过数据体,不做 zlib 解压。
fn png_has_transparency_chunk(bytes: &[u8]) -> bool {
let mut offset = 8usize;
loop {
let Some(header_end) = offset.checked_add(8) else {
return false;
};
if header_end > bytes.len() {
return false;
}
let chunk_type = &bytes[offset + 4..header_end];
// `tRNS` 必须出现在第一个 `IDAT` 之前;碰到 `IDAT` / `IEND` 就没有再往下扫的意义。
if chunk_type == b"tRNS" {
return true;
}
if chunk_type == b"IDAT" || chunk_type == b"IEND" {
return false;
}
let chunk_len =
u32::from_be_bytes(bytes[offset..offset + 4].try_into().unwrap_or([0_u8; 4])) as usize;
let Some(next) = header_end
.checked_add(chunk_len)
.and_then(|value| value.checked_add(4))
else {
return false;
};
if next <= offset || next > bytes.len() {
return false;
}
offset = next;
}
}
fn detect_webp_has_alpha(bytes: &[u8]) -> bool {
if bytes.len() < 16 || &bytes[..4] != b"RIFF" || &bytes[8..12] != b"WEBP" {
return false;
}
match &bytes[12..16] {
// `VP8X` 的 flags 第 4 位(0x10)就是 alpha 标志(第 20 字节)。
b"VP8X" => bytes.get(20).is_some_and(|flags| flags & 0x10 != 0),
// `VP8L` 位流头第 28 位是 `alpha_is_used`,落在第 25 个字节(下标 24)的 0x10 位。
b"VP8L" => bytes.len() >= 25 && bytes[24] & 0x10 != 0,
_ => false,
}
}
#[derive(Clone, Copy)]
enum TiffByteOrder {
LittleEndian,
@@ -745,6 +834,74 @@ mod tests {
.expect("valid 1x1 png")
}
/// PNG 的「签名 + IHDR」头。判据只读这一段的位深 / 颜色类型,因此后续 chunk 由用例自行拼。
fn png_header(color_type: u8) -> Vec<u8> {
png_header_with_size(color_type, 1, 1)
}
fn png_header_with_size(color_type: u8, width: u32, height: u32) -> Vec<u8> {
let mut bytes = b"\x89PNG\r\n\x1a\n".to_vec();
let mut ihdr = Vec::new();
ihdr.extend_from_slice(&width.to_be_bytes());
ihdr.extend_from_slice(&height.to_be_bytes());
ihdr.push(8);
ihdr.push(color_type);
ihdr.extend_from_slice(&[0, 0, 0]);
push_png_chunk(&mut bytes, b"IHDR", &ihdr);
bytes
}
/// 追加一个结构合法(长度、类型、CRC 位置正确)但数据体可以是任意字节的 PNG chunk。
/// alpha 判据不消费 CRC,因此这里填零;正因数据体不必是合法 deflate 流,它同时能证明
/// 判据没有解码像素。
fn push_png_chunk(bytes: &mut Vec<u8>, kind: &[u8; 4], data: &[u8]) {
bytes.extend_from_slice(
&u32::try_from(data.len())
.expect("chunk length")
.to_be_bytes(),
);
bytes.extend_from_slice(kind);
bytes.extend_from_slice(data);
bytes.extend_from_slice(&[0, 0, 0, 0]);
}
/// 扩展格式 WebP`VP8X`):`flags` 第 4 位(0x10)是 alpha 标志。
fn webp_vp8x(flags: u8) -> Vec<u8> {
let mut bytes = b"RIFF".to_vec();
bytes.extend_from_slice(&0_u32.to_le_bytes());
bytes.extend_from_slice(b"WEBP");
bytes.extend_from_slice(b"VP8X");
bytes.extend_from_slice(&10_u32.to_le_bytes());
bytes.push(flags);
bytes.extend_from_slice(&[0, 0, 0]);
bytes.extend_from_slice(&[0, 0, 0]);
bytes.extend_from_slice(&[0, 0, 0]);
bytes
}
/// 无损 WebP`VP8L`):位流头第 28 位是 `alpha_is_used`,落在下标 24 的 0x10 位。
fn webp_vp8l(has_alpha: bool) -> Vec<u8> {
let mut bytes = b"RIFF".to_vec();
bytes.extend_from_slice(&0_u32.to_le_bytes());
bytes.extend_from_slice(b"WEBP");
bytes.extend_from_slice(b"VP8L");
bytes.extend_from_slice(&5_u32.to_le_bytes());
bytes.push(0x2f);
bytes.extend_from_slice(&[0, 0, 0, if has_alpha { 0x10 } else { 0 }]);
bytes
}
/// 简单有损 WebP`VP8 `):容器上没有 alpha 通道;带 alpha 的有损 WebP 一定走
/// `VP8X` 扩展格式(+ `ALPH` chunk)。
fn webp_vp8_simple() -> Vec<u8> {
let mut bytes = b"RIFF".to_vec();
bytes.extend_from_slice(&0_u32.to_le_bytes());
bytes.extend_from_slice(b"WEBP");
bytes.extend_from_slice(b"VP8 ");
bytes.extend_from_slice(&0_u32.to_le_bytes());
bytes
}
fn jpeg_bytes(width: u16, height: u16, app1_payload: Option<&[u8]>) -> Vec<u8> {
let mut bytes = vec![0xff, 0xd8];
if let Some(payload) = app1_payload {
@@ -840,6 +997,138 @@ mod tests {
assert_eq!(preview.media_type, "image/png");
assert_eq!(preview.byte_len, png_bytes().len() as u64);
assert!(preview.data_url.starts_with("data:image/png;base64,"));
// 这份 fixture 是 PNG colorType 4(灰度 + alpha),因此预览必须报「有 alpha」——
// 资源卡据此才铺棋盘格底。
assert!(preview.has_alpha);
}
#[test]
fn png_alpha_follows_color_type_and_transparency_chunk() {
let color_type_alpha = |color_type: u8| {
let mut bytes = png_header(color_type);
push_png_chunk(&mut bytes, b"IDAT", &[0, 0, 0]);
push_png_chunk(&mut bytes, b"IEND", &[]);
detect_raster_image_has_alpha(&bytes, "image/png")
};
// 颜色类型 4(灰度 + alpha)与 6(真彩 + alpha)才带 alpha 通道。
assert!(color_type_alpha(4), "colorType 4 应判为有 alpha");
assert!(color_type_alpha(6), "PNG-32colorType 6)应判为有 alpha");
// 0 / 2 / 3 本身没有 alpha 通道:这是「AI 把棋盘格画进像素里」那张不透明 PNG 的形状。
assert!(!color_type_alpha(0), "colorType 0 不应判为有 alpha");
assert!(
!color_type_alpha(2),
"PNG-24colorType 2)不应判为有 alpha"
);
assert!(
!color_type_alpha(3),
"colorType 3 无 tRNS 时不应判为有 alpha"
);
// 未定义的颜色类型失败关闭为「不透明」,不能把坏文件当成透明。
assert!(!color_type_alpha(7), "未定义 colorType 不应判为有 alpha");
// 灰度 / 真彩 / 调色板可以靠 tRNS 声明透明色,那也是真透明 PNG,必须铺棋盘格。
for color_type in [0_u8, 2, 3] {
let mut bytes = png_header(color_type);
push_png_chunk(&mut bytes, b"tRNS", &[0]);
push_png_chunk(&mut bytes, b"IDAT", &[0, 0, 0]);
push_png_chunk(&mut bytes, b"IEND", &[]);
assert!(
detect_raster_image_has_alpha(&bytes, "image/png"),
"colorType {color_type} + tRNS 也是真透明 PNG"
);
}
// tRNS 规范上必须在 IDAT 之前:出现在之后不再继续扫 chunk(成本有界)。
let mut late_trns = png_header(3);
push_png_chunk(&mut late_trns, b"IDAT", &[0, 0, 0]);
push_png_chunk(&mut late_trns, b"tRNS", &[0]);
push_png_chunk(&mut late_trns, b"IEND", &[]);
assert!(!detect_raster_image_has_alpha(&late_trns, "image/png"));
}
#[test]
fn jpeg_and_webp_alpha_follow_container_flags() {
// JPEG 没有 alpha 通道:恒不透明(也绝不为了判 alpha 去解码扫描段)。
assert!(!detect_raster_image_has_alpha(
&jpeg_bytes(40, 20, None),
"image/jpeg"
));
// 扩展格式 VP8X 的 flags 第 4 位就是 alpha 标志。
assert!(detect_raster_image_has_alpha(
&webp_vp8x(0x10),
"image/webp"
));
assert!(!detect_raster_image_has_alpha(
&webp_vp8x(0x00),
"image/webp"
));
// 只有 ICC0x20/ EXIF0x08)等其它标志时不是 alpha。
assert!(!detect_raster_image_has_alpha(
&webp_vp8x(0x28),
"image/webp"
));
// 无损 VP8L 的 alpha_is_used 位。
assert!(detect_raster_image_has_alpha(
&webp_vp8l(true),
"image/webp"
));
assert!(!detect_raster_image_has_alpha(
&webp_vp8l(false),
"image/webp"
));
// 简单有损格式不带 alpha 通道。
assert!(!detect_raster_image_has_alpha(
&webp_vp8_simple(),
"image/webp"
));
// 头部被截断时失败关闭为「不透明」,且不得 panic。
let truncated_webp = webp_vp8x(0x10);
assert!(!detect_raster_image_has_alpha(
&truncated_webp[..18],
"image/webp"
));
let truncated_png = png_header(6);
assert!(!detect_raster_image_has_alpha(
&truncated_png[..20],
"image/png"
));
}
#[test]
fn alpha_judgement_never_decodes_pixels() {
// 4096×4096 的 PNG-32:真按像素解码要 64 MiB 缓冲,而下面的 IDAT 数据体不是合法
// deflate 流(全零),任何真正的解码器都会失败。判据只看头部,所以这里必须成功,
// 并且仍然判 has_alpha=true —— 这就是「不做全量解码」的可执行证据。
let root = tempfile::tempdir().expect("temp root");
fs::create_dir_all(root.path().join("assets/ui")).expect("asset dir");
let mut bytes = png_header_with_size(6, 4_096, 4_096);
push_png_chunk(&mut bytes, b"IDAT", &[0x00, 0x00, 0x00, 0x00]);
push_png_chunk(&mut bytes, b"IEND", &[]);
fs::write(root.path().join("assets/ui/large.png"), &bytes).expect("large image");
let preview = load_local_project_image_preview(root.path(), "assets/ui/large.png")
.expect("header-only preview");
assert_eq!(preview.pixel_width, 4_096);
assert_eq!(preview.byte_len, bytes.len() as u64);
assert!(preview.has_alpha);
}
#[test]
fn image_preview_serializes_alpha_flag_for_the_shell() {
let root = tempfile::tempdir().expect("temp root");
fs::create_dir_all(root.path().join("assets/ui")).expect("asset dir");
fs::write(root.path().join("assets/ui/prototype.png"), png_bytes()).expect("image");
let preview = load_local_project_image_preview(root.path(), "assets/ui/prototype.png")
.expect("load project preview");
// 前端按 camelCase 读 `hasAlpha``ProjectResourceCardPreviewTransportPayload`);
// 字段名或大小写改了会让资源卡永远退回纯色底,所以这里钉住 IPC 契约。
let serialized = serde_json::to_value(&preview).expect("serialize preview");
assert_eq!(serialized["hasAlpha"], serde_json::json!(true));
}
#[test]
@@ -244,6 +244,7 @@ macro_rules! app_log {
// 用 #[cfg] 编译期门控:仅开发(debug)且非测试构建编入;生产 release 与 cargo test 下整体剔除。
mod agent;
mod agent_native_tools;
mod asset_generation_tasks;
mod assets;
mod browser;
mod builtin_plugins;
@@ -289,6 +290,7 @@ mod windows;
use agent::*;
use agent_native_tools::*;
use asset_generation_tasks::*;
use assets::*;
use browser::*;
use cli::*;
@@ -2736,6 +2738,8 @@ fn main() {
ensure_ui_design_resource_for_prototype,
generate_platform_art_asset,
generate_local_project_asset,
start_local_project_asset_generation,
list_local_project_asset_generations,
open_canvas_project,
get_game_creation_agent_capabilities,
get_limited_local_commands,
@@ -0,0 +1,130 @@
/*
* 「设置素材类型」面板的弹窗骨架、纵向单选列表与信息浮层的类型入口。
*
* 单独一个文件而不是塞进 styles.css:与「编辑素材标签」面板当初同样的理由 ——
* 这份样式只服务本次的素材类型入口,与工作台其它区块没有共享选择器,独立文件让改动
* 边界更清楚,也不会与同一时段其它 Agent 在 styles.css 里的编辑互相踩。
*
* 骨架沿用「编辑素材标签」那套三段式契约(`auto / minmax(0, 1fr)`):标题常驻、
* 中间一行可压缩、`max-height` 兜住上界。类型面板没有底部按钮,所以只有两行。
*/
.game-resource-type-dialog {
width: min(480px, 100%);
max-height: min(720px, calc(100dvh - 40px));
grid-template-rows: auto minmax(0, 1fr);
}
/*
* body 分三段:提示 / 选项列表 / 错误提示。
*
* `min-height: 0` 是网格项能被 `1fr` 压缩的前提;**滚动不在这里**——滚动权交给选项列表
* (见下),否则往下滚时素材名和错误提示会跟着跑掉,用户看不到"改的是哪件素材、为什么失败"。
*/
.game-resource-type-body {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
gap: 10px;
min-height: 0;
}
/*
* 类型选项之上的一句短提示。只说这一屏要选什么,不写规则说明或开发解释。
*/
.game-resource-type-hint {
margin: 0;
color: var(--platform-text-base);
font-size: 12px;
}
/*
* 纵向单选列表(`role="radiogroup"`):**一行一个选项**。
*
* 之前 6 项横排在一条里(`PlatformSegmentedTabs` 的 3~6 列网格),窄屏上互相叠字读不出来。
* 单列网格 + 按行流向是"每项一行、互不重叠"的充分条件:只声明一列,6 个子元素必然上下排 6 行,
* 不存在两项挤一行的可能。选项多时列表自己滚(`max-height` + `overflow-y: auto`),
* 面板不会被撑高。移动端优先:360px 宽的窄屏同样是这一套声明(没有按宽度改列数的媒体查询)。
*/
.game-resource-type-options {
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-auto-flow: row;
align-content: start;
gap: 6px;
min-height: 0;
max-height: min(320px, 40dvh);
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
/*
* 单个选项行:复用共享的 `PlatformNavigableListItem` 骨架(w-full / flex / text-left /
* 圆角 / 悬停 / 焦点环都由它给),这里只补"整行可点 + 明确选中态"的表现。
*
* `width/min-width` 显式写出来,不依赖共享件里的 Tailwind `w-full`:这一行是不是满宽
* 决定了"一项一行"能不能成立,不能挂在另一份文件的工具类上。
* `min-height: 44px` 是移动端点击热区下限;`overflow-wrap` 让长选项名在窄屏换行而不是溢出。
*/
.game-resource-type-option {
width: 100%;
min-width: 0;
min-height: 44px;
padding: 8px 12px;
border: 1px solid var(--platform-subpanel-border);
background: rgb(255 255 255 / 62%);
color: var(--platform-text-base);
font-size: 13px;
font-weight: 600;
overflow-wrap: anywhere;
}
.game-resource-type-option:hover:not(:disabled) {
border-color: var(--platform-surface-hover-border);
}
/*
* 选中态完全由 `aria-checked="true"` 驱动:视觉与读屏读的是同一个属性,不会各说一套。
*
* 选择器显式提权到 (0,3,0) 以上:共享列表行自带的 `.platform-navigable-list-item:hover:not(:disabled)`
* 也是 (0,3,0),只写 `.game-resource-type-option[aria-checked='true']`(0,2,0))会在悬停时
* 被它的底色顶掉;带 `:hover:not(:disabled)` 的那条 (0,5,0) 保证选中行悬停时也不变色。
*/
.game-resource-type-options .game-resource-type-option[aria-checked='true'],
.game-resource-type-options
.game-resource-type-option[aria-checked='true']:hover:not(:disabled) {
border-color: var(--platform-warm-border);
background: var(--platform-warm-bg);
color: var(--platform-text-strong);
}
.game-resource-type-error {
margin: 0;
color: #b3261e;
font-size: 11px;
}
/*
* 第二入口:信息浮层「分类」行右侧的入口按钮。
*
* 放在 `dd` **外面**:信息字段的读取口径(`dt` / `dd` 文本逐行比对)在两处共用,
* 把按钮塞进 `dd` 会让分类值变成「角色与对象设置」这类拼接文案。
*/
.game-resource-info-field-action {
align-self: start;
margin-left: auto;
padding: 0 6px;
border: 1px solid var(--platform-subpanel-border);
border-radius: 8px;
background: transparent;
color: var(--platform-text-base);
font-size: 11px;
line-height: 20px;
cursor: pointer;
}
.game-resource-info-field-action:hover,
.game-resource-info-field-action:focus-visible {
border-color: var(--platform-surface-hover-border);
background: var(--platform-warm-bg);
color: var(--platform-text-strong);
}
@@ -22,24 +22,47 @@ export type ResourceCanvasAssetGenerationSubmitInput = {
imageSize: string;
};
export type ResourceCanvasAssetGenerationPanelViewProps = {
action: ResourceCanvasAssetToolAction;
onSubmit: (input: ResourceCanvasAssetGenerationSubmitInput) => Promise<void>;
onClose: () => void;
/** 提交面板的草稿:点击即关闭之后,只有「即时失败」重开时才需要把这份草稿带回来。 */
export type ResourceCanvasAssetGenerationPanelDraft = {
prompt: string;
assetName: string;
aspectRatio: string;
imageSize: string;
};
function assetGenerationErrorMessage(error: unknown) {
if (typeof error === 'string' && error.trim()) return error;
if (error instanceof Error && error.message) return error.message;
return '生成素材失败';
}
export type ResourceCanvasAssetGenerationPanelViewProps = {
action: ResourceCanvasAssetToolAction;
/**
* 上一次「点击瞬间就失败」带回来的草稿。
*
* 面板点击即关闭,草稿只活在组件里;重开时由宿主把它传回来,用户改完就能重试。
*/
draft?: ResourceCanvasAssetGenerationPanelDraft;
/** 上一次即时失败的原因;重开时直接以 `role="alert"` 呈现。 */
error?: string | null;
/**
* 提交回调:**同步返回**,面板不等它的结果。
*
* 受理失败要不要把面板带回来由宿主决定(只有「从未被后端受理」的即时失败才重开),
* 面板自己不持有任何在途状态。
*/
onSubmit: (input: ResourceCanvasAssetGenerationSubmitInput) => void;
onClose: () => void;
};
/**
* 栏目画布底部工具栏的图片类生成浮层(生成图片 / 生成规范 / 生成角色形象 / 生成图标素材 /
* 生成 UI 设计图共用)。
*
* 形态是独立弹层(`ThemedModal`,与既有「生成素材」面板同一套宿主 chrome),不在任何现有
* 面板下面追加内容;提交链路与结果定位由宿主 `index.tsx` 负责,面板只持有草稿与失败状态
* 面板下面追加内容;提交链路与结果定位由宿主 `index.tsx` 负责。
*
* **点击「生成」即关闭面板**:不等 IPC、不等排队、不等生成结束,用户立刻回到画布。所以面板里
* 不存在「排队中。」「正在生成。」「提交中…」这类阶段文案——阶段文案的唯一去处是画布上的
* 「生成任务」侧栏与工具栏提示条。关闭**不等于**取消:任务照常在后台跑完并把结果写回项目。
*
* 只有「点击瞬间就失败」(校验 / 权限拒绝 / IPC 立即报错,即后端从未受理)时,宿主才会带着
* `draft` 与 `error` 把面板重新打开,用户可以直接改后重试。
*
* 比例 / 尺寸选项来自网页端美术画布的纯模型(`ImageCanvasGenerationModel.ts`)经本地 IPC
* 白名单收窄后的子集:网页端面板会渲染 `4:3`,而本地通道明确拒绝它,照搬就是一个点了必
@@ -48,57 +71,52 @@ function assetGenerationErrorMessage(error: unknown) {
*/
export function ResourceCanvasAssetGenerationPanelView({
action,
draft,
error: initialError,
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);
const [prompt, setPrompt] = useState(draft?.prompt ?? '');
const [assetName, setAssetName] = useState(
draft?.assetName ?? action.assetName,
);
const [aspectRatio, setAspectRatio] = useState(
draft?.aspectRatio ?? action.aspectRatio,
);
const [imageSize, setImageSize] = useState(
draft?.imageSize ?? action.imageSize,
);
const [error, setError] = useState<string | null>(initialError ?? 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;
const canSubmit = prompt.trim().length > 0 && assetName.trim().length > 0;
async function submit(event: FormEvent<HTMLFormElement>) {
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const normalizedPrompt = prompt.trim();
const normalizedAssetName = assetName.trim();
if (!normalizedPrompt || !normalizedAssetName || submitting) {
if (!normalizedPrompt || !normalizedAssetName) {
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);
}
// 点击即关闭:不等 IPC、不等排队、不等生成结束。失败要不要把面板带回来由宿主决定
// (只有「从未被后端受理」的即时失败才重开并带回草稿),面板不持有在途状态。
onSubmit({
kind: action.assetKind,
prompt: normalizedPrompt,
assetName: normalizedAssetName,
aspectRatio,
imageSize,
});
onClose();
}
return (
<ThemedModal
open
ariaLabel={action.label}
closeOnBackdrop={!submitting}
closeOnEscape={!submitting}
onClose={() => {
if (!submitting) {
onClose();
}
}}
onClose={onClose}
panelClassName="game-approval-dialog game-resource-generation-dialog"
>
<header>
@@ -108,7 +126,6 @@ export function ResourceCanvasAssetGenerationPanelView({
<button
type="button"
aria-label={`关闭${action.label}`}
disabled={submitting}
onClick={onClose}
>
<X size={16} aria-hidden="true" />
@@ -120,7 +137,6 @@ export function ResourceCanvasAssetGenerationPanelView({
<PlatformTextField
aria-label="素材名称"
maxLength={120}
disabled={submitting}
value={assetName}
onChange={(event) => setAssetName(event.currentTarget.value)}
/>
@@ -132,7 +148,6 @@ export function ResourceCanvasAssetGenerationPanelView({
aria-label="生成提示词"
rows={6}
autoFocus
disabled={submitting}
maxLength={promptMaxLength}
placeholder={action.promptPlaceholder}
value={prompt}
@@ -152,7 +167,6 @@ export function ResourceCanvasAssetGenerationPanelView({
columns="threeToSix"
gap="sm"
size="compact"
disabled={submitting}
onChange={setAspectRatio}
/>
<PlatformSegmentedTabs
@@ -166,7 +180,6 @@ export function ResourceCanvasAssetGenerationPanelView({
columns="three"
gap="sm"
size="compact"
disabled={submitting}
onChange={setImageSize}
/>
</div>
@@ -182,7 +195,6 @@ export function ResourceCanvasAssetGenerationPanelView({
subject={`素材生成提示词(${action.label}`}
editKind="image-reference"
prompt={prompt}
disabled={submitting}
applyPrompt={setPrompt}
/>
{error ? (
@@ -194,14 +206,13 @@ export function ResourceCanvasAssetGenerationPanelView({
<PlatformActionButton
type="button"
tone="secondary"
disabled={submitting}
onClick={onClose}
>
</PlatformActionButton>
<PlatformActionButton type="submit" disabled={!canSubmit}>
<Sparkles size={15} aria-hidden="true" />
{submitting ? '生成中…' : action.label}
{action.label}
</PlatformActionButton>
</div>
</form>
@@ -0,0 +1,263 @@
import './resourceCanvasAssetGenerationTasksSidebar.css';
import { ChevronLeft, ChevronRight, ListChecks, X } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import {
RESOURCE_CANVAS_ASSET_GENERATION_STATUS_LABELS,
resourceCanvasAssetGenerationElapsedLabel,
type ResourceCanvasAssetGenerationTask,
resourceCanvasAssetGenerationTaskElapsedMillis,
resourceCanvasAssetGenerationTaskIsTerminal,
resourceCanvasAssetGenerationTaskTone,
sortResourceCanvasAssetGenerationTasks,
} from './resourceCanvasAssetGenerationTaskModel';
/** 「已完成」分栏的展示上限:触顶后只提示还有多少条,不无限拉长侧栏。 */
export const RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT = 20;
export type ResourceCanvasAssetGenerationTasksPanelViewProps = {
tasks: readonly ResourceCanvasAssetGenerationTask[];
/** 侧栏是否展开;折叠时只留贴边把手。 */
open: boolean;
onToggleOpen: () => void;
/** 定位到该任务产出的素材卡(宿主复用既有 `pendingResourceFocusRef` 聚焦链)。 */
onFocusTask: (task: ResourceCanvasAssetGenerationTask) => void;
};
function taskRow(
task: ResourceCanvasAssetGenerationTask,
nowMillis: number,
onFocusTask: (task: ResourceCanvasAssetGenerationTask) => void,
) {
const focusable = task.status === 'completed' && Boolean(task.assetId);
return (
<li
key={task.taskId}
className="game-resource-generation-task-card"
data-task-status={task.status}
>
<div className="game-resource-generation-task-card-title-row">
<strong className="game-resource-generation-task-card-name">
{task.assetName}
</strong>
<span className="game-resource-generation-task-card-action">
{task.actionLabel}
</span>
</div>
<div className="game-resource-generation-task-card-meta">
<span
className="game-resource-generation-task-badge"
data-tone={resourceCanvasAssetGenerationTaskTone(task.status)}
>
{RESOURCE_CANVAS_ASSET_GENERATION_STATUS_LABELS[task.status]}
</span>
<span className="game-resource-generation-task-card-phase">
{task.phaseDetail}
</span>
<span className="game-resource-generation-task-card-elapsed">
{`已耗时 ${resourceCanvasAssetGenerationElapsedLabel(
resourceCanvasAssetGenerationTaskElapsedMillis(task, nowMillis),
)}`}
</span>
</div>
{task.error ? (
<p className="game-resource-generation-task-card-error" role="alert">
{task.error}
</p>
) : null}
{focusable ? (
<button
type="button"
className="game-resource-generation-task-locate"
aria-label={`定位素材 ${task.assetName}`}
onClick={() => onFocusTask(task)}
>
</button>
) : null}
</li>
);
}
/**
* 画布上的「生成任务」侧栏(常驻、可折叠、非模态)。
*
* 形态对齐网页端美术画布的任务侧栏:贴边的独立 `<aside>`,展开时是一列任务(画布照常可交互),
* 折叠时只留一个可点的贴边把手(带在途数量角标)。它**不是**模态浮层:不铺全屏遮罩、不做焦点
* 陷阱、不进 `isResourceCanvasFloatingPanelOpen` / `resourceCanvasHostGenerationPanelOpen` 的
* 遮挡判据;折叠也不影响任务推进——任务活在账本与本地队列里,与这个视图的生命周期无关。
*
* 位置取舍:AGC 栏目画布的右侧是「智能创作」对话面板、底部是栏目工具栏、顶部是栏目标题栏,所以
* 侧栏挂在**左侧、标题栏之下、工具栏之上**,并且是**覆盖式**而不是把画布挤窄(画布的视口数学与
* 资源卡排布都不被 reflow 动到,收起即完全让出画布)。宽度 300px 落在 280–340px 区间内。
*
* 分栏、条数、封顶、折叠语义都在这里;颜色与排版在同目录的 CSS 文件里,取值全部走
* `--platform-*` 设计 token。
*/
export function ResourceCanvasAssetGenerationTasksPanelView({
tasks,
open,
onToggleOpen,
onFocusTask,
}: ResourceCanvasAssetGenerationTasksPanelViewProps) {
const [nowMillis, setNowMillis] = useState(() => Date.now());
const inFlightCount = tasks.filter(
(task) => !resourceCanvasAssetGenerationTaskIsTerminal(task),
).length;
const hasLiveTask = inFlightCount > 0;
const ordered = useMemo(
() => sortResourceCanvasAssetGenerationTasks(tasks),
[tasks],
);
const active = ordered.filter(
(task) => !resourceCanvasAssetGenerationTaskIsTerminal(task),
);
const done = ordered.filter((task) =>
resourceCanvasAssetGenerationTaskIsTerminal(task),
);
const visibleDone = done.slice(
0,
RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT,
);
// 已耗时是前端计时(后端只给时间戳):只在还有未终态任务时走秒表,全部收口后停掉。
useEffect(() => {
if (!hasLiveTask) {
return undefined;
}
const timer = setInterval(() => setNowMillis(Date.now()), 1_000);
return () => clearInterval(timer);
}, [hasLiveTask]);
if (!open) {
return (
<button
type="button"
className="platform-theme platform-theme--light game-resource-generation-tasks-handle"
aria-label="展开生成任务"
aria-expanded={false}
data-resource-generation-task-count={inFlightCount}
onClick={onToggleOpen}
>
<ListChecks size={15} aria-hidden="true" />
<span className="game-resource-generation-tasks-handle-label">
</span>
{inFlightCount > 0 ? (
<span
className="game-resource-generation-tasks-handle-badge"
aria-label={`在途生成任务 ${inFlightCount}`}
>
{inFlightCount}
</span>
) : null}
<ChevronRight size={13} aria-hidden="true" />
</button>
);
}
return (
<aside
className="platform-theme platform-theme--light game-resource-generation-tasks-sidebar"
role="region"
aria-label="生成任务"
data-resource-generation-task-count={inFlightCount}
>
<header className="game-resource-generation-tasks-sidebar-header">
<h2 className="game-resource-generation-tasks-sidebar-title">
<ListChecks size={15} aria-hidden="true" />
<span
className="game-resource-generation-tasks-sidebar-count"
aria-label={`在途生成任务 ${inFlightCount}`}
>
{inFlightCount}
</span>
</h2>
<button
type="button"
className="game-resource-generation-tasks-sidebar-icon-button"
aria-label="收起生成任务"
onClick={onToggleOpen}
>
<ChevronLeft size={16} aria-hidden="true" />
</button>
</header>
<div
className="game-resource-generation-tasks-scroll"
data-resource-generation-task-scroll=""
>
{ordered.length === 0 ? (
<p className="game-resource-generation-tasks-empty" role="status">
</p>
) : (
<>
<section
className="game-resource-generation-tasks-section"
aria-label="排队与生成中"
>
<h3 className="game-resource-generation-tasks-section-title">
<span>/</span>
<span className="game-resource-generation-tasks-section-title-count">
{active.length}
</span>
</h3>
{active.length === 0 ? (
<p className="game-resource-generation-tasks-empty">
</p>
) : (
<ul className="game-resource-generation-tasks-list">
{active.map((task) => taskRow(task, nowMillis, onFocusTask))}
</ul>
)}
</section>
<section
className="game-resource-generation-tasks-section"
aria-label="已完成"
>
<h3 className="game-resource-generation-tasks-section-title">
<span></span>
<span className="game-resource-generation-tasks-section-title-count">
{done.length}
</span>
</h3>
{done.length === 0 ? (
<p className="game-resource-generation-tasks-empty">
</p>
) : (
<>
<ul className="game-resource-generation-tasks-list">
{visibleDone.map((task) =>
taskRow(task, nowMillis, onFocusTask),
)}
</ul>
{done.length > visibleDone.length ? (
<p className="game-resource-generation-tasks-empty">
{`仅显示最近 ${RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT} 条,另有 ${
done.length - visibleDone.length
} 条较早记录`}
</p>
) : null}
</>
)}
</section>
</>
)}
</div>
<footer className="game-resource-generation-tasks-sidebar-footer">
<button
type="button"
className="game-resource-generation-tasks-sidebar-icon-button"
aria-label="关闭生成任务"
onClick={onToggleOpen}
>
<X size={16} aria-hidden="true" />
</button>
</footer>
</aside>
);
}
@@ -58,6 +58,10 @@ function resourceGenerationErrorMessage(error: unknown) {
* 形态是独立弹层(`ThemedModal`,与资源面板 / 分类面板同一套宿主 chrome),
* 不在任何现有面板下面追加内容;提交链路与结果定位由宿主 `index.tsx` 负责,
* 面板只持有草稿、类型选择与失败重试状态。
*
* 提交期间**不锁关闭**:× / 遮罩 / Esc / 「后台运行并关闭」四条路径都通。关闭只是把这一份
* view 卸下来,宿主那条请求继续跑(它是宿主的 `await onSubmit(...)`,不挂在面板生命周期上),
* 所以关闭**不等于**取消;失败时面板仍保留草稿与同一份请求身份可重试。
*/
export function ResourceCanvasGenerationPanelView({
kinds = RESOURCE_CANVAS_GENERATION_OPTIONS.map((option) => option.kind),
@@ -115,8 +119,8 @@ export function ResourceCanvasGenerationPanelView({
} catch (submitError) {
setError(resourceGenerationErrorMessage(submitError));
} finally {
// 成功路径也要收回在飞标记:宿主现在还靠卸载面板兜底,但组件本身不该
// 在 `onSubmit` 正常 resolve 后永久停在「生成中…」并把关闭路径全部锁住
// 成功路径也要收回在飞标记:宿主随后会卸载面板,但组件本身不该在 `onSubmit` 正常
// resolve 后永久停在「生成中…」;失败时收回标记才能让用户用同一份输入重试
setSubmitting(false);
}
}
@@ -125,13 +129,7 @@ export function ResourceCanvasGenerationPanelView({
<ThemedModal
open
ariaLabel={panelTitle}
closeOnBackdrop={!submitting}
closeOnEscape={!submitting}
onClose={() => {
if (!submitting) {
onClose();
}
}}
onClose={onClose}
panelClassName="game-approval-dialog game-resource-generation-dialog"
>
<header>
@@ -141,7 +139,6 @@ export function ResourceCanvasGenerationPanelView({
<button
type="button"
aria-label={`关闭${panelTitle}`}
disabled={submitting}
onClick={onClose}
>
<X size={16} aria-hidden="true" />
@@ -203,10 +200,9 @@ export function ResourceCanvasGenerationPanelView({
<PlatformActionButton
type="button"
tone="secondary"
disabled={submitting}
onClick={onClose}
>
{submitting ? '后台运行并关闭' : '取消'}
</PlatformActionButton>
{error ? (
<PlatformActionButton type="submit" disabled={submitting}>
@@ -0,0 +1,315 @@
import {
applyLocalProjectAssetGenerationRecords,
type LocalProjectAssetGenerationTaskRecord,
mergeLocalProjectAssetGenerationRecord,
nextResourceCanvasAssetGenerationDispatch,
type ResourceCanvasAssetGenerationTask,
} from './resourceCanvasAssetGenerationTaskModel';
export const RESOURCE_CANVAS_ASSET_GENERATION_POLL_INTERVAL_MILLIS = 2_000;
/**
* 账本里连续多少次找不到这条任务就放弃等待。
*
* 非终态记录一直存在时不能设上限(图片类生成最长 35 分钟),但记录**消失**是完全另一回事
* (账本被删、项目被换掉),继续轮询只会永远转下去。
*/
export const RESOURCE_CANVAS_ASSET_GENERATION_MISSING_RECORD_POLL_LIMIT = 15;
/** 账本读回的形状不合法(`undefined` / 非数组)时的原因文案。 */
export const RESOURCE_CANVAS_ASSET_GENERATION_MALFORMED_READ_ERROR =
'生成任务状态返回值不是数组';
/**
* 一次提交的终局。
*
* `record` 是后端账本里那条终态记录;任务连后端都没进去(IPC 抛错、后端拒绝入参)时为
* `null`,此时 `error` 带着原因。
*/
export type ResourceCanvasAssetGenerationSettlement = {
taskId: string;
/**
* 该任务所属项目。
*
* 宿主必须按它判断「这条终局还属不属于当前打开的项目」:任务可以跨项目切换收尾,拿当前项目
* 的路径去刷新另一个项目的清单是错的。
*/
projectId: string;
status: 'completed' | 'failed';
record: LocalProjectAssetGenerationTaskRecord | null;
error: string | null;
};
export type ResourceCanvasAssetGenerationQueueDeps = {
invoke(command: string, args: Record<string, unknown>): Promise<unknown>;
/**
* 当前项目路径。
*
* 队列实例要跨渲染保持同一份(`draining` 是它的内部状态),所以路径不能靠闭包捕获,
* 只能在派发那一刻从宿主当前值读。
*/
projectPath(): string;
/** 提交前的平台会话刷新(与既有生成入口同一条前置动作)。 */
refreshPlatformSession?: <T>(operation: () => Promise<T>) => Promise<T>;
listTasks(): readonly ResourceCanvasAssetGenerationTask[];
replaceTask(task: ResourceCanvasAssetGenerationTask): void;
onSettled(
settlement: ResourceCanvasAssetGenerationSettlement,
): void | Promise<void>;
pollIntervalMillis?: number;
wait?: (millis: number) => Promise<void>;
nowMillis?: () => number;
};
export type ResourceCanvasAssetGenerationQueue = {
/**
* 入队并推进。
*
* resolve = 该任务已终态(成功);reject = 失败(含后端拒绝入参)。面板据此保留草稿可重试。
* 排在别的在途任务后面的提交会先在本地队列里等,**不发 IPC**——这是本批的**提交节流**:
* AGC 本地 durable 输出槽已按精确动作指纹分槽,不同 prompt / 素材名可以同时在途,所以排队
* 不再是「远端会拒绝并发」的被迫行为;真并行派发需要并发收口设计(配对读 + manifest CAS +
* 聚焦意图互不覆盖),留待下一批。
*/
submit(task: ResourceCanvasAssetGenerationTask): Promise<void>;
/** 推进已有队列(宿主挂载 / 重开项目恢复任务时调一次)。 */
drain(): void;
};
function errorMessage(error: unknown) {
if (typeof error === 'string' && error.trim()) return error;
if (error instanceof Error && error.message) return error.message;
return '生成素材失败';
}
function isTerminalStatus(status: string) {
return status === 'completed' || status === 'failed';
}
type SettlementWaiter = {
resolve: () => void;
reject: (error: Error) => void;
};
/**
* 本地排队 + 后端任务账本的驱动器。
*
* 顺序上只有一条规则:**下一条必须等上一条终态**(本批的提交节流,见 `submit`);上一条结束
* (成功或失败)后由同一个循环自动补发。派发之后不再由前端猜进度:状态与阶段文案一律来自
* `list_local_project_asset_generations` 返回的后端记录。
*/
export function createResourceCanvasAssetGenerationQueue(
deps: ResourceCanvasAssetGenerationQueueDeps,
): ResourceCanvasAssetGenerationQueue {
const pollIntervalMillis =
deps.pollIntervalMillis ??
RESOURCE_CANVAS_ASSET_GENERATION_POLL_INTERVAL_MILLIS;
const wait =
deps.wait ??
((millis: number) =>
new Promise<void>((resolve) => {
setTimeout(resolve, millis);
}));
const nowMillis = deps.nowMillis ?? (() => Date.now());
const waiters = new Map<string, SettlementWaiter[]>();
let draining = false;
function settleWaiters(settlement: ResourceCanvasAssetGenerationSettlement) {
const pending = waiters.get(settlement.taskId);
if (!pending) {
return;
}
waiters.delete(settlement.taskId);
for (const waiter of pending) {
if (settlement.status === 'completed') {
waiter.resolve();
} else {
waiter.reject(new Error(settlement.error ?? '生成素材失败'));
}
}
}
function replaceFromRecord(
task: ResourceCanvasAssetGenerationTask,
record: LocalProjectAssetGenerationTaskRecord,
) {
deps.replaceTask(mergeLocalProjectAssetGenerationRecord(task, record));
}
async function dispatch(
task: ResourceCanvasAssetGenerationTask,
): Promise<ResourceCanvasAssetGenerationSettlement> {
const projectPath = deps.projectPath();
// 命令名写成字面量:`scripts/check-config.mjs` 的 invoke 门禁按字符串字面量登记调用方,
// 抽成常量会让这两条 IPC 被判成「没有前端调用方」。
const start = async () =>
(await deps.invoke('start_local_project_asset_generation', {
projectPath,
projectId: task.projectId,
taskId: task.taskId,
kind: task.assetKind,
prompt: task.prompt,
aspectRatio: task.aspectRatio,
imageSize: task.imageSize,
assetName: task.assetName,
outputPath: task.outputPath,
})) as LocalProjectAssetGenerationTaskRecord;
let started: LocalProjectAssetGenerationTaskRecord;
try {
started = await (deps.refreshPlatformSession
? deps.refreshPlatformSession(start)
: start());
} catch (error) {
return {
taskId: task.taskId,
projectId: task.projectId,
status: 'failed',
record: null,
error: errorMessage(error),
};
}
replaceFromRecord(task, started);
let missingRecordPolls = 0;
let readFailed = false;
let lastReadError = RESOURCE_CANVAS_ASSET_GENERATION_MALFORMED_READ_ERROR;
for (;;) {
let records: LocalProjectAssetGenerationTaskRecord[];
try {
records = (await deps.invoke('list_local_project_asset_generations', {
projectPath,
})) as LocalProjectAssetGenerationTaskRecord[];
} catch (error) {
// IPC 拒绝(未注册 / 权限拒绝 / 账本读坏):按「本轮读不到」处理,绝不把拒绝往上抛——
// 派发循环是 `void (async …)()`,抛出去就是未处理的 Promise 拒绝。
records = [];
readFailed = true;
lastReadError = errorMessage(error);
}
if (!Array.isArray(records)) {
records = [];
readFailed = true;
lastReadError = RESOURCE_CANVAS_ASSET_GENERATION_MALFORMED_READ_ERROR;
}
const record = records.find((item) => item.taskId === task.taskId);
if (record) {
missingRecordPolls = 0;
readFailed = false;
replaceFromRecord(task, record);
if (isTerminalStatus(record.status)) {
return {
taskId: task.taskId,
projectId: task.projectId,
status: record.status === 'completed' ? 'completed' : 'failed',
record,
error: record.error,
};
}
} else {
missingRecordPolls += 1;
if (
missingRecordPolls >=
RESOURCE_CANVAS_ASSET_GENERATION_MISSING_RECORD_POLL_LIMIT
) {
// 「读不到通道」与「读到数组但没有这条」是两件事,文案必须能区分:前者是账本不可用,
// 后者是记录被账本上限淘汰或项目被换掉。
return {
taskId: task.taskId,
projectId: task.projectId,
status: 'failed',
record: null,
error: readFailed
? `生成任务状态读取失败,已停止等待:${lastReadError}`
: '生成任务账本里已找不到这条任务,已停止等待',
};
}
}
await wait(pollIntervalMillis);
}
}
function markSettled(settlement: ResourceCanvasAssetGenerationSettlement) {
const settled = deps
.listTasks()
.find((task) => task.taskId === settlement.taskId);
if (!settled || isTerminalStatus(settled.status)) {
return;
}
// 连后端都没进去的任务(record === null)也必须收口为终态:留在「已派发但未终态」
// 会让本地队列认为还有在途任务,后面的排队任务永远补发不出去。
deps.replaceTask({
...settled,
status: settlement.status,
phaseDetail:
settlement.record?.phaseDetail ??
`生成失败:${settlement.error ?? '未知原因'}`,
error: settlement.error,
finishedAtMillis: settlement.record?.finishedAtMillis ?? nowMillis(),
});
}
function drain(): void {
if (draining) {
return;
}
draining = true;
void (async () => {
try {
for (;;) {
const next = nextResourceCanvasAssetGenerationDispatch(
deps.listTasks(),
);
if (!next) {
return;
}
deps.replaceTask({ ...next, dispatched: true });
let settlement: ResourceCanvasAssetGenerationSettlement;
try {
settlement = await dispatch(next);
} catch (error) {
// `dispatch` 已经把可预期的失败(提交失败 / 账本读失败 / 记录丢失)收口成
// settlement;这里是最后一道兜底:任何意外抛出都不能变成未处理的 Promise 拒绝,
// 也不能让这条任务永远停在「已派发但未终态」把后面的排队任务卡死。
settlement = {
taskId: next.taskId,
projectId: next.projectId,
status: 'failed',
record: null,
error: errorMessage(error),
};
}
markSettled(settlement);
settleWaiters(settlement);
try {
await deps.onSettled(settlement);
} catch {
// 宿主收尾(配对读清单 / 提示条)失败不改变任务终局,也不能打断队列:
// 任务本身的状态已经写进列表并通知了等待者。
}
}
} finally {
draining = false;
}
})();
}
return {
submit(task) {
deps.replaceTask(task);
const promise = new Promise<void>((resolve, reject) => {
const pending = waiters.get(task.taskId) ?? [];
waiters.set(task.taskId, [...pending, { resolve, reject }]);
});
drain();
return promise;
},
drain,
};
}
/** 用后端记录刷新整个任务列表(重开项目后恢复历史任务时用)。 */
export function mergeResourceCanvasAssetGenerationTasksWithRecords(
tasks: readonly ResourceCanvasAssetGenerationTask[],
records: readonly LocalProjectAssetGenerationTaskRecord[],
): ResourceCanvasAssetGenerationTask[] {
return applyLocalProjectAssetGenerationRecords(tasks, records);
}
@@ -0,0 +1,328 @@
import {
resolveResourceCanvasBottomTools,
type ResourceCanvasAssetToolAction,
resourceCanvasBottomToolActions,
} from './resourceCanvasBottomToolbarModel';
/** 一条生成任务在宿主里的状态。`queued` 包含「本地排队」和「后端排队」两种来源。 */
export type ResourceCanvasAssetGenerationTaskStatus =
| 'queued'
| 'running'
| 'completed'
| 'failed';
/**
* Rust 账本里的一条记录(`list_local_project_asset_generations` 的元素)。
*
* `phaseDetail` **由后端拥有**:前端只渲染这个字符串,不自己拼阶段或造百分比进度。
*/
export type LocalProjectAssetGenerationTaskRecord = {
taskId: string;
projectId: string;
kind: string;
assetName: string;
status: string;
phaseDetail: string;
createdAtMillis: number;
startedAtMillis: number | null;
finishedAtMillis: number | null;
assetId: string | null;
error: string | null;
};
/** 宿主「生成任务」列表里的一条:本地排队信息 + 后端账本记录。 */
export type ResourceCanvasAssetGenerationTask = {
/** 本地任务 id,同时作为提交给后端的 taskId(重开项目后靠它对上账本记录)。 */
taskId: string;
actionId: string;
actionLabel: string;
assetKind: string;
assetName: string;
prompt: string;
aspectRatio: string;
imageSize: string;
outputPath: string | null;
projectId: string;
/** 是否已经把这次提交交给后端。未派发的任务只活在本地队列里。 */
dispatched: boolean;
status: ResourceCanvasAssetGenerationTaskStatus;
phaseDetail: string;
createdAtMillis: number;
startedAtMillis: number | null;
finishedAtMillis: number | null;
assetId: string | null;
error: string | null;
/** 从账本恢复出来的历史任务(本地没有对应的草稿)。 */
restored: boolean;
};
export const RESOURCE_CANVAS_ASSET_GENERATION_STATUS_LABELS: Record<
ResourceCanvasAssetGenerationTaskStatus,
string
> = {
queued: '排队中',
running: '生成中',
completed: '已完成',
failed: '失败',
};
/**
* 状态 → 视觉 tone(中性 / 品牌 / 成功 / 危险)。
*
* 放在模型层而不是组件层有两个原因:① 它是纯映射,组件不该自己发明颜色语义;
* ② 组件文件只导出组件与常量(`react-refresh/only-export-components`),导出函数会破坏热更新边界。
* 具体样式在 `resourceCanvasAssetGenerationTasksSidebar.css` 里按 `[data-tone=…]` 落地,映射由测试钉住。
*/
export type ResourceCanvasAssetGenerationTaskTone =
| 'queued'
| 'running'
| 'completed'
| 'failed';
export function resourceCanvasAssetGenerationTaskTone(
status: ResourceCanvasAssetGenerationTaskStatus,
): ResourceCanvasAssetGenerationTaskTone {
return status;
}
/**
* 本地排队的阶段文案。
*
* 只有**还没交给后端**的那条任务会用到它:单值槽换成任务列表后,第二条提交在前一条终态
* 之前不发 IPC(见 `nextResourceCanvasAssetGenerationDispatch`),所以它在后端账本里不
* 存在,没有后端阶段可渲染。一旦派发,阶段文案一律改由后端 `phaseDetail` 提供。
*/
export const RESOURCE_CANVAS_ASSET_GENERATION_LOCAL_QUEUE_PHASE = '排队中。';
const RESOURCE_CANVAS_ASSET_GENERATION_CATEGORIES = [
'ui-interaction',
'character',
'scene',
] as const;
/**
* 图片类生成 kind → 入口文案。
*
* 从工具栏模型派生而不是另抄一张表:kind 白名单只有一份(`resourceCanvasBottomToolbarModel`),
* 抄第二份就会在加 kind 时漂移。同一 kind 出现在多个入口时取首次出现的那个。
*/
export function resourceCanvasAssetGenerationKindLabel(
kind: string,
): string | null {
for (const category of RESOURCE_CANVAS_ASSET_GENERATION_CATEGORIES) {
for (const tool of resolveResourceCanvasBottomTools(category)) {
for (const action of resourceCanvasBottomToolActions(tool)) {
if (action.route === 'asset' && action.assetKind === kind) {
return action.label;
}
}
}
}
return null;
}
export function resolveResourceCanvasAssetGenerationTaskStatus(
status: string,
): ResourceCanvasAssetGenerationTaskStatus {
switch (status) {
case 'queued':
case 'running':
case 'completed':
return status;
default:
return 'failed';
}
}
export function resourceCanvasAssetGenerationTaskIsTerminal(
task: Pick<ResourceCanvasAssetGenerationTask, 'status'>,
): boolean {
return task.status === 'completed' || task.status === 'failed';
}
/** 新提交的任务:先本地排队,派发之前不进后端账本。 */
export function createResourceCanvasAssetGenerationTask(input: {
taskId: string;
action: ResourceCanvasAssetToolAction;
prompt: string;
assetName: string;
aspectRatio: string;
imageSize: string;
outputPath: string | null;
projectId: string;
nowMillis: number;
}): ResourceCanvasAssetGenerationTask {
return {
taskId: input.taskId,
actionId: input.action.id,
actionLabel: input.action.label,
assetKind: input.action.assetKind,
assetName: input.assetName,
prompt: input.prompt,
aspectRatio: input.aspectRatio,
imageSize: input.imageSize,
outputPath: input.outputPath,
projectId: input.projectId,
dispatched: false,
status: 'queued',
phaseDetail: RESOURCE_CANVAS_ASSET_GENERATION_LOCAL_QUEUE_PHASE,
createdAtMillis: input.nowMillis,
startedAtMillis: null,
finishedAtMillis: null,
assetId: null,
error: null,
restored: false,
};
}
const RESOURCE_CANVAS_ASSET_GENERATION_TASK_LIMIT = 50;
/** 账本记录 → 任务列表里的一条(重开项目后恢复显示)。 */
export function restoreResourceCanvasAssetGenerationTask(
record: LocalProjectAssetGenerationTaskRecord,
): ResourceCanvasAssetGenerationTask {
return {
taskId: record.taskId,
actionId: `restored:${record.kind}`,
actionLabel:
resourceCanvasAssetGenerationKindLabel(record.kind) ?? record.assetName,
assetKind: record.kind,
assetName: record.assetName,
prompt: '',
aspectRatio: '',
imageSize: '',
outputPath: null,
projectId: record.projectId,
dispatched: true,
status: resolveResourceCanvasAssetGenerationTaskStatus(record.status),
phaseDetail: record.phaseDetail,
createdAtMillis: record.createdAtMillis,
startedAtMillis: record.startedAtMillis,
finishedAtMillis: record.finishedAtMillis,
assetId: record.assetId,
error: record.error,
restored: true,
};
}
/**
* 本地任务 + 后端记录 → 下一版任务列表。
*
* 两个方向都要覆盖:本地任务用后端记录刷新状态与阶段;后端有、本地没有的记录(重开项目、
* 或本地列表被清空)恢复成一条历史任务。后端不再返回的本地任务保持原样——记录被账本上限
* 淘汰不该让前端把一条已完成任务抹掉。
*
* `records` **在函数入口归一化**:宿主从 IPC 拿到的不一定是数组(旧壳没有这条命令、权限拒绝、
* 账本文件读坏、命令未注册都可能给出 `undefined` 或别的形状)。非数组一律按「没有后端记录」
* 处理——抛出去会变成 effect 里的未处理 Promise 拒绝,把「任务列表暂不可用」升级成整块视图出错。
* 数组里的垃圾条目(`null` / 数字 / 缺 `taskId`)同样逐条丢弃,不让一条坏记录带崩整次恢复。
*/
export function applyLocalProjectAssetGenerationRecords(
tasks: readonly ResourceCanvasAssetGenerationTask[],
records: readonly LocalProjectAssetGenerationTaskRecord[] | null | undefined,
): ResourceCanvasAssetGenerationTask[] {
const safeRecords = Array.isArray(records)
? records.filter(
(record): record is LocalProjectAssetGenerationTaskRecord =>
typeof record === 'object' &&
record !== null &&
typeof (record as LocalProjectAssetGenerationTaskRecord).taskId ===
'string' &&
(record as LocalProjectAssetGenerationTaskRecord).taskId.trim()
.length > 0,
)
: [];
const recordsByTaskId = new Map(
safeRecords.map((record) => [record.taskId, record]),
);
const merged = tasks.map((task) => {
const record = recordsByTaskId.get(task.taskId);
return record ? mergeLocalProjectAssetGenerationRecord(task, record) : task;
});
const knownTaskIds = new Set(tasks.map((task) => task.taskId));
const restored = safeRecords
.filter((record) => !knownTaskIds.has(record.taskId))
.map(restoreResourceCanvasAssetGenerationTask);
if (restored.length === 0) {
return merged;
}
return [...merged, ...restored].slice(
-RESOURCE_CANVAS_ASSET_GENERATION_TASK_LIMIT,
);
}
/** 用后端记录刷新一条本地任务:状态、阶段、时间戳、资源 id 与失败原因都以后端为准。 */
export function mergeLocalProjectAssetGenerationRecord(
task: ResourceCanvasAssetGenerationTask,
record: LocalProjectAssetGenerationTaskRecord,
): ResourceCanvasAssetGenerationTask {
return {
...task,
dispatched: true,
status: resolveResourceCanvasAssetGenerationTaskStatus(record.status),
phaseDetail: record.phaseDetail,
startedAtMillis: record.startedAtMillis ?? task.startedAtMillis,
finishedAtMillis: record.finishedAtMillis ?? task.finishedAtMillis,
assetId: record.assetId ?? task.assetId,
error: record.error ?? task.error,
};
}
/**
* 本地排队的派发判据:**同一时刻只派发一条任务**(本批的提交节流)。
*
* AGC 本地 durable 输出槽已按**精确动作指纹**分槽,不同 prompt / 素材名可以同时在途,所以这条
* 判据**不再是「远端会拒绝并发」的被迫排队**;本批前端仍按「同一时刻只派发一条」排队,作为提交
* 节流。真并行派发需要并发收口设计(配对读 + manifest CAS + 聚焦意图互不覆盖),留待下一批。
*
* 「在途」的判据是 `dispatched && 未终态`,而不是 `status === 'running'`:已派发但后端记录还
* 没读回来的那一段(状态仍是 `queued`)同样算在途,漏掉这一档就会提前放出第二条。
*/
export function nextResourceCanvasAssetGenerationDispatch(
tasks: readonly ResourceCanvasAssetGenerationTask[],
): ResourceCanvasAssetGenerationTask | null {
const inFlight = tasks.some(
(task) =>
task.dispatched && !resourceCanvasAssetGenerationTaskIsTerminal(task),
);
if (inFlight) {
return null;
}
return (
tasks.find((task) => !task.dispatched && task.status === 'queued') ?? null
);
}
/** 面板展示顺序:新提交的在上。 */
export function sortResourceCanvasAssetGenerationTasks(
tasks: readonly ResourceCanvasAssetGenerationTask[],
): ResourceCanvasAssetGenerationTask[] {
return [...tasks].sort(
(left, right) =>
right.createdAtMillis - left.createdAtMillis ||
left.taskId.localeCompare(right.taskId),
);
}
/** 已耗时文案:排队中按创建时间算,已结束按结束时间算。 */
export function resourceCanvasAssetGenerationElapsedLabel(
elapsedMillis: number,
): string {
const totalSeconds = Math.max(0, Math.floor(elapsedMillis / 1000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return minutes > 0 ? `${minutes}${seconds}` : `${seconds}`;
}
export function resourceCanvasAssetGenerationTaskElapsedMillis(
task: Pick<
ResourceCanvasAssetGenerationTask,
'createdAtMillis' | 'finishedAtMillis'
>,
nowMillis: number,
): number {
return Math.max(
0,
(task.finishedAtMillis ?? nowMillis) - task.createdAtMillis,
);
}
@@ -0,0 +1,435 @@
/* 「生成任务」侧栏的样式。
*
* 分层与网页端美术画布的任务侧栏(`ImageCanvasTaskSidebarView.tsx` + `src/index.css:6084+`)一一对应:
* 容器 → 头部(标题 + 在途计数)→ 分栏标题(各带条数)→ 条目卡片(状态徽标 / 阶段 / 耗时 / 定位)。
* 差别只有一处:**颜色全部换成 AGC 的平台设计 token`--platform-*`**,不再照抄网页端的固定色值,
* 这样亮/暗主题都跟着 `platform-theme` 走,也不引入第二套色板。
*
* 行为(分栏、条数、已完成封顶、折叠不清列表、提交后自动展开)都不在这里,样式只负责表现。
*/
.game-resource-generation-tasks-sidebar {
position: fixed;
top: 4rem;
bottom: 6rem;
left: 0.75rem;
z-index: 40;
display: flex;
width: min(300px, 80vw);
flex-direction: column;
overflow: hidden;
border: 1px solid var(--platform-subpanel-border);
border-radius: 0.75rem;
background: var(--platform-subpanel-fill);
color: var(--platform-text-strong);
box-shadow: var(--platform-panel-shadow);
backdrop-filter: blur(10px);
animation: game-resource-generation-tasks-enter 160ms ease-out;
}
/* 提交后面板会重新挂载,动画只负责「进场」这一下;不做宽度 reflow,避免画布抖动。 */
@keyframes game-resource-generation-tasks-enter {
from {
opacity: 0;
transform: translateX(-0.5rem);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.game-resource-generation-tasks-sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
border-bottom: 1px solid var(--platform-line-soft);
padding: 0.68rem 0.78rem;
}
.game-resource-generation-tasks-sidebar-title {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.8rem;
font-weight: 850;
}
.game-resource-generation-tasks-sidebar-count {
display: inline-flex;
min-width: 1.35rem;
height: 1.35rem;
align-items: center;
justify-content: center;
border: 1px solid var(--platform-neutral-border);
border-radius: 999px;
background: var(--platform-neutral-bg);
color: var(--platform-neutral-text);
font-size: 0.72rem;
font-variant-numeric: tabular-nums;
font-weight: 850;
}
.game-resource-generation-tasks-sidebar-icon-button {
display: grid;
width: 1.75rem;
height: 1.75rem;
place-items: center;
border: 1px solid transparent;
border-radius: 999px;
background: transparent;
color: var(--platform-button-ghost-text);
transition:
background 120ms ease,
color 120ms ease,
border-color 120ms ease;
}
.game-resource-generation-tasks-sidebar-icon-button:hover {
border-color: var(--platform-subpanel-border);
background: var(--platform-nav-item-hover-fill);
color: var(--platform-text-strong);
}
.game-resource-generation-tasks-sidebar-icon-button:focus-visible,
.game-resource-generation-tasks-handle:focus-visible,
.game-resource-generation-task-locate:focus-visible {
outline: 2px solid var(--platform-accent);
outline-offset: 2px;
box-shadow: 0 0 0 3px var(--platform-input-focus-ring);
}
.game-resource-generation-tasks-scroll {
min-height: 0;
flex: 1 1 auto;
overflow-y: auto;
overscroll-behavior: contain;
padding: 0.5rem 0.6rem;
scrollbar-color: var(--platform-line-soft) transparent;
scrollbar-width: thin;
}
.game-resource-generation-tasks-scroll::-webkit-scrollbar {
width: 6px;
}
.game-resource-generation-tasks-scroll::-webkit-scrollbar-thumb {
border-radius: 999px;
background: var(--platform-line-soft);
}
.game-resource-generation-tasks-scroll::-webkit-scrollbar-track {
background: transparent;
}
.game-resource-generation-tasks-empty {
padding: 1rem 0.5rem;
color: var(--platform-text-muted);
font-size: 0.74rem;
font-weight: 750;
text-align: center;
}
.game-resource-generation-tasks-section
+ .game-resource-generation-tasks-section {
margin-top: 0.65rem;
border-top: 1px solid var(--platform-line-soft);
padding-top: 0.6rem;
}
.game-resource-generation-tasks-section-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
color: var(--platform-text-muted);
font-size: 0.72rem;
font-weight: 850;
letter-spacing: 0.02em;
}
.game-resource-generation-tasks-section-title-count {
display: inline-flex;
min-width: 1.05rem;
height: 1.05rem;
align-items: center;
justify-content: center;
border-radius: 999px;
background: var(--platform-neutral-bg);
color: var(--platform-neutral-text);
font-size: 0.68rem;
font-variant-numeric: tabular-nums;
}
.game-resource-generation-tasks-list {
display: grid;
gap: 0.38rem;
margin-top: 0.4rem;
}
.game-resource-generation-task-card {
display: grid;
gap: 0.3rem;
width: 100%;
border: 1px solid var(--platform-subpanel-border);
border-radius: 0.5rem;
background: var(--platform-panel-fill);
padding: 0.5rem 0.55rem;
transition:
border-color 120ms ease,
box-shadow 120ms ease,
transform 120ms ease;
}
.game-resource-generation-task-card:hover {
border-color: var(
--platform-surface-hover-border,
var(--platform-subpanel-border)
);
box-shadow: var(--platform-desktop-hover-shadow);
transform: translateY(-1px);
}
.game-resource-generation-task-card-title-row {
display: grid;
min-width: 0;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
gap: 0.45rem;
}
.game-resource-generation-task-card-name {
overflow: hidden;
color: var(--platform-text-strong);
font-size: 0.78rem;
font-weight: 850;
text-overflow: ellipsis;
white-space: nowrap;
}
.game-resource-generation-task-card-action {
overflow: hidden;
color: var(--platform-text-muted);
font-size: 0.68rem;
font-weight: 750;
text-overflow: ellipsis;
white-space: nowrap;
}
.game-resource-generation-task-card-meta {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.4rem;
}
.game-resource-generation-task-badge {
display: inline-flex;
align-items: center;
gap: 0.3rem;
border: 1px solid var(--platform-neutral-border);
border-radius: 999px;
background: var(--platform-neutral-bg);
color: var(--platform-neutral-text);
padding: 0.1rem 0.45rem;
font-size: 0.68rem;
font-weight: 800;
white-space: nowrap;
}
.game-resource-generation-task-badge::before {
content: '';
width: 0.375rem;
height: 0.375rem;
border-radius: 999px;
background: currentcolor;
}
/* 四种状态的 tone 映射:中性 / 品牌 / 成功 / 危险,全部走平台 token,不硬编码颜色。 */
.game-resource-generation-task-badge[data-tone='queued'] {
border-color: var(--platform-neutral-border);
background: var(--platform-neutral-bg);
color: var(--platform-neutral-text);
}
.game-resource-generation-task-badge[data-tone='running'] {
border-color: var(--platform-accent);
background: transparent;
color: var(--platform-accent);
}
.game-resource-generation-task-badge[data-tone='completed'] {
border-color: var(--platform-success-border);
background: var(--platform-success-bg);
color: var(--platform-success-text);
}
.game-resource-generation-task-badge[data-tone='failed'] {
border-color: var(--platform-button-danger-border);
background: var(--platform-button-danger-fill);
color: var(--platform-button-danger-text);
}
/* 生成中只给「有在动」的呼吸感,不做百分比 —— 后端没有可播报的百分比。 */
.game-resource-generation-task-badge[data-tone='running']::before {
animation: game-resource-generation-task-pulse 1.2s ease-in-out infinite;
}
@keyframes game-resource-generation-task-pulse {
50% {
opacity: 0.25;
}
}
.game-resource-generation-task-card-phase {
overflow: hidden;
color: var(--platform-text-muted);
font-size: 0.7rem;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.game-resource-generation-task-card-elapsed {
color: var(--platform-text-muted);
font-size: 0.68rem;
font-variant-numeric: tabular-nums;
font-weight: 750;
text-align: right;
white-space: nowrap;
}
.game-resource-generation-task-card-error {
color: var(--platform-button-danger-text);
font-size: 0.7rem;
line-height: 1.35;
overflow-wrap: anywhere;
white-space: normal;
}
.game-resource-generation-task-locate {
justify-self: start;
border: 0;
background: transparent;
color: var(--platform-accent);
font-size: 0.7rem;
font-weight: 800;
padding: 0;
text-decoration: underline;
text-underline-offset: 0.16rem;
transition: color 120ms ease;
}
.game-resource-generation-task-locate:hover {
color: var(--platform-text-strong);
}
.game-resource-generation-tasks-sidebar-footer {
display: flex;
align-items: center;
justify-content: flex-end;
border-top: 1px solid var(--platform-line-soft);
padding: 0.4rem 0.6rem;
}
/* 折叠态:贴边竖向把手 + 在途数量角标。 */
.game-resource-generation-tasks-handle {
position: fixed;
top: 50%;
left: 0;
z-index: 40;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.3rem;
border: 1px solid var(--platform-subpanel-border);
border-left: 0;
border-radius: 0 0.75rem 0.75rem 0;
background: var(--platform-subpanel-fill);
color: var(--platform-text-strong);
padding: 0.6rem 0.3rem;
box-shadow: var(--platform-panel-shadow);
transform: translateY(-50%);
transition:
background 120ms ease,
box-shadow 120ms ease,
transform 120ms ease;
animation: game-resource-generation-tasks-handle-enter 160ms ease-out;
}
.game-resource-generation-tasks-handle:hover {
background: var(--platform-nav-item-hover-fill);
box-shadow: var(--platform-desktop-hover-shadow);
transform: translateY(-50%) translateX(0.1rem);
}
@keyframes game-resource-generation-tasks-handle-enter {
from {
opacity: 0;
transform: translateY(-50%) translateX(-0.5rem);
}
to {
opacity: 1;
transform: translateY(-50%) translateX(0);
}
}
.game-resource-generation-tasks-handle-label {
font-size: 0.7rem;
font-weight: 850;
writing-mode: vertical-rl;
}
.game-resource-generation-tasks-handle-badge {
display: inline-flex;
min-width: 1.1rem;
height: 1.1rem;
align-items: center;
justify-content: center;
border: 1px solid var(--platform-accent);
border-radius: 999px;
color: var(--platform-accent);
font-size: 0.68rem;
font-variant-numeric: tabular-nums;
font-weight: 850;
}
/* 窄屏(含 360px):侧栏占满可用宽度,把手只占一条窄边,不挡画布操作。 */
@media (max-width: 480px) {
.game-resource-generation-tasks-sidebar {
top: 3.5rem;
right: 0.5rem;
bottom: 5.5rem;
left: 0.5rem;
width: auto;
}
.game-resource-generation-tasks-handle {
padding: 0.5rem 0.2rem;
}
}
/* 降低动效偏好:进场动画、悬停位移与呼吸全部关掉。 */
@media (prefers-reduced-motion: reduce) {
.game-resource-generation-tasks-sidebar,
.game-resource-generation-tasks-handle {
animation: none;
}
.game-resource-generation-task-card,
.game-resource-generation-tasks-handle,
.game-resource-generation-tasks-sidebar-icon-button,
.game-resource-generation-task-badge::before,
.game-resource-generation-task-locate {
transition: none;
animation: none;
}
.game-resource-generation-task-card:hover,
.game-resource-generation-tasks-handle:hover {
transform: none;
}
}
@@ -21,7 +21,7 @@ import type { ResourceCanvasGenerationKind } from './resourceCanvasGenerationMod
*/
/**
* `generate_local_project_asset` 放行的无源生成类型。
* `start_local_project_asset_generation` 放行的无源生成类型。
*
* 与 Rust `PLATFORM_ART_ASSET_GENERATION_KINDS` 一一对应(`publication-material` 属宣发
* 素材,本轮不做,因此不在这里)。`spec` / `icon-spec` 都会在 Rust 侧收口到 `icon-spec`
@@ -72,7 +72,7 @@ type ResourceCanvasBottomToolActionBase = {
imageSize: string;
};
/** 图片类生成入口:走 `generate_local_project_asset`。 */
/** 图片类生成入口:走 `start_local_project_asset_generation`(提交即返回、后台生成)。 */
export type ResourceCanvasAssetToolAction =
ResourceCanvasBottomToolActionBase & {
route: 'asset';
@@ -19,6 +19,14 @@ export type ResourceInfoFieldRow = {
const EMPTY_TAGS_TEXT = '暂无标签';
/**
* 「分类」字段的行标识。
*
* 画布上的信息浮层用这一行接出素材类型设置入口(运行页签的「信息展示」不带入口,
* 同一份字段清单在两处渲染);把字面量放在这里,模型与入口两侧不会各写一个。
*/
export const RESOURCE_INFO_CATEGORY_FIELD_LABEL = '分类';
/**
* 栏目文案与资源筛选同源;`version` 不是跨端资源分类,和画布栏目一样单独给文案。
*/
@@ -43,7 +51,10 @@ export function resolveResourceInfoFieldRows(
{ label: '名称', value: resource.label },
{ label: '路径', value: resource.path },
{ label: '类型', value: resource.mediaType },
{ label: '分类', value: resourceCategoryLabel(resource.category) },
{
label: RESOURCE_INFO_CATEGORY_FIELD_LABEL,
value: resourceCategoryLabel(resource.category),
},
{
label: '标签',
value: tags.length > 0 ? tags.join('、') : EMPTY_TAGS_TEXT,
+17 -3
View File
@@ -7023,10 +7023,24 @@ iframe.preview-frame {
place-items: center;
}
.game-resource-card[data-preview-kind='raster-image']
/*
* 棋盘格底只铺给**这张图真的有 alpha 通道**的卡(`data-preview-has-alpha='true'`
* 判据来自原生侧头部解析,见 `src-tauri/src/image_inspect.rs`),不再按「预览分支是图片」
* 无条件铺。
*
* 原因:AI 生成的「透明底」PNG 常常把棋盘格**画进像素里**。无条件铺底时,卡面棋盘格与图内
* 棋盘格叠在一起,验收无法区分「真透明底」与「假棋盘格」;改为按真实 alpha 判定后两者可分。
*
* 没有该属性时(JPEG 恒不透明;media-image / video 目前没有头部 alpha 判据)退回
* `.game-resource-card-visual` 的既有纯色底(见上一条规则),不引入第二套底色,
* 因此不会出现「半透明叠色」之类的问题。
*/
.game-resource-card[data-preview-kind='raster-image'][data-preview-has-alpha='true']
.game-resource-card-visual,
.game-resource-card[data-preview-kind='media-image'] .game-resource-card-visual,
.game-resource-card[data-preview-kind='video'] .game-resource-card-visual {
.game-resource-card[data-preview-kind='media-image'][data-preview-has-alpha='true']
.game-resource-card-visual,
.game-resource-card[data-preview-kind='video'][data-preview-has-alpha='true']
.game-resource-card-visual {
background: linear-gradient(45deg, #f1ebe7 25%, transparent 25%),
linear-gradient(-45deg, #f1ebe7 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #f1ebe7 75%),
@@ -5,38 +5,22 @@ import { useState } from 'react';
import { PlatformActionButton } from '../../../../../packages/shared/src/components/PlatformActionButton';
import { PlatformPillBadge } from '../../../../../packages/shared/src/components/PlatformPillBadge';
import { PlatformSegmentedTabs } from '../../../../../packages/shared/src/components/PlatformSegmentedTabs';
import { PlatformTextField } from '../../../../../packages/shared/src/components/PlatformTextField';
import {
GAME_CREATION_APP_ASSET_CATEGORIES,
type GameCreationAppAssetCategory,
gameCreationAppAssetCategory,
type GameCreationAppAssetManifestEntry,
gameCreationAppAssetPersistedCategory,
gameCreationAppAssetTags,
normalizeGameCreationAppAssetTags,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { ThemedModal } from '../../components/modal/ThemedModal';
import { resourceReferenceCategoryLabel } from '../../features/project-workspace/resourceReferences';
import { projectAssetCommandErrorMessage } from './projectAssetCommandErrorMessage';
import { resourceAssetDisplayName } from './resourceAssetDisplayName';
type UpdateLocalProjectResourceClassificationResult = {
asset: GameCreationAppAssetManifestEntry;
committedProjectRevision: number;
};
/**
* 素材类型(功能分类)选项 = 合法分类枚举 × 既有中文展示名。
*
* 展示名只从 `resourceReferenceCategoryLabel`(筛选与栏目的同一份口径)取,
* 不在业务页另写一张译名表 —— 面板说「角色与对象」而栏目说别的,用户会以为是两个东西。
*/
const RESOURCE_CLASSIFICATION_CATEGORY_OPTIONS =
GAME_CREATION_APP_ASSET_CATEGORIES.map((category) => ({
id: category,
label: resourceReferenceCategoryLabel(category),
}));
/**
* 标签草稿沿用写入路径的归一化边界,只按中英文逗号、顿号与换行切分。
* 与输入框旧的"整段逗号分隔文本"口径完全一致,改动只是把结果换成逐个可删的 pill。
@@ -57,16 +41,6 @@ function mergeResourceClassificationTagDraft(
return next;
}
/**
* 素材名取 `localPath` 的 basenamemanifest 资产没有独立的显示名字段,
* 与资源卡、`@` 面板的显示口径一致。
*/
function resourceAssetDisplayName(localPath: string) {
const normalized = localPath.replaceAll('\\', '/');
const segments = normalized.split('/');
return segments[segments.length - 1] || localPath;
}
function resourceClassificationErrorMessage(error: unknown) {
// 项目身份 / 版本 CAS 拒绝翻成用户可读中文,其余原样透出;
// 与重命名、删除共用同一份映射。
@@ -89,26 +63,17 @@ export function ResourceClassificationPanel({
onSaved,
}: ResourceClassificationPanelProps) {
/**
* 素材类型(功能分类)从本面板设置,与标签同一次保存、同一条写入路径
* 本面板只编辑标签:素材类型(功能分类)在「设置素材类型」面板里单独设置
*
* **选择器读的是「显示口径** `gameCreationAppAssetCategory`:它与资源画布栏目
* `projectResourceAssetCategory` / `projectResourceCanvasCategory`)同一份读数,
* 所以用户看到的选中项恰好就是他看到的那一栏,不存在「面板说 A、卡片在 B 栏」。
*
* **写回不能用这个读数**:显示口径含读时自愈 —— 落盘 `unclassified` 而 `kind` 能派生出
* 明确分类时,读出来的是派生值。回传它就等于用户只改标签也被静默改了分类
* **写回必须用落盘口径** `gameCreationAppAssetPersistedCategory`,不能用读显示口径
* `gameCreationAppAssetCategory`:显示口径含读时自愈 —— 落盘 `unclassified` 而 `kind`
* 能派生出明确分类时,读出来的是派生值。回传它就等于用户只改标签也被静默改了分类
* (真机上同一条 `kind:"ui"` 资产同时出现过 `unclassified` 与 `ui-interaction` 两种落盘值)。
*
* 因此用 `categoryChoice` 表达「用户是否主动选过」
* - `null`(没碰过分类控件)→ 回传 `gameCreationAppAssetPersistedCategory` 落盘原值
* - 用户选过 → 回传用户选的那个值
* 这条分叉是本次改动的核心不变量,两个方向都由
* `tests/resourceClassificationPanel.test.tsx` 的对照用例钉住。
* 拆出类型面板后这条不变量不再依赖"用户是否碰过控件",而是结构性的
* 本面板没有类型控件,`category` 恒为落盘原值
* `tests/resourceClassificationPanel.test.tsx` 两个方向各有用例钉住它
*/
const [categoryChoice, setCategoryChoice] =
useState<GameCreationAppAssetCategory | null>(null);
const displayedCategory =
categoryChoice ?? gameCreationAppAssetCategory(asset);
const [tags, setTags] = useState<string[]>(() =>
gameCreationAppAssetTags(asset),
);
@@ -159,9 +124,8 @@ export function ResourceClassificationPanel({
expectedProjectId: projectId,
expectedProjectRevision: status.revision,
assetId: asset.id,
// 用户没主动选类型就原样回传落盘值(不含读时自愈),选了就写用户选的那个
category:
categoryChoice ?? gameCreationAppAssetPersistedCategory(asset),
// 分类不由本面板编辑:恒回传落盘值(不含读时自愈)。
category: gameCreationAppAssetPersistedCategory(asset),
tags: normalizeGameCreationAppAssetTags(tagsToSave),
},
},
@@ -214,20 +178,10 @@ export function ResourceClassificationPanel({
</header>
<div className="game-resource-classification-body">
{/*
素材类型选择器:选中的那一项就是这张卡当前所在的画布栏目。
点任意一项即视为用户主动改类型(即便点的是当前已选中的那一项),
与「没碰过就回传落盘原值」的分叉保持同一条判据,不做隐式 no-op
本面板没有素材类型控件:类型是「设置素材类型」面板的编辑对象,入口在资源卡选中
工具条上。曾长在这里的类型 chip 只改本地 state、不落盘,保存又只能借道标签的
「添加」,导致"改了类型没生效"
*/}
<PlatformSegmentedTabs
items={RESOURCE_CLASSIFICATION_CATEGORY_OPTIONS}
activeId={displayedCategory}
onChange={setCategoryChoice}
layout="scroll"
gap="sm"
frame="bare"
surface="transparent"
size="compact"
/>
{tags.length > 0 ? (
<ul className="game-resource-tag-list" aria-label="已有标签">
{tags.map((tag) => (
@@ -3,6 +3,7 @@ import { Info, X } from 'lucide-react';
import {
resolveResourceInfoFieldRows,
resolveResourceInfoPanelStyle,
RESOURCE_INFO_CATEGORY_FIELD_LABEL,
type ResourceInfoPanelAnchor,
} from '../../features/resource-canvas/resourceCanvasInfoModel';
import type { ProjectResource } from './resourceProjectionModel';
@@ -10,11 +11,17 @@ import type { ProjectResource } from './resourceProjectionModel';
/**
* 只读资源信息字段。运行页签的「信息展示」与画布上的信息浮层共用这一份,
* 字段清单只在 `resolveResourceInfoFieldRows` 里定义,两处不会各说一套。
*
* `onEditCategory` 是「分类」行的可选入口(只有画布浮层传):分类值本身仍然只读展示,
* 入口按钮渲染在 `dd` **外面** —— 字段值的读取口径是 `dt` / `dd` 的文本,
* 把按钮塞进 `dd` 会让分类值变成「角色与对象设置」这类拼接文案。
*/
export function ResourceInfoFieldsView({
resource,
onEditCategory,
}: {
resource: ProjectResource;
onEditCategory?: () => void;
}) {
return (
<dl className="game-resource-info-fields">
@@ -22,6 +29,18 @@ export function ResourceInfoFieldsView({
<div key={row.label}>
<dt>{row.label}</dt>
<dd>{row.value}</dd>
{onEditCategory &&
row.label === RESOURCE_INFO_CATEGORY_FIELD_LABEL ? (
<button
type="button"
className="game-resource-info-field-action"
aria-label="设置素材类型"
title="设置素材类型"
onClick={onEditCategory}
>
</button>
) : null}
</div>
))}
</dl>
@@ -30,6 +49,10 @@ export function ResourceInfoFieldsView({
export type ResourceInfoPanelViewProps = ResourceInfoPanelAnchor & {
resource: ProjectResource;
/**
* 「分类」行的类型设置入口;不传就没有入口(资源不是 manifest 资产时宿主不传)。
*/
onEditCategory?: () => void;
onClose: () => void;
};
@@ -44,6 +67,7 @@ export function ResourceInfoPanelView({
sourceLayer,
viewport,
canvasSize,
onEditCategory,
onClose,
}: ResourceInfoPanelViewProps) {
const style = resolveResourceInfoPanelStyle({
@@ -75,7 +99,10 @@ export function ResourceInfoPanelView({
<X size={14} aria-hidden="true" />
</button>
</header>
<ResourceInfoFieldsView resource={resource} />
<ResourceInfoFieldsView
resource={resource}
onEditCategory={onEditCategory}
/>
</section>
);
}
@@ -0,0 +1,238 @@
import '../../features/project-workspace/resourceTypePanel.css';
import { Check } from 'lucide-react';
import {
type KeyboardEvent as ReactKeyboardEvent,
useRef,
useState,
} from 'react';
import { PlatformNavigableListItem } from '../../../../../packages/shared/src/components/PlatformNavigableListItem';
import {
GAME_CREATION_APP_ASSET_CATEGORIES,
type GameCreationAppAssetCategory,
gameCreationAppAssetCategory,
type GameCreationAppAssetManifestEntry,
gameCreationAppAssetTags,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { ThemedModal } from '../../components/modal/ThemedModal';
import { resourceReferenceCategoryLabel } from '../../features/project-workspace/resourceReferences';
import { projectAssetCommandErrorMessage } from './projectAssetCommandErrorMessage';
import { resourceAssetDisplayName } from './resourceAssetDisplayName';
type UpdateLocalProjectResourceClassificationResult = {
asset: GameCreationAppAssetManifestEntry;
committedProjectRevision: number;
};
/**
* 素材类型(功能分类)选项 = 合法分类枚举 × 既有中文展示名。
*
* 展示名只从 `resourceReferenceCategoryLabel`(筛选与栏目的同一份口径)取,
* 不在业务页另写一张译名表 —— 面板说「角色与对象」而栏目说别的,用户会以为是两个东西。
*/
const RESOURCE_TYPE_CATEGORY_OPTIONS = GAME_CREATION_APP_ASSET_CATEGORIES.map(
(category) => ({
id: category,
label: resourceReferenceCategoryLabel(category),
}),
);
function resourceTypeErrorMessage(error: unknown) {
// 项目身份 / 版本 CAS 拒绝翻成用户可读中文,其余原样透出;
// 与重命名、删除、标签共用同一份映射。
return projectAssetCommandErrorMessage(error, '设置素材类型失败');
}
type ResourceTypePanelProps = {
projectPath: string;
projectId: string;
asset: GameCreationAppAssetManifestEntry;
onClose: () => void;
onSaved: (result: UpdateLocalProjectResourceClassificationResult) => void;
};
/**
* 「设置素材类型」面板:素材类型(功能分类)的独立入口,与「编辑素材标签」彻底分家。
*
* 拆开的理由是原设计的动作语义错位 —— 类型 chip 曾长在标签弹窗里,点它只改本地 state,
* 而全弹窗唯一的保存入口是标签的「添加」。于是「改类型」必须借道一个语义上是"加标签"的
* 按钮,只选类型就直接关窗(点遮罩 / Esc / ×)则改动静默丢失。
*
* 本面板把动作压成一步:**选中即落盘**,不再有也只不需要任何标签动作。
*
* 三个口径要点:
* 1. **显示**用读显示口径 `gameCreationAppAssetCategory`:它与画布栏目、资源卡角标同一份
* 读数,用户看到的选中项恰好就是他看到的那一栏。
* 2. **写回**用用户当次点的那个值,且只写这一个字段;`tags` 逐字回传
* `gameCreationAppAssetTags(asset)`(落盘原值),不使用任何读时自愈口径
* —— 改类型不许顺手改标签,也不许把自愈出来的值写回去。
* 3. **没碰过就不写**:面板本身不产生"打开即写"或"关闭时补写",没有用户动作就没有写入。
* 另一半对照(用户主动选了就必须写)由 `tests/resourceTypePanel.test.tsx` 钉住。
*/
export function ResourceTypePanel({
projectPath,
projectId,
asset,
onClose,
onSaved,
}: ResourceTypePanelProps) {
/**
* 保存在飞时先把用户点的那一项显出来(否则 await 期间面板像没反应)。
* 写入失败就退回显示口径,不留一个"看起来成功"的选中态。
*/
const [pendingCategory, setPendingCategory] =
useState<GameCreationAppAssetCategory | null>(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const activeCategory = pendingCategory ?? gameCreationAppAssetCategory(asset);
const optionsRef = useRef<HTMLDivElement | null>(null);
/**
* 单选组的键盘口径:Tab 进组只停一次(roving tabindex,见下面的 `tabIndex`);
* 方向键在选项之间移动**焦点**Enter/Space`<button>` 的原生行为)才落盘。
*
* 方向键刻意不顺手选中:这里的"选中"是一次 CAS 写盘动作,"浏览选项"不该变成连环写盘。
* 读屏仍能逐项读到"选项名 + 已选中/未选中"`aria-checked`),所以浏览时不丢上下文。
* 走到头不越界(不回卷):单选组里回卷会让焦点从最后一项跳回第一项,方向感丢失。
*/
function handleOptionKeyDown(
event: ReactKeyboardEvent<HTMLButtonElement>,
index: number,
) {
const step =
event.key === 'ArrowDown' || event.key === 'ArrowRight'
? 1
: event.key === 'ArrowUp' || event.key === 'ArrowLeft'
? -1
: 0;
if (step === 0) return;
event.preventDefault();
// 不在子组件上挂 ref(共享列表行不透传 ref),按住处从自己的容器里数。
const options =
optionsRef.current?.querySelectorAll<HTMLButtonElement>('[role="radio"]');
if (!options || options.length === 0) return;
const target =
options[Math.min(Math.max(index + step, 0), options.length - 1)];
target?.focus();
}
async function saveResourceType(
category: GameCreationAppAssetCategory,
): Promise<void> {
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
setError('设置素材类型需要在客户端内保存');
return;
}
setSaving(true);
setError(null);
try {
const status = await invoke<{ revision: number }>(
'get_local_game_project_revision',
{ projectPath },
);
if (!Number.isSafeInteger(status.revision) || status.revision < 0) {
throw new Error('项目 revision 无效');
}
const result =
await invoke<UpdateLocalProjectResourceClassificationResult>(
'update_local_project_resource_classification',
{
input: {
projectPath,
expectedProjectId: projectId,
expectedProjectRevision: status.revision,
assetId: asset.id,
// 用户点的就是落盘值:不套用读时自愈,也不和"是否碰过控件"分叉。
category,
// 类型面板不改标签:逐字回传落盘原值。
tags: gameCreationAppAssetTags(asset),
},
},
);
onSaved(result);
} catch (saveError) {
setPendingCategory(null);
setError(resourceTypeErrorMessage(saveError));
} finally {
setSaving(false);
}
}
return (
<ThemedModal
open
ariaLabel="设置素材类型"
onClose={onClose}
// 保存在飞时不许用 Escape / 点遮罩把面板关掉:关掉后迟到的 `onSaved`
// 会打到一个已经卸载的面板上。头部 × 同样按 `saving` 禁用。
closeOnBackdrop={!saving}
closeOnEscape={!saving}
panelClassName="game-approval-dialog game-resource-type-dialog"
>
<header>
<div>
<h2></h2>
<p>{resourceAssetDisplayName(asset.localPath)}</p>
</div>
<button
type="button"
aria-label="关闭设置素材类型"
disabled={saving}
onClick={onClose}
>
×
</button>
</header>
<div className="game-resource-type-body">
{/* 只说这一屏要选什么,不写规则说明或开发解释。 */}
<p className="game-resource-type-hint"></p>
{/*
纵向单选列表(`role="radiogroup"` + 每项 `role="radio"`):
一行一个选项,不再横排成一条 —— 6 项挤在一行时窄屏会互相叠字。
选中项就是这张卡当前所在的画布栏目;点任意一项即落盘(含点当前已选中的那一项:
用户显式确认归属,不做隐式 no-op)。视觉选中态由 `aria-checked="true"` 驱动,
与读屏读到的状态是同一个属性。
*/}
<div
ref={optionsRef}
role="radiogroup"
aria-label="素材类型"
className="game-resource-type-options"
>
{RESOURCE_TYPE_CATEGORY_OPTIONS.map((option, index) => {
const active = option.id === activeCategory;
return (
<PlatformNavigableListItem
key={option.id}
role="radio"
aria-checked={active}
// roving tabindex:只有选中项进入 Tab 序列,Tab 进组只停一次。
tabIndex={active ? 0 : -1}
disabled={saving}
className="game-resource-type-option"
trailing={
active ? <Check size={14} aria-hidden="true" /> : null
}
onClick={() => {
setPendingCategory(option.id);
void saveResourceType(option.id);
}}
onKeyDown={(event) => handleOptionKeyDown(event, index)}
>
{option.label}
</PlatformNavigableListItem>
);
})}
</div>
{error ? (
<p className="game-resource-type-error" role="alert">
{error}
</p>
) : null}
</div>
</ThemedModal>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,12 @@
/**
* 素材名取 `localPath` 的 basenamemanifest 资产没有独立的显示名字段,
* 与资源卡、`@` 面板的显示口径一致。
*
* 「编辑素材标签」与「设置素材类型」两块面板共用这一份 —— 副标题是同一个素材名,
* 两处各写一份 basename 实现迟早会出现一个带目录、一个不带。
*/
export function resourceAssetDisplayName(localPath: string) {
const normalized = localPath.replaceAll('\\', '/');
const segments = normalized.split('/');
return segments[segments.length - 1] || localPath;
}
@@ -43,6 +43,19 @@ export type ProjectResourceCardPreviewPayload = {
byteLen: number;
pixelWidth?: number;
pixelHeight?: number;
/**
* 这张图是否**真的**带 alpha 通道(原生侧头部级判据,见 `image_inspect.rs` 的
* `detect_raster_image_has_alpha`):PNG 颜色类型 4/6 或 `tRNS`、WebP 的 alpha 标志为 true
* JPEG 恒 false。
*
* 资源卡的棋盘格底只按它铺(`data-preview-has-alpha='true'`),不再按「预览分支是图片」
* 无条件铺 —— 否则 AI 把棋盘格画进像素里的不透明图会与卡面棋盘格叠在一起,
* 验收时无法区分「真透明底」与「假棋盘格」。
*
* 只有 `read_local_project_image_preview` 这条图像读取链路会给出该字段;文本 / 媒体预览的
* payload 没有它(`undefined`),必须与 `false` 同档处理:不知道就不铺棋盘格。
*/
hasAlpha?: boolean;
sourceUrl?: string;
content?: string;
};
@@ -67,6 +67,15 @@ type ResourceLayoutWriteIntent = {
scopeEpoch: number;
resourceSignature: string;
conflictRetries: number;
/**
* 显式「整理画布」写意图:这一笔写回按 `rederive` 策略丢掉全部自动坐标重算。
*
* 仍然是"坐标真的变了才落盘"(沿用既有 `changed` 门):已经整齐的画布按一下不该产生
* 一次无意义的 CAS / revision 推进,也不该在关系图被截断这类"重算结果同样可信但不能
* 声称变过"的场景里凭空写一笔。自动(签名变化触发)的资源同步永远是 `false`,
* 只补新卡、不动既有坐标。
*/
rederive: boolean;
};
type LayoutWriteIntent = ManualLayoutWriteIntent | ResourceLayoutWriteIntent;
@@ -74,9 +83,10 @@ type LayoutWriteIntent = ManualLayoutWriteIntent | ResourceLayoutWriteIntent;
const MAX_RESOURCE_SYNC_CONFLICT_RETRIES = 2;
/**
* 自动坐标策略。`rederive` 在每次协调时丢弃全部自动坐标按当前资源与拓扑重算
* PRD 要求的「关系图首次就绪 / `dependencyDepth` / 拓扑身份签名变化后按最终拓扑
* 重算」依赖它;`preserve` 只补新资源 ID,不重排任何已存在的坐标
* 自动坐标策略。`rederive` 丢掉全部自动坐标按当前资源与拓扑重算(手动坐标原样保留);
* 它现在只由显式动作触发——用户的「整理画布」按钮,以及「关系图首次就绪」那一次
* `rederiveNow()`。`preserve` 只补新资源 ID,不重排任何已存在的坐标,是画布默认口径:
* 新增一张素材不再牵动整张画布。
*/
type AutomaticPositionPolicy = 'rederive' | 'preserve';
@@ -532,6 +542,7 @@ export function useProjectResourceCanvasLayout({
scopeEpoch,
resourceSignature: signature,
conflictRetries,
rederive: false,
});
}
if (mountedRef.current) {
@@ -565,7 +576,9 @@ export function useProjectResourceCanvasLayout({
const writePolicy =
intent.kind === 'manual'
? MANUAL_WRITE_AUTOMATIC_POSITION_POLICY
: automaticPositionPolicy(rederiveAutomaticPositions);
: intent.rederive
? 'rederive'
: automaticPositionPolicy(rederiveAutomaticPositions);
const reconciled = reconcileLayout(
persistedLayoutRef.current,
resourcesRef.current,
@@ -686,6 +699,9 @@ export function useProjectResourceCanvasLayout({
if (intent.kind === 'manual') {
redragRequiredScopeEpochRef.current = null;
setNotice('布局已保存');
} else if (intent.rederive) {
// 显式整理复用同一条提示:用户按了按钮,就必须看到"这次重算真的落盘了"。
setNotice('布局已保存');
}
if (needsResourceSync) {
enqueueResourceSyncRef.current(currentScope.epoch);
@@ -1004,6 +1020,48 @@ export function useProjectResourceCanvasLayout({
[applyLayout, initializationReady, scopeKey],
);
/**
* 显式「整理画布」:丢掉全部自动坐标、按当前资源与拓扑重算一次;坐标确有变化时写回
* sidecar(沿用既有 `changed` 门),画布本身先乐观按重算结果显示。
*
* 这是整张画布重排的唯一入口——画布不再因为资源协调签名变化自动重派生。走的是与自动
* 同步同一条写队列与写回链路,只把策略换成 `rederive`;用户可见反馈仍由既有的
* `notice` / `saving` 状态位承担,不另造一套状态。
*/
const rederiveNow = useCallback(() => {
const scope = scopeRef.current;
if (
scope.key !== scopeKey ||
!initializationReady ||
initializedScopeEpochRef.current !== scope.epoch
) {
return;
}
const queued = writeQueueRef.current.find(
(intent): intent is ResourceLayoutWriteIntent =>
intent.kind === 'resources' &&
intent.scopeEpoch === scope.epoch &&
intent.rederive &&
intent !== activeWriteIntentRef.current,
);
if (queued) {
queued.resourceSignature = resourceSignatureRef.current;
queued.conflictRetries = 0;
} else {
writeQueueRef.current.push({
kind: 'resources',
scopeEpoch: scope.epoch,
resourceSignature: resourceSignatureRef.current,
conflictRetries: 0,
rederive: true,
});
}
// 乐观视图:立刻把重算结果显示出来,别让用户以为按钮没反应。
rebuildOptimisticLayout(scope.epoch, 'rederive');
setSaving(true);
pumpWritesRef.current();
}, [initializationReady, rebuildOptimisticLayout, scopeKey]);
const scopeMatches =
initializationReady &&
scopeRef.current.key === scopeKey &&
@@ -1035,5 +1093,6 @@ export function useProjectResourceCanvasLayout({
readReport,
scopeIdentity: scopeKey,
commitPosition,
rederiveNow,
};
}
@@ -164,6 +164,7 @@ function materializeProjectResourceCardPreview(
byteLen: transport.byteLen,
pixelWidth: transport.pixelWidth,
pixelHeight: transport.pixelHeight,
hasAlpha: transport.hasAlpha,
content: transport.content,
},
retainedBytes:
@@ -200,6 +201,8 @@ function materializeProjectResourceCardPreview(
byteLen: transport.byteLen,
pixelWidth: imageDimensions?.pixelWidth,
pixelHeight: imageDimensions?.pixelHeight,
// 头部级 alpha 判据随图像预览 payload 一起透传:卡面棋盘格底只认它。
hasAlpha: transport.hasAlpha,
sourceUrl: objectUrl,
},
retainedBytes: blob.size,
File diff suppressed because it is too large Load Diff

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