From 12ef866b667c98bd82aff19a5e993b48f96bf8f1 Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 29 Jun 2026 16:19:28 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E7=94=BB=E5=B8=83=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E5=88=97=E8=A1=A8=E4=B8=8E=E8=BF=9C=E7=A8=8B=E6=8A=A0?= =?UTF-8?q?=E5=9B=BE=E6=8E=A5=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增图片画布任务侧栏页签、滚轮隔离、完成时间、后端计时和运行中状态展示。 限制外部生成任务只显示当前项目,并让任务点击聚焦画布内容和快速编辑源图。 接入外部生成任务列表查询状态过滤、提示词摘要和后端任务映射。 补充 BiRefNet 抠图请求日志、任务耗时提示和相关文档。 补充侧栏、画布聚焦、外部任务契约和 api-server 定向测试。 --- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 2 +- ...端架构】外部生成Worker化方案-2026-06-03.md | 2 +- .../src/contracts/externalGeneration.ts | 1 + .../crates/api-server/src/editor_project.rs | 59 +- .../api-server/src/external_generation.rs | 149 ++++- .../src/external_generation.rs | 1 + .../src/mapper/external_generation.rs | 2 + ...external_generation_job_list_input_type.rs | 1 + .../src/external_generation.rs | 37 +- .../ImageCanvasEditorShellView.test.tsx | 4 + .../image-editor/ImageCanvasEditorTypes.ts | 3 + .../image-editor/ImageCanvasEditorView.tsx | 119 +++- .../ImageCanvasInteractionModel.test.ts | 16 + .../ImageCanvasInteractionModel.ts | 41 +- .../image-editor/ImageCanvasStageView.tsx | 16 +- .../ImageCanvasTaskSidebarView.test.tsx | 240 ++++++++ .../ImageCanvasTaskSidebarView.tsx | 541 +++++++++++++++++- .../useCanvasGenerationDialogs.ts | 88 ++- .../useImageCanvasGenerationWorkflow.ts | 1 + src/index.css | 69 ++- .../externalGenerationClient.ts | 5 + 21 files changed, 1327 insertions(+), 70 deletions(-) create mode 100644 src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 6a0321784..48b8ce6e5 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -108,7 +108,7 @@ - 快速编辑面板底部必须显示当前选择的比例 / 尺寸和模型,参数按钮可展开修改且视觉对齐其它可编辑面板;打开时视口聚焦必须预留底部面板空间,面板位于素材下方,不得遮挡原素材,且素材在当前屏幕内完整可见。快速编辑请求只把原图作为 `sourceImageSrc` 直接提交,信息面板输入快照只展示用户额外选择的参考图。 - 点击生成、生成规范、生成角色形象或生成图标素材后创建的占位图可继续保留;点击画布空白区域让当前图片或占位图失焦时,关闭当前生成面板并移除图片选中样式,但不删除占位图本身。 - 生成资源显示元数据按钮,元数据窗口展示来源、生成输入快照、model、task、Resolution 和 OSS 引用;生成输入快照只包含用户面板输入和参考图行引用,不包含后端拼接 Prompt,不再展示独立 Size 字段,也不渲染参考图 Data URL 缩略图。 -- 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、重绘、下载按钮。裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩展区域透明填充。去除背景调用同源 BFF `POST /api/editor/images/background-removals`,由 api-server 代理远端 BiRefNet 服务并持久化结果,完成后用新的 project resource 引用替换当前图层,并在右上角本地任务侧栏展示耗时和状态。UI设计图的提取素材必须先进入红框素材框选状态,默认启用矩形框选,右侧框选工具与快速编辑统一且可再次点击取消启用态,当前启用工具按钮必须保持高亮。素材提取面板必须在素材下方,使用与生成新素材一致的面板宽度和底部模型 / 按钮样式,提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选区域截图预览、固定模型 `gpt-image-2`、左下角计划规格 `1:1·1K/2K` 和 `提取 · N泥点` 按钮,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。至少框选一个区域后才可提交,前端把红色轮廓绘入原图后固定走 `gpt-image-2` 和提示词 `仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板或绿色阴影。`,生成的 spritesheet 原图和拆分后的独立素材都作为画布图层保留。 +- 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、重绘、下载按钮。裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩展区域透明填充。去除背景调用同源 BFF `POST /api/editor/images/background-removals`,由 api-server 代理远端 BiRefNet 服务并持久化结果,完成后用新的 project resource 引用替换当前图层,并在右上角本地任务侧栏展示耗时和状态。画布任务侧栏按“排队/生成中”和“已完成”分页,生成中排在排队前,生成中耗时从任务开始时间戳实时计算;完成态生成任务副标题显示用户提示词并单行截断;点击任务只聚焦对应画布内容,不激活生成面板或改变任务顺序,聚焦时必须预留图片上方工具栏、底部工具栏和可见生成对话框空间。UI设计图的提取素材必须先进入红框素材框选状态,默认启用矩形框选,右侧框选工具与快速编辑统一且可再次点击取消启用态,当前启用工具按钮必须保持高亮。素材提取面板必须在素材下方,使用与生成新素材一致的面板宽度和底部模型 / 按钮样式,提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选区域截图预览、固定模型 `gpt-image-2`、左下角计划规格 `1:1·1K/2K` 和 `提取 · N泥点` 按钮,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。至少框选一个区域后才可提交,前端把红色轮廓绘入原图后固定走 `gpt-image-2` 和提示词 `仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板或绿色阴影。`,生成的 spritesheet 原图和拆分后的独立素材都作为画布图层保留。 - 重绘生成资源后,右侧出现新生成结果图层,并自动 fit 原图 + 新图,且重绘面板保持打开。 - 快速编辑 / 重绘站内 public 示例图、历史 generated 图或 OSS generated 图时,前端先读取成 `data:image/*;base64,...` 再提交,后端不得再收到 `/creation-type-references/*`、`/generated-*` 或 OSS URL 作为 `referenceImageSrcs/sourceImageSrc`。 - 快速编辑的额外参考图既进入输入快照,也作为 `/api/editor/images/edits` 的 `referenceImageSrcs` 提交给后端;原图仍只作为 `sourceImageSrc`,不占用额外参考图额度。 diff --git a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md index 6093f3207..db360fac8 100644 --- a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md +++ b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md @@ -38,7 +38,7 @@ 队列状态对前端只通过 `api-server` BFF 暴露,不允许前端直接查询 SpacetimeDB private table: - `GET /api/runtime/external-generation/queue-overview`:当前账号队列概览,用于兼容旧展示和轻量状态读取。返回 pending、running、未确认终态数量和更新时间。 -- `GET /api/runtime/external-generation/jobs?limit=20&includeAcknowledgedTerminal=false`:当前账号正式生成任务列表,用于 `我的` 页签任务列表和完成 / 失败提示。返回每个任务的 job id、kind、source、可展示 label、状态、进度、错误、`priceMudPoints`、`refundLedgerId`、`notificationAcknowledgedAt` 和时间戳。默认不返回已确认的终态任务。 +- `GET /api/runtime/external-generation/jobs?limit=20&includeAcknowledgedTerminal=false`:当前账号正式生成任务列表,用于 `我的` 页签任务列表和完成 / 失败提示。返回每个任务的 job id、kind、source、可展示 label、状态、进度、错误、`priceMudPoints`、`refundLedgerId`、`notificationAcknowledgedAt` 和时间戳。默认不返回已确认的终态任务;需要拆分活跃和完成列表时可追加 `statuses=running,queued` 或 `statuses=completed,failed`,BFF 仍只返回当前账号任务。 - `POST /api/runtime/external-generation/jobs/acknowledge`:生成完成 / 失败提示展示后由前端后台调用,BFF 只传当前账号 job ids,后端只确认属于当前账号且已终态的任务。 - `GET /api/runtime/external-generation/jobs/{jobId}`:单 job 状态,用于生成页轮询某次动作。返回 `jobId`、`jobKind`、`sourceModule`、`sourceEntityId`、`status`、`attempt`、`maxAttempts`、`createdAt`、`startedAt`、`completedAt`、`updatedAt`、可展示的 `requestLabel`、可展示的 `lastErrorMessage`、以及业务侧下一次轮询所需的 source 标识。 diff --git a/packages/shared/src/contracts/externalGeneration.ts b/packages/shared/src/contracts/externalGeneration.ts index c2ab99ef3..24919afec 100644 --- a/packages/shared/src/contracts/externalGeneration.ts +++ b/packages/shared/src/contracts/externalGeneration.ts @@ -35,6 +35,7 @@ export interface ExternalGenerationTaskRecord { sourceModule: string; sourceEntityId: string; requestLabel: string; + requestPrompt?: string | null; status: ExternalGenerationJobStatus; phaseLabel: string; phaseDetail: string; diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index bee07b65b..880c3934a 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -1846,13 +1846,26 @@ async fn request_editor_background_removal_image( source_image: OpenAiReferenceImage, ) -> Result { let url = editor_background_removal_endpoint(state)?; + let call_id = format!("background-removal-call-{}", current_utc_micros()); + let request_started_at = Instant::now(); + let timeout_ms = state + .config + .editor_background_removal_request_timeout_ms + .max(1); + let input_bytes = source_image.bytes.len(); + let source_file_name = source_image.file_name.clone(); + let source_mime_type = source_image.mime_type.clone(); + tracing::info!( + %call_id, + upstream_url = %url, + file_name = %source_file_name, + mime_type = %source_mime_type, + input_bytes, + timeout_ms, + "editor_background_removal_request_start" + ); let http_client = reqwest::Client::builder() - .timeout(std::time::Duration::from_millis( - state - .config - .editor_background_removal_request_timeout_ms - .max(1), - )) + .timeout(std::time::Duration::from_millis(timeout_ms)) .build() .map_err(|error| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ @@ -1884,13 +1897,29 @@ async fn request_editor_background_removal_image( let response = request .send() .await - .map_err(map_editor_background_removal_error)?; + .map_err(|error| { + tracing::warn!( + %call_id, + elapsed_ms = request_started_at.elapsed().as_millis() as u64, + timeout = error.is_timeout(), + error = %error, + "editor_background_removal_request_failed" + ); + map_editor_background_removal_error(error) + })?; let status = response.status(); if !status.is_success() { let message = response .text() .await .unwrap_or_else(|error| format!("读取上游错误失败:{error}")); + tracing::warn!( + %call_id, + upstream_status = status.as_u16(), + elapsed_ms = request_started_at.elapsed().as_millis() as u64, + upstream_message = %message.chars().take(160).collect::(), + "editor_background_removal_upstream_rejected" + ); return Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "birefnet", @@ -1907,6 +1936,11 @@ async fn request_editor_background_removal_image( .and_then(normalize_editor_reference_image_mime_type) .unwrap_or("image/png") .to_string(); + let upstream_elapsed_ms = response + .headers() + .get("x-birefnet-elapsed-ms") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()); let bytes = read_editor_background_removal_bytes(response).await?; if bytes.is_empty() { return Err( @@ -1922,6 +1956,17 @@ async fn request_editor_background_removal_image( extension: editor_reference_image_extension(mime_type.as_str()).to_string(), mime_type, }; + tracing::info!( + %call_id, + status = status.as_u16(), + width = decoded.width(), + height = decoded.height(), + input_bytes, + output_bytes = image.bytes.len(), + elapsed_ms = request_started_at.elapsed().as_millis() as u64, + upstream_elapsed_ms, + "editor_background_removal_request_done" + ); Ok(EditorBackgroundRemovalImage { image, diff --git a/server-rs/crates/api-server/src/external_generation.rs b/server-rs/crates/api-server/src/external_generation.rs index 2ae45190d..0757cbc5a 100644 --- a/server-rs/crates/api-server/src/external_generation.rs +++ b/server-rs/crates/api-server/src/external_generation.rs @@ -5,7 +5,7 @@ use axum::{ response::Response, }; use serde::Deserialize; -use serde_json::json; +use serde_json::{Value, json}; use shared_contracts::external_generation::{ ExternalGenerationJobStatus, ExternalGenerationJobStatusRecord, ExternalGenerationJobStatusResponse, ExternalGenerationQueueOverview, @@ -31,6 +31,7 @@ const EXTERNAL_GENERATION_PROVIDER: &str = "external_generation"; pub struct ExternalGenerationTaskListQuery { limit: Option, include_acknowledged_terminal: Option, + statuses: Option, } pub async fn get_external_generation_queue_overview( @@ -45,6 +46,7 @@ pub async fn get_external_generation_queue_overview( owner_user_id, limit: 1, include_acknowledged_terminal: false, + statuses: Vec::new(), }) .await .map_err(|error| external_generation_error_response(&request_context, error))?; @@ -64,26 +66,33 @@ pub async fn list_external_generation_tasks( Query(query): Query, ) -> Result, Response> { let owner_user_id = authenticated.claims().user_id().to_string(); + let requested_limit = query.limit.unwrap_or(50).clamp(1, 100); + let status_filter = parse_external_generation_status_filter(query.statuses.as_deref()); + let statuses = external_generation_status_filter_input(&status_filter); let list = state .spacetime_client() .list_external_generation_jobs(ExternalGenerationJobListRecordInput { owner_user_id, - limit: query.limit.unwrap_or(50).clamp(1, 100), + limit: requested_limit, include_acknowledged_terminal: query.include_acknowledged_terminal.unwrap_or(false), + statuses, }) .await .map_err(|error| external_generation_error_response(&request_context, error))?; + let overview = map_external_generation_queue_overview(&list); + let mut tasks: Vec<_> = list + .jobs + .into_iter() + .map(map_external_generation_task_record) + .filter(|task| { + status_filter.is_empty() || status_filter.iter().any(|status| status == &task.status) + }) + .collect(); + tasks.truncate(requested_limit as usize); Ok(json_success_body( Some(&request_context), - ExternalGenerationTaskListResponse { - overview: map_external_generation_queue_overview(&list), - tasks: list - .jobs - .into_iter() - .map(map_external_generation_task_record) - .collect(), - }, + ExternalGenerationTaskListResponse { overview, tasks }, )) } @@ -176,12 +185,14 @@ fn map_external_generation_task_record( job: ExternalGenerationJobRecord, ) -> ExternalGenerationTaskRecord { let status_record = map_external_generation_job_status(job.clone()); + let request_prompt = extract_external_generation_request_prompt(&job.request_payload_json); ExternalGenerationTaskRecord { job_id: job.job_id, job_kind: job.job_kind, source_module: job.source_module, source_entity_id: job.source_entity_id, request_label: job.request_label, + request_prompt, status: status_record.status, phase_label: status_record.phase_label, phase_detail: status_record.phase_detail, @@ -198,6 +209,90 @@ fn map_external_generation_task_record( } } +fn extract_external_generation_request_prompt(request_payload_json: &str) -> Option { + let payload: Value = serde_json::from_str(request_payload_json).ok()?; + for key in ["prompt", "promptText", "spritesheetLabel"] { + if let Some(prompt) = payload + .get(key) + .and_then(Value::as_str) + .and_then(normalize_external_generation_prompt_text) + { + return Some(prompt); + } + } + if let Some(prompt) = payload + .get("iconDescriptions") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(Value::as_str) + .filter_map(normalize_external_generation_prompt_text) + .collect::>() + .join("、") + }) + .and_then(|value| normalize_external_generation_prompt_text(&value)) + { + return Some(prompt); + } + payload + .get("generationInputs") + .and_then(|value| value.get("fields")) + .and_then(Value::as_array) + .and_then(|fields| { + fields.iter().find_map(|field| { + let title = field.get("title").and_then(Value::as_str)?.trim(); + if !matches!(title, "prompt" | "gpt_description_prompt") { + return None; + } + field + .get("value") + .and_then(Value::as_str) + .and_then(normalize_external_generation_prompt_text) + }) + }) +} + +fn normalize_external_generation_prompt_text(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +fn parse_external_generation_status_filter( + statuses: Option<&str>, +) -> Vec { + statuses + .unwrap_or_default() + .split(',') + .filter_map(|status| match status.trim() { + "queued" => Some(ExternalGenerationJobStatus::Queued), + "running" => Some(ExternalGenerationJobStatus::Running), + "completed" => Some(ExternalGenerationJobStatus::Completed), + "failed" => Some(ExternalGenerationJobStatus::Failed), + _ => None, + }) + .collect() +} + +fn external_generation_status_filter_input( + statuses: &[ExternalGenerationJobStatus], +) -> Vec { + statuses + .iter() + .map(|status| match status { + ExternalGenerationJobStatus::Queued => "queued", + ExternalGenerationJobStatus::Running => "running", + ExternalGenerationJobStatus::Completed => "completed", + ExternalGenerationJobStatus::Failed => "failed", + }) + .map(str::to_string) + .collect() +} + fn current_utc_micros() -> i64 { shared_kernel::offset_datetime_to_unix_micros(time::OffsetDateTime::now_utc()) } @@ -213,3 +308,37 @@ fn external_generation_error_response( })) .into_response_with_context(Some(request_context)) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_external_generation_status_filter() { + assert_eq!( + parse_external_generation_status_filter(Some("running,queued,unknown")), + vec![ + ExternalGenerationJobStatus::Running, + ExternalGenerationJobStatus::Queued + ] + ); + } + + #[test] + fn extracts_external_generation_request_prompt_from_payload() { + assert_eq!( + extract_external_generation_request_prompt( + r#"{"prompt":" 发光主视觉 ","sourceImageSrc":"data:image/png;base64,secret"}"#, + ) + .as_deref(), + Some("发光主视觉"), + ); + assert_eq!( + extract_external_generation_request_prompt( + r#"{"iconDescriptions":["返回按钮"," 设置按钮 "],"referenceImageSrc":"data:image/png;base64,secret"}"#, + ) + .as_deref(), + Some("返回按钮、设置按钮"), + ); + } +} diff --git a/server-rs/crates/shared-contracts/src/external_generation.rs b/server-rs/crates/shared-contracts/src/external_generation.rs index 87318593b..004574c81 100644 --- a/server-rs/crates/shared-contracts/src/external_generation.rs +++ b/server-rs/crates/shared-contracts/src/external_generation.rs @@ -50,6 +50,7 @@ pub struct ExternalGenerationTaskRecord { pub source_module: String, pub source_entity_id: String, pub request_label: String, + pub request_prompt: Option, pub status: ExternalGenerationJobStatus, pub phase_label: String, pub phase_detail: String, diff --git a/server-rs/crates/spacetime-client/src/mapper/external_generation.rs b/server-rs/crates/spacetime-client/src/mapper/external_generation.rs index 54cd7e046..bbb775796 100644 --- a/server-rs/crates/spacetime-client/src/mapper/external_generation.rs +++ b/server-rs/crates/spacetime-client/src/mapper/external_generation.rs @@ -83,6 +83,7 @@ impl From for ExternalGenerationJobListInp owner_user_id: input.owner_user_id, limit: input.limit, include_acknowledged_terminal: input.include_acknowledged_terminal, + statuses: input.statuses, } } } @@ -270,6 +271,7 @@ pub struct ExternalGenerationJobListRecordInput { pub owner_user_id: String, pub limit: u32, pub include_acknowledged_terminal: bool, + pub statuses: Vec, } #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_list_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_list_input_type.rs index 03ca6384b..1df01420a 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_list_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_list_input_type.rs @@ -10,6 +10,7 @@ pub struct ExternalGenerationJobListInput { pub owner_user_id: String, pub limit: u32, pub include_acknowledged_terminal: bool, + pub statuses: Vec, } impl __sdk::InModule for ExternalGenerationJobListInput { diff --git a/server-rs/crates/spacetime-module/src/external_generation.rs b/server-rs/crates/spacetime-module/src/external_generation.rs index d37a69a0a..bee53cb08 100644 --- a/server-rs/crates/spacetime-module/src/external_generation.rs +++ b/server-rs/crates/spacetime-module/src/external_generation.rs @@ -153,6 +153,7 @@ pub struct ExternalGenerationJobListInput { pub owner_user_id: String, pub limit: u32, pub include_acknowledged_terminal: bool, + pub statuses: Vec, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] @@ -568,6 +569,7 @@ fn list_external_generation_jobs_tx( let now_micros = ctx.timestamp.to_micros_since_unix_epoch(); let (pending_count, running_count, unacknowledged_terminal_count) = count_external_generation_jobs_for_owner(ctx, &owner_user_id); + let status_filter = normalize_external_generation_job_status_filter(&input.statuses); let mut rows = Vec::new(); for row in ctx @@ -579,7 +581,9 @@ fn list_external_generation_jobs_tx( let should_include = input.include_acknowledged_terminal || !is_external_generation_job_terminal(&row) || row.notification_acknowledged_at.is_none(); - if should_include { + let should_include_status = + status_filter.is_empty() || status_filter.iter().any(|status| row.status == *status); + if should_include && should_include_status { rows.push(row); } } @@ -966,6 +970,22 @@ fn external_generation_job_sort_bucket(row: &ExternalGenerationJob) -> u8 { } } +fn normalize_external_generation_job_status_filter(statuses: &[String]) -> Vec<&'static str> { + statuses + .iter() + .filter_map(|status| match status.trim() { + "queued" | EXTERNAL_GENERATION_STATUS_PENDING => { + Some(EXTERNAL_GENERATION_STATUS_PENDING) + } + EXTERNAL_GENERATION_STATUS_RUNNING => Some(EXTERNAL_GENERATION_STATUS_RUNNING), + EXTERNAL_GENERATION_STATUS_COMPLETED => Some(EXTERNAL_GENERATION_STATUS_COMPLETED), + EXTERNAL_GENERATION_STATUS_FAILED => Some(EXTERNAL_GENERATION_STATUS_FAILED), + EXTERNAL_GENERATION_STATUS_CANCELLED => Some(EXTERNAL_GENERATION_STATUS_CANCELLED), + _ => None, + }) + .collect() +} + fn record_external_generation_claimable_age( stats: &mut ExternalGenerationQueueStatsSnapshot, row: &ExternalGenerationJob, @@ -1232,6 +1252,21 @@ mod tests { ); } + #[test] + fn task_list_status_filter_accepts_public_queued_alias() { + assert_eq!( + normalize_external_generation_job_status_filter(&[ + "queued".to_string(), + "running".to_string(), + "unknown".to_string(), + ]), + vec![ + EXTERNAL_GENERATION_STATUS_PENDING, + EXTERNAL_GENERATION_STATUS_RUNNING, + ], + ); + } + #[test] fn worker_ownership_requires_matching_trimmed_worker_id() { let mut row = external_generation_job_fixture(EXTERNAL_GENERATION_STATUS_RUNNING); diff --git a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx index 8dd42d51f..3e10708ce 100644 --- a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx @@ -116,6 +116,7 @@ function createTopbarProps(): ImageCanvasTopbarViewProps { function createStageProps(): ImageCanvasStageViewProps { return { + projectId: 'project-1', canvasViewportRef: createRef(), specToolWrapRef: createRef(), musicToolWrapRef: createRef(), @@ -175,6 +176,9 @@ function createStageProps(): ImageCanvasStageViewProps { onUpdateLayerAssetKind: vi.fn(), onGenerationFramePointerDown: vi.fn(), onActivateGenerationDialog: vi.fn(), + onFocusGenerationDialog: vi.fn(), + onFocusTaskLayer: vi.fn(), + onFocusExternalTask: vi.fn(), onToggleTaskSidebar: vi.fn(), onCropExpandHandlePointerDown: vi.fn(), onOpenQuickEditPanel: vi.fn(), diff --git a/src/components/image-editor/ImageCanvasEditorTypes.ts b/src/components/image-editor/ImageCanvasEditorTypes.ts index 12f20397a..3f85acd9d 100644 --- a/src/components/image-editor/ImageCanvasEditorTypes.ts +++ b/src/components/image-editor/ImageCanvasEditorTypes.ts @@ -228,6 +228,8 @@ export type GenerateDialogState = { aspectRatio?: string; imageSize?: string; errorMessage?: string; + generationStartedAt?: number; + generationFinishedAt?: number; placeholder?: { x: number; y: number; @@ -260,6 +262,7 @@ export type CanvasTaskListItem = { elapsedMs?: number; provider?: string; errorMessage?: string; + focusLayerId?: string; }; export type ImageContextMenuState = { diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index b7d341e02..8b6e858a2 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -16,6 +16,7 @@ import { type EditorProjectSnapshot, loadEditorGenerationPricing, } from '../../services/image-editor/editorProjectClient'; +import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration'; import { shouldShowRechargeEntry } from '../../services/payment/paymentPlatform'; import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient'; import { useAuthUi } from '../auth/AuthUiContext'; @@ -50,7 +51,11 @@ import type { import { getEditorUploadAccept, isImageFile } from './ImageCanvasFileModel'; import { createLayerGenerationDialogDraft } from './ImageCanvasGenerationDialogModel'; import { applyEditorGenerationPricingConfig } from './ImageCanvasGenerationModel'; -import { isCanvasGenerationComposerVisible } from './ImageCanvasOverlayModel'; +import { + isCanvasGenerationComposerVisible, + resolveQuickEditFocusViewport, +} from './ImageCanvasOverlayModel'; +import { fitViewportToBounds } from './ImageCanvasInteractionModel'; import { getCanvasGenerationSelectionId, getSelectedGenerationDialogIds, @@ -78,6 +83,12 @@ import { useImageCanvasViewportControls, } from './useImageCanvasViewportControls'; +const TASK_FOCUS_HORIZONTAL_INSET = 28; +const TASK_FOCUS_TOP_INSET = 82; +const TASK_FOCUS_BOTTOM_INSET = 90; +const TASK_FOCUS_DIALOG_BOTTOM_INSET = 280; +const TASK_FOCUS_PADDING = 18; + function resolveLayerProjectResourceImageSrc(layer: CanvasLayer) { const objectKey = layer.objectKey?.trim(); if (objectKey) { @@ -578,6 +589,108 @@ export function ImageCanvasEditorView() { }, [setGenerateDialog], ); + const focusCanvasBounds = useCallback( + ( + bounds: { x: number; y: number; width: number; height: number }, + options: { reserveDialogSpace?: boolean } = {}, + ) => { + const nextViewport = fitViewportToBounds({ + bounds, + canvasSize, + insets: { + left: TASK_FOCUS_HORIZONTAL_INSET, + right: TASK_FOCUS_HORIZONTAL_INSET, + top: TASK_FOCUS_TOP_INSET, + bottom: + TASK_FOCUS_BOTTOM_INSET + + (options.reserveDialogSpace ? TASK_FOCUS_DIALOG_BOTTOM_INSET : 0), + }, + padding: TASK_FOCUS_PADDING, + }); + setViewport(nextViewport); + }, + [canvasSize, setViewport], + ); + const focusCanvasLayerById = useCallback( + (layerId: string) => { + const targetLayer = layersRef.current.find((layer) => layer.id === layerId); + if (!targetLayer) { + return; + } + selectSingleLayer(targetLayer.id); + setActiveTool('select'); + setActiveSidebarPanel('layers'); + focusCanvasBounds(targetLayer); + }, + [focusCanvasBounds, selectSingleLayer, setActiveSidebarPanel, setActiveTool], + ); + const focusCanvasGenerationDialog = useCallback( + (dialog: CanvasGenerationDialogState) => { + if ( + dialog.mode === 'quick-edit' && + dialog.sourceLayerId && + !dialog.generatedLayerId + ) { + const sourceLayer = layersRef.current.find( + (layer) => layer.id === dialog.sourceLayerId, + ); + if (sourceLayer) { + setSelectedLayerId(sourceLayer.id); + setSelectedLayerIds([sourceLayer.id]); + setActiveTool('generate'); + setActiveSidebarPanel('layers'); + setViewport(resolveQuickEditFocusViewport({ sourceLayer, canvasSize })); + return; + } + } + if (dialog.generatedLayerId) { + focusCanvasLayerById(dialog.generatedLayerId); + return; + } + if (!dialog.placeholder) { + return; + } + setSelectedLayerId(null); + setSelectedLayerIds([getCanvasGenerationSelectionId(dialog.id)]); + setActiveTool('select'); + focusCanvasBounds(dialog.placeholder, { + reserveDialogSpace: isCanvasGenerationComposerVisible(dialog), + }); + }, + [ + canvasSize, + focusCanvasBounds, + focusCanvasLayerById, + setActiveSidebarPanel, + setActiveTool, + setViewport, + ], + ); + const focusExternalGenerationTask = useCallback( + (task: ExternalGenerationTaskRecord) => { + const targetLayer = layersRef.current.find( + (layer) => + layer.taskId === task.jobId || + layer.id === task.sourceEntityId || + layer.resourceId === task.sourceEntityId || + layer.sourceResourceId === task.sourceEntityId, + ); + if (targetLayer) { + focusCanvasLayerById(targetLayer.id); + return; + } + const targetDialog = canvasGenerationDialogsRef.current.find( + (dialog) => + dialog.id === task.sourceEntityId || + dialog.sourceLayerId === task.sourceEntityId || + dialog.generatedLayerId === task.sourceEntityId, + ); + if (targetDialog) { + focusCanvasGenerationDialog(targetDialog); + } + }, + [focusCanvasGenerationDialog, focusCanvasLayerById], + ); const persistGeneratedAsset = useCallback( (layer: CanvasLayer) => { if (authUiRef.current && !authUiRef.current.canAccessProtectedData) { @@ -1548,6 +1661,7 @@ export function ImageCanvasEditorView() { onOpenWallet: openRechargeOrRewardCodeModal, }; const stageProps = { + projectId, canvasViewportRef, specToolWrapRef, musicToolWrapRef, @@ -1612,6 +1726,9 @@ export function ImageCanvasEditorView() { onUpdateLayerAssetKind: updateLayerAssetKind, onGenerationFramePointerDown: handleGenerationFramePointerDown, onActivateGenerationDialog: activateCanvasGenerationDialog, + onFocusGenerationDialog: focusCanvasGenerationDialog, + onFocusTaskLayer: focusCanvasLayerById, + onFocusExternalTask: focusExternalGenerationTask, onToggleTaskSidebar: generationSurface.toggleTaskSidebar, onCropExpandHandlePointerDown: generationSurface.startCropExpandFrameResize, onOpenQuickEditPanel: openLayerGenerationDialog, diff --git a/src/components/image-editor/ImageCanvasInteractionModel.test.ts b/src/components/image-editor/ImageCanvasInteractionModel.test.ts index 7356be03f..0e677d3ee 100644 --- a/src/components/image-editor/ImageCanvasInteractionModel.test.ts +++ b/src/components/image-editor/ImageCanvasInteractionModel.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { createMinimapModel, + fitViewportToBounds, fitViewportToLayers, getCanvasDropPoint, getCanvasPointFromClient, @@ -90,6 +91,21 @@ describe('ImageCanvasInteractionModel', () => { ).toBe(0.025); }); + it('fits bounds inside reserved viewport insets', () => { + const viewport = fitViewportToBounds({ + bounds: { x: 100, y: 100, width: 400, height: 300 }, + canvasSize: { width: 900, height: 640 }, + insets: { top: 80 }, + padding: 20, + }); + + expect(viewport).toEqual({ + x: 150, + y: 110, + scale: 1, + }); + }); + it('maps client points to canvas and world coordinates with safe fallbacks', () => { const rect = { left: 100, top: 50, right: 700, bottom: 450 }; diff --git a/src/components/image-editor/ImageCanvasInteractionModel.ts b/src/components/image-editor/ImageCanvasInteractionModel.ts index 692210672..664c9ca91 100644 --- a/src/components/image-editor/ImageCanvasInteractionModel.ts +++ b/src/components/image-editor/ImageCanvasInteractionModel.ts @@ -141,10 +141,39 @@ export function fitViewportToLayers({ if (!bounds) { return null; } - const boundsWidth = Math.max(1, bounds.maxX - bounds.minX); - const boundsHeight = Math.max(1, bounds.maxY - bounds.minY); - const availableWidth = Math.max(1, canvasSize.width - FIT_VIEW_PADDING * 2); - const availableHeight = Math.max(1, canvasSize.height - FIT_VIEW_PADDING * 2); + return fitViewportToBounds({ + bounds: { + x: bounds.minX, + y: bounds.minY, + width: bounds.maxX - bounds.minX, + height: bounds.maxY - bounds.minY, + }, + canvasSize, + }); +} + +export function fitViewportToBounds({ + bounds, + canvasSize, + insets = {}, + padding = FIT_VIEW_PADDING, +}: { + bounds: { x: number; y: number; width: number; height: number }; + canvasSize: CanvasSize; + insets?: { top?: number; right?: number; bottom?: number; left?: number }; + padding?: number; +}): CanvasViewport { + const boundsWidth = Math.max(1, bounds.width); + const boundsHeight = Math.max(1, bounds.height); + const leftInset = Math.max(0, insets.left ?? 0) + padding; + const rightInset = Math.max(0, insets.right ?? 0) + padding; + const topInset = Math.max(0, insets.top ?? 0) + padding; + const bottomInset = Math.max(0, insets.bottom ?? 0) + padding; + const availableWidth = Math.max(1, canvasSize.width - leftInset - rightInset); + const availableHeight = Math.max( + 1, + canvasSize.height - topInset - bottomInset, + ); const scale = clamp( Math.min(1, availableWidth / boundsWidth, availableHeight / boundsHeight), MIN_SCALE, @@ -152,8 +181,8 @@ export function fitViewportToLayers({ ); return { - x: canvasSize.width / 2 - (bounds.minX + boundsWidth / 2) * scale, - y: canvasSize.height / 2 - (bounds.minY + boundsHeight / 2) * scale, + x: leftInset + availableWidth / 2 - (bounds.x + boundsWidth / 2) * scale, + y: topInset + availableHeight / 2 - (bounds.y + boundsHeight / 2) * scale, scale, }; } diff --git a/src/components/image-editor/ImageCanvasStageView.tsx b/src/components/image-editor/ImageCanvasStageView.tsx index c167180b8..c731cd54c 100644 --- a/src/components/image-editor/ImageCanvasStageView.tsx +++ b/src/components/image-editor/ImageCanvasStageView.tsx @@ -9,6 +9,9 @@ import type { import { ImageCanvasBottomToolbarView } from './ImageCanvasBottomToolbarView'; import { ImageCanvasContextMenusView } from './ImageCanvasContextMenusView'; +import type { + ExternalGenerationTaskRecord, +} from '../../../packages/shared/src/contracts/externalGeneration'; import type { CanvasAssetKind, CanvasClipboard, @@ -38,6 +41,7 @@ import { ImageCanvasUiAssetExtractionOverlayView } from './ImageCanvasUiAssetExt import { ImageCanvasWorldView } from './ImageCanvasWorldView'; export type ImageCanvasStageViewProps = { + projectId?: string | null; canvasViewportRef: RefObject; specToolWrapRef: RefObject; musicToolWrapRef: RefObject; @@ -113,6 +117,9 @@ export type ImageCanvasStageViewProps = { dialog: CanvasGenerationDialogState, ) => void; onActivateGenerationDialog: (dialog: CanvasGenerationDialogState) => void; + onFocusGenerationDialog: (dialog: CanvasGenerationDialogState) => void; + onFocusTaskLayer: (layerId: string) => void; + onFocusExternalTask: (task: ExternalGenerationTaskRecord) => void; onToggleTaskSidebar: () => void; onCropExpandHandlePointerDown: ( event: ReactPointerEvent, @@ -175,6 +182,7 @@ export type ImageCanvasStageViewProps = { }; export function ImageCanvasStageView({ + projectId, canvasViewportRef, specToolWrapRef, musicToolWrapRef, @@ -235,6 +243,9 @@ export function ImageCanvasStageView({ onUpdateLayerAssetKind, onGenerationFramePointerDown, onActivateGenerationDialog, + onFocusGenerationDialog, + onFocusTaskLayer, + onFocusExternalTask, onToggleTaskSidebar, onCropExpandHandlePointerDown, onOpenQuickEditPanel, @@ -433,11 +444,14 @@ export function ImageCanvasStageView({ /> ({ + listExternalGenerationTasks: vi.fn().mockResolvedValue({ + overview: { + pendingCount: 0, + runningCount: 0, + unacknowledgedTerminalCount: 0, + updatedAtMicros: 1, + }, + tasks: [], + }), +})); + +const listExternalGenerationTasksMock = vi.mocked(listExternalGenerationTasks); + +beforeEach(() => { + listExternalGenerationTasksMock.mockClear(); + listExternalGenerationTasksMock.mockResolvedValue({ + overview: { + pendingCount: 0, + runningCount: 0, + unacknowledgedTerminalCount: 0, + updatedAtMicros: 1, + }, + tasks: [], + }); +}); + +function createDialog( + overrides: Partial, +): CanvasGenerationDialogState { + return { + id: overrides.id ?? 'dialog-1', + mode: overrides.mode ?? 'generate', + prompt: overrides.prompt ?? '', + status: overrides.status ?? 'idle', + composerOpen: false, + placeholder: { + x: 0, + y: 0, + width: 320, + height: 320, + originalWidth: 1024, + originalHeight: 1024, + }, + ...overrides, + }; +} + +function createExternalTask( + overrides: Partial = {}, +): ExternalGenerationTaskRecord { + const now = new Date().toISOString(); + return { + jobId: overrides.jobId ?? 'extgen-1', + jobKind: overrides.jobKind ?? 'editor_image_generation', + sourceModule: overrides.sourceModule ?? 'editor-canvas', + sourceEntityId: overrides.sourceEntityId ?? 'project-1', + requestLabel: overrides.requestLabel ?? '图片画布生成图片', + requestPrompt: overrides.requestPrompt ?? '发光猫咪主视觉', + status: overrides.status ?? 'running', + phaseLabel: overrides.phaseLabel ?? '生成图片', + phaseDetail: overrides.phaseDetail ?? '正在生成第 2/4 段。', + progress: overrides.progress ?? 35, + error: overrides.error ?? null, + priceMudPoints: overrides.priceMudPoints ?? 1, + refundLedgerId: overrides.refundLedgerId ?? null, + notificationAcknowledgedAt: overrides.notificationAcknowledgedAt ?? null, + createdAt: overrides.createdAt ?? now, + startedAt: overrides.startedAt ?? now, + completedAt: overrides.completedAt ?? null, + updatedAt: overrides.updatedAt ?? now, + updatedAtMicros: overrides.updatedAtMicros ?? 1, + }; +} + +describe('ImageCanvasTaskSidebarView', () => { + it('groups tasks by tabs and keeps running tasks before queued tasks', async () => { + const focusGenerationDialog = vi.fn(); + const parentWheel = vi.fn(); + render( +
+ +
, + ); + + await waitFor(() => { + expect(listExternalGenerationTasksMock).toHaveBeenCalledTimes(2); + }); + expect(listExternalGenerationTasksMock).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + includeAcknowledgedTerminal: false, + limit: 100, + statuses: ['running', 'queued'], + }), + ); + expect(listExternalGenerationTasksMock).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + includeAcknowledgedTerminal: true, + limit: 20, + statuses: ['completed', 'failed'], + }), + ); + + const activeTitles = screen + .getAllByRole('button') + .map((button) => button.textContent ?? '') + .filter((text) => text.includes('角色图片') || text.includes('图标素材')); + expect(activeTitles[0]).toContain('角色图片'); + expect(activeTitles[1]).toContain('图标素材'); + + const completedTab = screen.getByRole('tab', { name: /已完成/u }); + fireEvent.click(completedTab); + expect(screen.queryByText('视频生成')).not.toBeNull(); + expect(screen.queryByText(/生成一段开场动画/u)).not.toBeNull(); + expect(screen.queryByText(/完成于/u)).not.toBeNull(); + expect(screen.queryByText(/生成完成/u)).toBeNull(); + + fireEvent.wheel(screen.getByLabelText('画布任务列表'), { deltaY: 120 }); + expect(parentWheel).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByText('视频生成')); + expect(focusGenerationDialog).toHaveBeenCalledWith( + expect.objectContaining({ id: 'done-dialog' }), + ); + expect(completedTab.getAttribute('aria-selected')).toBe('true'); + }); + + it('shows only current project external tasks with backend timing and phase detail', async () => { + const startedAt = new Date(Date.now() - 65_000).toISOString(); + const completedAt = new Date(Date.now() - 20_000).toISOString(); + listExternalGenerationTasksMock.mockImplementation( + (options: Parameters[0] = {}) => + Promise.resolve({ + overview: { + pendingCount: 0, + runningCount: 1, + unacknowledgedTerminalCount: 1, + updatedAtMicros: 1, + }, + tasks: options.statuses?.includes('running') + ? [ + createExternalTask({ + jobId: 'active-current', + startedAt, + }), + createExternalTask({ + jobId: 'active-other-project', + sourceEntityId: 'project-other', + requestPrompt: '不该显示的外部项目任务', + }), + ] + : [ + createExternalTask({ + jobId: 'done-current', + status: 'completed', + requestPrompt: '已完成提示词', + startedAt: new Date(Date.now() - 90_000).toISOString(), + completedAt, + updatedAt: completedAt, + progress: 100, + phaseDetail: '生成已完成。', + }), + createExternalTask({ + jobId: 'done-other-project', + sourceEntityId: 'project-other', + status: 'completed', + requestPrompt: '另一个项目的已完成任务', + completedAt, + }), + ], + }), + ); + + render( + , + ); + + expect( + await screen.findByText(/正在生成第 2\/4 段。 · 总进度 35%/u), + ).toBeTruthy(); + expect(screen.getByText(/已用时 1分/u)).toBeTruthy(); + expect(screen.queryByText(/不该显示的外部项目任务/u)).toBeNull(); + + fireEvent.click(screen.getByRole('tab', { name: /已完成/u })); + expect(screen.getByText(/已完成提示词/u)).toBeTruthy(); + expect(screen.getByText(/完成于/u)).toBeTruthy(); + expect(screen.queryByText(/另一个项目的已完成任务/u)).toBeNull(); + }); +}); diff --git a/src/components/image-editor/ImageCanvasTaskSidebarView.tsx b/src/components/image-editor/ImageCanvasTaskSidebarView.tsx index 2d0bc7034..ae936f9d6 100644 --- a/src/components/image-editor/ImageCanvasTaskSidebarView.tsx +++ b/src/components/image-editor/ImageCanvasTaskSidebarView.tsx @@ -5,8 +5,17 @@ import { Loader2, XCircle, } from 'lucide-react'; -import { useMemo } from 'react'; +import { + type WheelEvent as ReactWheelEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration'; +import { listExternalGenerationTasks } from '../../services/external-generation'; import { EditorIconButton } from './ImageCanvasEditorPrimitives'; import type { CanvasGenerationDialogState, @@ -14,30 +23,66 @@ import type { CanvasTaskStatus, } from './ImageCanvasEditorTypes'; +const COMPLETED_TASK_LIST_LIMIT = 20; +const ACTIVE_TASK_LIST_LIMIT = 100; +const ACTIVE_TASK_POLL_INTERVAL_MS = 4000; + +type TaskSidebarTab = 'active' | 'completed'; + type TaskSidebarItem = { id: string; title: string; detail: string; status: CanvasTaskStatus; + startedAt?: number; + finishedAt?: number; elapsedMs?: number; errorMessage?: string; onClick?: () => void; + sortTime: number; }; type ImageCanvasTaskSidebarViewProps = { + projectId?: string | null; generationDialogs: CanvasGenerationDialogState[]; backgroundRemovalTasks: CanvasTaskListItem[]; open: boolean; onToggleOpen: () => void; - onActivateGenerationDialog: (dialog: CanvasGenerationDialogState) => void; + onFocusGenerationDialog: (dialog: CanvasGenerationDialogState) => void; + onFocusLayer: (layerId: string) => void; + onFocusExternalTask: (task: ExternalGenerationTaskRecord) => void; }; +function parseTaskTimeMs(value?: string | null) { + if (!value) { + return undefined; + } + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) ? timestamp : undefined; +} + function formatElapsedMs(elapsedMs?: number) { if (typeof elapsedMs !== 'number' || !Number.isFinite(elapsedMs)) { return null; } - const seconds = elapsedMs / 1000; - return seconds >= 10 ? `${Math.round(seconds)}秒` : `${seconds.toFixed(1)}秒`; + const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000)); + if (totalSeconds >= 60) { + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return seconds > 0 ? `${minutes}分${seconds}秒` : `${minutes}分`; + } + return `${Math.max(1, totalSeconds)}秒`; +} + +function formatTaskTimeMs(timestampMs?: number) { + if (typeof timestampMs !== 'number' || !Number.isFinite(timestampMs)) { + return null; + } + const date = new Date(timestampMs); + const pad = (value: number) => String(value).padStart(2, '0'); + return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad( + date.getHours(), + )}:${pad(date.getMinutes())}`; } function generationDialogTitle(dialog: CanvasGenerationDialogState) { @@ -77,6 +122,65 @@ function generationDialogDetail(dialog: CanvasGenerationDialogState) { return dialog.generatedLayerId ? '生成完成' : '待提交'; } +function normalizeTaskPrompt(value?: string | null) { + const prompt = value?.trim(); + return prompt || null; +} + +function generationDialogTaskDetail( + dialog: CanvasGenerationDialogState, + status: CanvasTaskStatus, +) { + if (status === 'done') { + return normalizeTaskPrompt(dialog.prompt) ?? generationDialogTitle(dialog); + } + return generationDialogDetail(dialog); +} + +function externalTaskDetail( + task: ExternalGenerationTaskRecord, + status: CanvasTaskStatus, +) { + if (status === 'failed') { + return task.error?.trim() || task.phaseDetail || task.updatedAt; + } + if (status === 'done') { + return ( + normalizeTaskPrompt(task.requestPrompt) || + task.requestLabel.trim() || + task.jobKind + ); + } + const phaseLabel = task.phaseLabel.trim(); + const phaseDetail = task.phaseDetail.trim(); + const progressLabel = + Number.isFinite(task.progress) && task.progress > 0 + ? `总进度 ${Math.min(100, Math.max(0, Math.round(task.progress)))}%` + : null; + return [phaseLabel, phaseDetail, progressLabel] + .filter((value): value is string => Boolean(value)) + .join(' · '); +} + +function stopTaskSidebarWheel(event: ReactWheelEvent) { + event.stopPropagation(); +} + +function canvasTaskStatusFromExternalTask( + status: ExternalGenerationTaskRecord['status'], +): CanvasTaskStatus { + if (status === 'running') { + return 'running'; + } + if (status === 'queued') { + return 'pending'; + } + if (status === 'failed') { + return 'failed'; + } + return 'done'; +} + function statusLabel(status: CanvasTaskStatus) { if (status === 'pending') { return '待提交'; @@ -103,33 +207,365 @@ function statusIcon(status: CanvasTaskStatus) { return