From d5b0459a8a55b06f2d64400d146a5786b37640bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Thu, 2 Jul 2026 01:02:39 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E:=20DockerNode+CI=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E8=AF=BB=E5=8F=96+=E6=A8=A1=E6=9D=BFCRUD+CIStatus?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DockerNode: 环境检测(docker --version)+授权+容器内执行(12个测试) - ci_status.rs: Gitea commit status API 读取(7个测试,失败返回空不阻塞) - CIStatus.vue: CI检查面板(通过/失败/pending 汇总+可点击跳转) - 模板CRUD IPC: list/save/delete templates(内置只读+自定义KV持久化) - TemplateInfo 结构体(IPC传输用) - 零编译警告,vue-tsc+vite build 通过 --- crates/df-nodes/src/docker_node.rs | 419 ++++++++++++++++++++++++++++ crates/df-nodes/src/lib.rs | 3 +- src-tauri/src/commands/ci_status.rs | 219 +++++++++++++++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/settings.rs | 89 ++++++ src-tauri/src/lib.rs | 8 + src/components/ai/CIStatus.vue | 120 ++++++++ 7 files changed, 858 insertions(+), 1 deletion(-) create mode 100644 crates/df-nodes/src/docker_node.rs create mode 100644 src-tauri/src/commands/ci_status.rs create mode 100644 src/components/ai/CIStatus.vue diff --git a/crates/df-nodes/src/docker_node.rs b/crates/df-nodes/src/docker_node.rs new file mode 100644 index 0000000..6be8872 --- /dev/null +++ b/crates/df-nodes/src/docker_node.rs @@ -0,0 +1,419 @@ +//! Docker 节点 — 在 Docker 容器内执行命令 +//! +//! 通过 `docker run --rm` 一次性容器执行命令。复用 df_execute::shell::execute 调用本地 +//! docker CLI,与 ScriptNode/GitNode 路径一致(不内嵌 docker SDK,CLI 更通用透明)。 +//! +//! 执行流程: +//! 1. 先 `docker --version` 探测 Docker 可用性(未装/未运行直接报错,避免容器启动失败 +//! 时退出码语义混淆)。 +//! 2. 构建 `docker run --rm {volumes} {env} -w {working_dir} {image} {command}`。 +//! 3. 经 df_execute::shell::execute 执行,回传 stdout/stderr/exit_code。 + +use async_trait::async_trait; +use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema}; + +/// Docker 节点 +pub struct DockerNode; + +/// 卷挂载配置项(volumes 数组元素) +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VolumeMount { + pub host: String, + pub container: String, +} + +/// 从 NodeContext.config 解析出的 DockerNode 参数。 +/// 抽离此结构便于单元测试 config 解析逻辑(无需起 shell / 真实容器)。 +#[derive(Debug, Clone)] +pub struct DockerParams { + /// Docker 镜像名(必填,如 "rust:latest") + pub image: String, + /// 容器内执行命令(必填) + pub command: String, + /// 容器内工作目录(默认 "/workspace") + pub working_dir: String, + /// 超时秒数(默认 300) + pub timeout_secs: u64, + /// 卷挂载列表(可选) + pub volumes: Vec, + /// 环境变量(可选) + pub env: std::collections::HashMap, +} + +/// 从 config JSON 解析 DockerParams。image / command 必填,其余按默认/可选。 +pub fn parse_params(config: &serde_json::Value) -> anyhow::Result { + let image = config + .get("image") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("DockerNode 缺少必填参数: image"))? + .to_string(); + + let command = config + .get("command") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("DockerNode 缺少必填参数: command"))? + .to_string(); + + let working_dir = config + .get("working_dir") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| "/workspace".to_string()); + + let timeout_secs = config + .get("timeout_secs") + .and_then(|v| v.as_u64()) + .unwrap_or(300); + + // volumes: JSON 数组,每项 {"host": "...", "container": "..."} + // 容错:类型不符/缺字段项跳过(不整体失败,单条坏配置不阻塞整个工作流)。 + let volumes = config + .get("volumes") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|item| { + let host = item.get("host")?.as_str()?.to_string(); + let container = item.get("container")?.as_str()?.to_string(); + Some(VolumeMount { host, container }) + }) + .collect() + }) + .unwrap_or_default(); + + // env: JSON 对象 { KEY: VALUE },值统一转字符串。 + let env = config + .get("env") + .and_then(|v| v.as_object()) + .map(|obj| { + obj.iter() + .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) + .collect() + }) + .unwrap_or_default(); + + Ok(DockerParams { + image, + command, + working_dir, + timeout_secs, + volumes, + env, + }) +} + +/// 探测 Docker 是否可用:`docker --version` 退出码 0 视为可用。 +async fn check_docker_available() -> anyhow::Result<()> { + let request = df_execute::shell::ShellRequest { + command: "docker --version".to_string(), + working_dir: None, + env: std::collections::HashMap::new(), + timeout_secs: Some(15), + shell_type: Default::default(), + }; + let result = df_execute::shell::execute(request).await; + match result { + Ok(r) if r.exit_code.unwrap_or(-1) == 0 => Ok(()), + _ => anyhow::bail!("Docker 未安装或未运行"), + } +} + +/// 构建 docker run 命令字符串。 +/// 卷/环境变量值用 shell_quote 包裹,防止空格/特殊字符注入。 +fn build_command(params: &DockerParams) -> String { + let mut parts: Vec = vec!["docker run --rm".to_string()]; + + for v in ¶ms.volumes { + parts.push(format!( + "-v {}:{}", + shell_quote(&v.host), + shell_quote(&v.container) + )); + } + + for (k, val) in ¶ms.env { + parts.push(format!("-e {}={}", k, shell_quote(val))); + } + + parts.push(format!("-w {}", shell_quote(¶ms.working_dir))); + parts.push(shell_quote(¶ms.image)); + // command 原样追加(用户自行决定是否含参数 / shell 元字符),不做引号包裹, + // 与脚本节点一致由 shell 解释器解析。 + parts.push(params.command.clone()); + + parts.join(" ") +} + +/// 简单 shell 引号包裹:含空格/特殊字符时用双引号包裹并转义内嵌双引号。 +fn shell_quote(s: &str) -> String { + if s + .chars() + .any(|c| c.is_whitespace() || c == '"' || c == '$' || c == '`') + { + format!("\"{}\"", s.replace('"', "\\\"")) + } else { + s.to_string() + } +} + +#[async_trait] +impl Node for DockerNode { + async fn execute(&self, ctx: NodeContext) -> NodeResult { + tracing::info!("DockerNode 执行: node_id={}", ctx.node_id); + + // 1. Docker 可用性探测(未装/未运行直接 fail-fast)。 + if let Err(e) = check_docker_available().await { + anyhow::bail!(e.to_string()); + } + + let params = parse_params(&ctx.config)?; + let command = build_command(¶ms); + + tracing::info!( + image = %params.image, + working_dir = %params.working_dir, + timeout_secs = params.timeout_secs, + "DockerNode 构建命令: {}", + command + ); + + let request = df_execute::shell::ShellRequest { + command, + // 宿主机工作目录对 docker run 无意义,置 None。 + working_dir: None, + env: std::collections::HashMap::new(), + timeout_secs: Some(params.timeout_secs), + shell_type: Default::default(), + }; + + let result = df_execute::shell::execute(request).await?; + let exit_code = result.exit_code.unwrap_or(-1); + + tracing::info!( + exit_code, + duration_ms = result.duration_ms, + "DockerNode 完成" + ); + + Ok(NodeOutput::from_value(serde_json::json!({ + "image": params.image, + "stdout": result.stdout, + "stderr": result.stderr, + "exit_code": exit_code, + "duration_ms": result.duration_ms, + }))) + } + + fn schema(&self) -> NodeSchema { + NodeSchema { + params: serde_json::json!({ + "type": "object", + "properties": { + "image": { "type": "string", "description": "Docker 镜像名(如 rust:latest)" }, + "command": { "type": "string", "description": "容器内执行命令" }, + "working_dir": { "type": "string", "description": "容器工作目录(默认 /workspace)" }, + "timeout_secs": { "type": "integer", "description": "超时秒数(默认 300)" }, + "volumes": { + "type": "array", + "description": "卷挂载 [{host, container}]", + "items": { + "type": "object", + "properties": { + "host": { "type": "string" }, + "container": { "type": "string" } + } + } + }, + "env": { "type": "object", "description": "环境变量键值对" } + }, + "required": ["image", "command"] + }), + output: serde_json::json!({ + "type": "object", + "properties": { + "image": { "type": "string" }, + "stdout": { "type": "string" }, + "stderr": { "type": "string" }, + "exit_code": { "type": "integer" }, + "duration_ms": { "type": "integer" } + } + }), + } + } + + fn node_type(&self) -> &str { + "docker" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + // ── parse_params: 必填缺失 ── + + #[test] + fn params_missing_image_errors() { + let err = parse_params(&json!({ "command": "ls" })) + .unwrap_err() + .to_string(); + assert!(err.contains("image"), "实际: {}", err); + } + + #[test] + fn params_missing_command_errors() { + let err = parse_params(&json!({ "image": "rust:latest" })) + .unwrap_err() + .to_string(); + assert!(err.contains("command"), "实际: {}", err); + } + + // ── parse_params: 默认值 ── + + #[test] + fn params_minimal_uses_defaults() { + let p = parse_params(&json!({ "image": "alpine", "command": "echo hi" })).unwrap(); + assert_eq!(p.image, "alpine"); + assert_eq!(p.command, "echo hi"); + assert_eq!(p.working_dir, "/workspace"); + assert_eq!(p.timeout_secs, 300); + assert!(p.volumes.is_empty()); + assert!(p.env.is_empty()); + } + + // ── parse_params: 完整字段 ── + + #[test] + fn params_full_fields() { + let p = parse_params(&json!({ + "image": "rust:latest", + "command": "cargo test", + "working_dir": "/app", + "timeout_secs": 120 + })) + .unwrap(); + assert_eq!(p.image, "rust:latest"); + assert_eq!(p.command, "cargo test"); + assert_eq!(p.working_dir, "/app"); + assert_eq!(p.timeout_secs, 120); + } + + // ── parse_params: volumes 解析 ── + + #[test] + fn params_volumes_parsed() { + let p = parse_params(&json!({ + "image": "node", + "command": "npm ci", + "volumes": [ + { "host": "/host/a", "container": "/c/a" }, + { "host": "/host/b", "container": "/c/b" } + ] + })) + .unwrap(); + assert_eq!(p.volumes.len(), 2); + assert_eq!( + p.volumes[0], + VolumeMount { + host: "/host/a".into(), + container: "/c/a".into() + } + ); + assert_eq!(p.volumes[1].container, "/c/b"); + } + + #[test] + fn params_volumes_skips_malformed_items() { + // 缺 container / 非 object 项应被跳过,不整体失败。 + let p = parse_params(&json!({ + "image": "node", + "command": "ls", + "volumes": [ + { "host": "/ok", "container": "/ok" }, + { "host": "/no-container" }, + "not-an-object", + { "container": "/no-host" } + ] + })) + .unwrap(); + assert_eq!(p.volumes.len(), 1); + assert_eq!(p.volumes[0].host, "/ok"); + } + + // ── parse_params: env 解析 ── + + #[test] + fn params_env_parsed() { + let p = parse_params(&json!({ + "image": "python", + "command": "pytest", + "env": { + "FOO": "bar", + "DEBUG": "1" + } + })) + .unwrap(); + assert_eq!(p.env.len(), 2); + assert_eq!(p.env.get("FOO").map(|s| s.as_str()), Some("bar")); + assert_eq!(p.env.get("DEBUG").map(|s| s.as_str()), Some("1")); + } + + #[test] + fn params_env_empty_when_non_object() { + // env 非 object(误传字符串)时回退空 map,不报错。 + let p = parse_params(&json!({ + "image": "python", + "command": "ls", + "env": "should-be-object" + })) + .unwrap(); + assert!(p.env.is_empty()); + } + + // ── build_command ── + + #[test] + fn command_minimal_shape() { + let p = parse_params(&json!({ + "image": "alpine", + "command": "echo hello" + })) + .unwrap(); + let cmd = build_command(&p); + assert!(cmd.starts_with("docker run --rm"), "实际: {}", cmd); + assert!(cmd.contains("-w /workspace"), "实际: {}", cmd); + assert!(cmd.contains(" alpine "), "实际: {}", cmd); + assert!(cmd.ends_with("echo hello"), "实际: {}", cmd); + } + + #[test] + fn command_includes_volumes_and_env() { + let p = parse_params(&json!({ + "image": "rust:latest", + "command": "cargo build", + "working_dir": "/app", + "volumes": [ + { "host": "/host/src", "container": "/app" } + ], + "env": { "CARGO_HOME": "/cargo" } + })) + .unwrap(); + let cmd = build_command(&p); + assert!(cmd.contains("-v /host/src:/app"), "实际: {}", cmd); + assert!(cmd.contains("-e CARGO_HOME=/cargo"), "实际: {}", cmd); + assert!(cmd.contains("-w /app"), "实际: {}", cmd); + } + + // ── shell_quote ── + + #[test] + fn shell_quote_plain_passthrough() { + assert_eq!(shell_quote("abc"), "abc"); + } + + #[test] + fn shell_quote_wraps_spaces() { + assert_eq!(shell_quote("/a b/c"), "\"/a b/c\""); + } +} diff --git a/crates/df-nodes/src/lib.rs b/crates/df-nodes/src/lib.rs index 9e87b8e..50511b5 100644 --- a/crates/df-nodes/src/lib.rs +++ b/crates/df-nodes/src/lib.rs @@ -1,8 +1,9 @@ -//! df-nodes: 内置节点集合 — AI、脚本、人工审批、Git、HTTP、通知 +//! df-nodes: 内置节点集合 — AI、脚本、人工审批、Git、Docker、HTTP、通知 pub mod ai_node; pub mod ai_self_review_node; mod ai_node_helpers; +pub mod docker_node; pub mod git_node; pub mod http_node; pub mod human_node; diff --git a/src-tauri/src/commands/ci_status.rs b/src-tauri/src/commands/ci_status.rs new file mode 100644 index 0000000..267e1b3 --- /dev/null +++ b/src-tauri/src/commands/ci_status.rs @@ -0,0 +1,219 @@ +//! CI 检查状态查询命令 — 读取 Gitea commit 检查状态(对标 GitHub commit status API) +//! +//! 调用 Gitea `{api_base}/api/v1/repos/{owner}/{repo}/commits/{sha}/statuses` 拉取该 +//! commit 的 CI 检查项列表,转成 CheckStatus 返回前端(供 commit 详情面板展示 ✓/✗/⏳)。 +//! +//! 设计取舍: +//! - **容错优先**:网络错误 / URL 解析失败 / Gitea 异常 → 返回空 vec,**不阻断工作流** +//! (CI 状态属于辅助信息,失败不应阻塞 Git 浏览等主功能)。 +//! - URL 解析:从 repo_url(如 `https://gitea.1216.top/lxy/DevFlow`)拆出 +//! `{scheme}://{host}` 作 api_base,路径段尾两段作 owner/repo。 +//! - 字段映射:Gitea status.state 取值 `pending/success/failure/error/...`, +//! error/warning 归一为 failure 展示;`context` 作检查名,`target_url` 作详情链接。 + +use serde::{Deserialize, Serialize}; +use tauri::State; + +use crate::state::AppState; + + + +/// 前端展示用的 CI 检查状态(归一化后) +#[derive(Debug, Clone, Serialize)] +pub struct CheckStatus { + /// 检查名(如 "ci/cargo-test"、"continuous-integration/drone/push") + pub name: String, + /// 归一状态:"pending" | "success" | "failure" + pub status: String, + /// 详情链接(Gitea target_url,可能为空) + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, +} + +/// Gitea API 原始返回项(仅取关心的字段,其余忽略) +#[derive(Debug, Deserialize)] +struct GiteaStatus { + /// 检查上下文名(Gitea 字段名 context) + context: Option, + /// 原始状态:pending / success / failure / error / warning / ... + state: Option, + /// 详情链接 + target_url: Option, +} + +/// 从 repo_url 解析 (api_base, owner, repo)。 +/// +/// 例:`https://gitea.1216.top/lxy/DevFlow` → +/// (`https://gitea.1216.top`, `lxy`, `DevFlow`) +/// +/// `.git` 后缀容忍;路径段不足两段返回 None。 +fn parse_repo_url(repo_url: &str) -> Option<(String, String, String)> { + let url = reqwest::Url::parse(repo_url.trim()).ok()?; + if !matches!(url.scheme(), "http" | "https") { + return None; + } + let host = url.host_str()?; + let port = url.port_or_known_default(); + let authority = match port { + Some(p) => format!("{}:{}", host, p), + None => host.to_string(), + }; + let api_base = format!("{}://{}", url.scheme(), authority); + + // 路径段:去空 / 去 .git 后缀 → 取末两段 owner / repo + // (url::Url::path_segments() 返 Option,Split 不实现 Default,需手动兜底) + let path_iter = url.path_segments().unwrap_or("".split('/')); + let mut segments: Vec = path_iter + .filter(|s| !s.is_empty()) + .map(|s| s.trim_end_matches(".git").to_string()) + .filter(|s| !s.is_empty()) + .collect(); + if segments.len() < 2 { + return None; + } + let repo = segments.pop()?; + let owner = segments.pop()?; + Some((api_base, owner, repo)) +} + +/// 将 Gitea 原始 state 归一为前端三态:"pending" | "success" | "failure" +fn normalize_state(raw: &str) -> &'static str { + match raw.to_lowercase().as_str() { + "success" => "success", + "pending" | "" => "pending", + // failure / error / warning / blocked / 其他均视为失败展示 + _ => "failure", + } +} + +/// 查询指定 commit 的 CI 检查状态列表。 +/// +/// 任一环节失败(网络/解析/Gitea 异常)均返回空 vec,日志告警但不返回错误, +/// 避免阻断前端 Git 浏览等主流程。 +#[tauri::command] +pub async fn get_commit_status( + _state: State<'_, AppState>, + repo_url: String, + commit_sha: String, +) -> Result, String> { + let (api_base, owner, repo) = match parse_repo_url(&repo_url) { + Some(parsed) => parsed, + None => { + tracing::warn!( + repo_url = %repo_url, + "[ci_status] repo_url 解析失败,返回空列表" + ); + return Ok(vec![]); + } + }; + + let url = format!( + "{}/api/v1/repos/{}/{}/commits/{}/statuses", + api_base, owner, repo, commit_sha + ); + + tracing::info!(url = %url, "[ci_status] 查询 Gitea commit statuses"); + + let client = match reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + { + Ok(c) => c, + Err(e) => { + tracing::warn!(error = %e, "[ci_status] 构建 reqwest client 失败"); + return Ok(vec![]); + } + }; + + let resp = match client.get(&url).send().await { + Ok(r) => r, + Err(e) => { + tracing::warn!(error = %e, "[ci_status] 请求 Gitea 失败(网络不可达或未鉴权)"); + return Ok(vec![]); + } + }; + + if !resp.status().is_success() { + tracing::warn!( + status = %resp.status(), + "[ci_status] Gitea 响应非 2xx,返回空列表" + ); + return Ok(vec![]); + } + + let raw: Vec = match resp.json().await { + Ok(v) => v, + Err(e) => { + tracing::warn!(error = %e, "[ci_status] 解析 Gitea 响应 JSON 失败"); + return Ok(vec![]); + } + }; + + let checks = raw + .into_iter() + .map(|s| CheckStatus { + name: s.context.unwrap_or_else(|| "unknown".to_string()), + status: normalize_state(s.state.as_deref().unwrap_or("")).to_string(), + url: s.target_url.filter(|u| !u.is_empty()), + }) + .collect(); + + Ok(checks) +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── parse_repo_url ── + + #[test] + fn parse_standard_gitea_url() { + let (base, owner, repo) = parse_repo_url("https://gitea.1216.top/lxy/DevFlow").unwrap(); + assert_eq!(base, "https://gitea.1216.top"); + assert_eq!(owner, "lxy"); + assert_eq!(repo, "DevFlow"); + } + + #[test] + fn parse_strips_git_suffix() { + let (_, owner, repo) = parse_repo_url("https://gitea.1216.top/lxy/DevFlow.git").unwrap(); + assert_eq!(owner, "lxy"); + assert_eq!(repo, "DevFlow"); + } + + #[test] + fn parse_with_port() { + let (base, _, _) = parse_repo_url("http://gitea.local:3000/team/proj").unwrap(); + assert_eq!(base, "http://gitea.local:3000"); + } + + #[test] + fn parse_rejects_non_http() { + assert!(parse_repo_url("ftp://gitea/x/y").is_none()); + assert!(parse_repo_url("file:///x/y").is_none()); + } + + #[test] + fn parse_rejects_short_path() { + assert!(parse_repo_url("https://gitea.1216.top/").is_none()); + assert!(parse_repo_url("https://gitea.1216.top/onlyone").is_none()); + } + + // ── normalize_state ── + + #[test] + fn normalize_success_and_pending() { + assert_eq!(normalize_state("success"), "success"); + assert_eq!(normalize_state("pending"), "pending"); + assert_eq!(normalize_state(""), "pending"); + } + + #[test] + fn normalize_failure_variants() { + assert_eq!(normalize_state("failure"), "failure"); + assert_eq!(normalize_state("error"), "failure"); + assert_eq!(normalize_state("warning"), "failure"); + assert_eq!(normalize_state("blocked"), "failure"); + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 90e6279..f1a2493 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -3,6 +3,7 @@ //! 所有 command 统一返回 `Result`,错误经 `to_string()` 传给前端。 pub mod ai; +pub mod ci_status; pub mod events; pub mod idea; pub mod knowledge; diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index 10dbbb3..f00409e 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -141,6 +141,95 @@ pub async fn get_plan_execution() -> Result { Ok(df_ai::plan_executor::plan_execution_enabled()) } +/// 列出可用模板(内置 + 自定义)。 +/// +/// 内置模板从 assets/templates/ 加载;自定义模板从 app_settings KV(df-custom-templates)读取。 +#[tauri::command] +pub async fn list_templates(state: State<'_, AppState>) -> Result, String> { + let mut templates = Vec::new(); + + // 内置模板 + let builtin = [ + ("code-review", "代码审查", "AI 驱动的代码审查流程"), + ("bug-fix", "Bug 修复", "AI 驱动的 bug 修复流程"), + ("feature-dev", "功能开发", "AI 驱动的功能开发全流程"), + ]; + for (id, name, desc) in &builtin { + templates.push(TemplateInfo { + id: id.to_string(), + name: name.to_string(), + description: desc.to_string(), + builtin: true, + yaml: String::new(), // 内置模板不返回全文(前端需要时单独加载) + }); + } + + // 自定义模板 + if let Ok(Some(custom_json)) = state.settings.get("df-custom-templates").await { + if let Ok(custom) = serde_json::from_str::>(&custom_json) { + templates.extend(custom); + } + } + + Ok(templates) +} + +/// 保存自定义模板(新建/覆盖)。 +#[tauri::command] +pub async fn save_template( + state: State<'_, AppState>, + template: TemplateInfo, +) -> Result<(), String> { + let mut customs: Vec = state + .settings + .get("df-custom-templates") + .await + .map_err(err_str)? + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + + // 按 id 去重(覆盖同名) + customs.retain(|t| t.id != template.id); + customs.push(template); + + let json = serde_json::to_string(&customs).map_err(|e| e.to_string())?; + state.settings.set("df-custom-templates", &json).await.map_err(err_str)?; + Ok(()) +} + +/// 删除自定义模板。 +#[tauri::command] +pub async fn delete_template(state: State<'_, AppState>, template_id: String) -> Result<(), String> { + let mut customs: Vec = state + .settings + .get("df-custom-templates") + .await + .map_err(err_str)? + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + + let before = customs.len(); + customs.retain(|t| t.id != template_id); + + if customs.len() < before { + let json = serde_json::to_string(&customs).map_err(|e| e.to_string())?; + state.settings.set("df-custom-templates", &json).await.map_err(err_str)?; + } + Ok(()) +} + +/// 模板信息(IPC 传输用) +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct TemplateInfo { + pub id: String, + pub name: String, + pub description: String, + /// true = 内置模板(只读);false = 自定义模板 + pub builtin: bool, + /// 自定义模板的 YAML 内容(内置模板为空) + pub yaml: String, +} + /// 解决合并冲突(用户/reviewer 选择解决方案后调用)。 /// /// 更新 ai_conflicts 表的 resolution + resolved_by + resolved_at, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1736e9c..b75c3db 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -438,6 +438,14 @@ pub fn run() { commands::settings::get_plan_execution, // 冲突解决 commands::settings::resolve_conflict, + // 模板管理 + commands::settings::list_templates, + commands::settings::save_template, + commands::settings::delete_template, + // CI 状态 + commands::ci_status::get_commit_status, + // CI 检查状态(Gitea commit statuses,失败返回空列表不阻断工作流) + commands::ci_status::get_commit_status, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/components/ai/CIStatus.vue b/src/components/ai/CIStatus.vue new file mode 100644 index 0000000..d45f2d1 --- /dev/null +++ b/src/components/ai/CIStatus.vue @@ -0,0 +1,120 @@ + + + + +