Merge pull request 'AGC 画布:生成期间可退出与任务侧栏(高优)、画布手动整理、透明底判据、素材类型独立入口' (#358) from fix/agc-canvas-acceptance-batch into master
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 4m41s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 4m38s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m34s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 4m25s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 4m34s
Project CI / AI game creator shell Rust crates (push) Successful in 2m30s
Project CI / Frontend tests (push) Successful in 4m9s
Project CI / Repository checks (push) Successful in 3m15s
Project CI / Backend tests (push) Successful in 6m49s
Project CI / Native shell tests (push) Successful in 6m28s
Project CI / AI game creator shell web tests (push) Successful in 3m15s
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 4m41s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 4m38s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m34s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 4m25s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 4m34s
Project CI / AI game creator shell Rust crates (push) Successful in 2m30s
Project CI / Frontend tests (push) Successful in 4m9s
Project CI / Repository checks (push) Successful in 3m15s
Project CI / Backend tests (push) Successful in 6m49s
Project CI / Native shell tests (push) Successful in 6m28s
Project CI / AI game creator shell web tests (push) Successful in 3m15s
This commit was merged in pull request #358.
This commit is contained in:
@@ -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
+96
@@ -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-32(colorType 6)应判为有 alpha");
|
||||
// 0 / 2 / 3 本身没有 alpha 通道:这是「AI 把棋盘格画进像素里」那张不透明 PNG 的形状。
|
||||
assert!(!color_type_alpha(0), "colorType 0 不应判为有 alpha");
|
||||
assert!(
|
||||
!color_type_alpha(2),
|
||||
"PNG-24(colorType 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"
|
||||
));
|
||||
// 只有 ICC(0x20)/ EXIF(0x08)等其它标志时不是 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);
|
||||
}
|
||||
+61
-50
@@ -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>
|
||||
|
||||
+263
@@ -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>
|
||||
);
|
||||
}
|
||||
+8
-12
@@ -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}>
|
||||
|
||||
+315
@@ -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);
|
||||
}
|
||||
+328
@@ -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,
|
||||
);
|
||||
}
|
||||
+435
@@ -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;
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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,
|
||||
|
||||
@@ -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%),
|
||||
|
||||
+13
-59
@@ -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` 的 basename:manifest 资产没有独立的显示名字段,
|
||||
* 与资源卡、`@` 面板的显示口径一致。
|
||||
*/
|
||||
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` 的 basename:manifest 资产没有独立的显示名字段,
|
||||
* 与资源卡、`@` 面板的显示口径一致。
|
||||
*
|
||||
* 「编辑素材标签」与「设置素材类型」两块面板共用这一份 —— 副标题是同一个素材名,
|
||||
* 两处各写一份 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;
|
||||
};
|
||||
|
||||
+63
-4
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
+3
@@ -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
Reference in New Issue
Block a user