重构: tool_registry拆分及多批改进

This commit is contained in:
2026-06-19 00:10:14 +08:00
parent a2871a66e0
commit 60b01d03ee
33 changed files with 703 additions and 247 deletions

4
Cargo.lock generated
View File

@@ -795,12 +795,9 @@ name = "df-execute"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait",
"df-types",
"serde", "serde",
"serde_json", "serde_json",
"tokio", "tokio",
"tracing",
] ]
[[package]] [[package]]
@@ -886,7 +883,6 @@ dependencies = [
"futures", "futures",
"serde", "serde",
"serde_json", "serde_json",
"thiserror 2.0.18",
"tokio", "tokio",
"tracing", "tracing",
] ]

View File

@@ -131,27 +131,6 @@ impl Default for AiToolRegistry {
} }
} }
// ============================================================
// 工具执行结果
// ============================================================
/// 工具执行结果
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolExecutionResult {
/// 工具调用 ID
pub tool_call_id: String,
/// 工具名称
pub tool_name: String,
/// 执行参数
pub arguments: Value,
/// 执行结果
pub result: Value,
/// 是否需要人工批准
pub approval_required: bool,
/// 风险级别
pub risk_level: RiskLevel,
}
// ============================================================ // ============================================================
// 辅助: 构建 JSON Schema 参数 // 辅助: 构建 JSON Schema 参数
// ============================================================ // ============================================================

View File

@@ -451,6 +451,107 @@ impl AnthropicCompatProvider {
messages.push(serde_json::json!({ "role": "user", "content": blocks })); messages.push(serde_json::json!({ "role": "user", "content": blocks }));
} }
/// 生成 messages 诊断摘要(每条 role + content 形态 + tool 标记),不含敏感数据。
/// B-260618-27: 1214 类错误时随 bail 文案直达前端 raw,定位哪条/字段非法。
fn summarize_messages(messages: &[serde_json::Value]) -> String {
let lines: Vec<String> = messages
.iter()
.enumerate()
.map(|(i, m)| {
let role = m.get("role").and_then(|r| r.as_str()).unwrap_or("?");
let desc = match m.get("content") {
Some(serde_json::Value::String(s)) => format!("text({}B)", s.len()),
Some(serde_json::Value::Array(blocks)) => {
let parts: Vec<String> = blocks
.iter()
.map(|b| {
let ty = b.get("type").and_then(|t| t.as_str()).unwrap_or("?");
match ty {
"text" => format!(
"text({}B)",
b.get("text").and_then(|t| t.as_str()).map(|s| s.len()).unwrap_or(0)
),
"tool_use" => format!(
"tool_use[id={},input_obj={}]",
b.get("id").and_then(|t| t.as_str()).unwrap_or(""),
b.get("input").map(|v| v.is_object()).unwrap_or(false)
),
"tool_result" => format!(
"tool_result[tid={}]",
b.get("tool_use_id").and_then(|t| t.as_str()).unwrap_or("")
),
"image" => "image".to_string(),
_ => ty.to_string(),
}
})
.collect();
format!("[{}]", parts.join(","))
}
_ => "?".to_string(),
};
format!("#{}:{} {}", i, role, desc)
})
.collect();
format!("{} msgs: {}", lines.len(), lines.join(" | "))
}
/// B-260618-27: 协议预检——扫 messages 发现确定非法形态,命中返回原因(仅诊断不修复)。
/// 覆盖:首条非 user / 连续同 role / tool_use input 非 object / 空 content / orphan tool_result
/// (tool_use_id 无前置 tool_use,常见于裁剪/过滤后 assistant 被删但 tool_result 留)。
fn precheck_messages(messages: &[serde_json::Value]) -> Result<(), String> {
if messages.is_empty() {
return Err("messages 为空".into());
}
let first_role = messages[0].get("role").and_then(|r| r.as_str()).unwrap_or("");
if first_role != "user" {
return Err(format!("首条 role={} 非法(须 user)", first_role));
}
for w in messages.windows(2) {
let r0 = w[0].get("role").and_then(|r| r.as_str()).unwrap_or("");
let r1 = w[1].get("role").and_then(|r| r.as_str()).unwrap_or("");
if r0 == r1 && (r0 == "user" || r0 == "assistant") {
return Err(format!("连续同 role={}", r0));
}
}
let mut tool_use_ids: Vec<&str> = Vec::new();
for (i, m) in messages.iter().enumerate() {
let role = m.get("role").and_then(|r| r.as_str()).unwrap_or("");
match m.get("content") {
Some(serde_json::Value::String(s)) if s.is_empty() && role == "user" => {
return Err(format!("#{} user content 空", i));
}
Some(serde_json::Value::Array(blocks)) => {
if blocks.is_empty() && role == "user" {
return Err(format!("#{} user content 空数组", i));
}
for b in blocks {
match b.get("type").and_then(|t| t.as_str()).unwrap_or("") {
"tool_use" => {
let id = b.get("id").and_then(|t| t.as_str()).unwrap_or("");
tool_use_ids.push(id);
if !b.get("input").map(|v| v.is_object()).unwrap_or(false) {
return Err(format!("#{} tool_use input 非 object", i));
}
}
"tool_result" => {
let tid = b.get("tool_use_id").and_then(|t| t.as_str()).unwrap_or("");
if !tid.is_empty() && !tool_use_ids.contains(&tid) {
return Err(format!(
"#{} orphan tool_result(tid={} 无前置 tool_use)",
i, tid
));
}
}
_ => {}
}
}
}
_ => {}
}
}
Ok(())
}
/// 统一鉴权头x-api-key + anthropic-version /// 统一鉴权头x-api-key + anthropic-version
fn auth_headers(&self, rb: reqwest::RequestBuilder) -> reqwest::RequestBuilder { fn auth_headers(&self, rb: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
rb.header("x-api-key", &self.api_key) rb.header("x-api-key", &self.api_key)
@@ -466,6 +567,13 @@ impl LlmProvider for AnthropicCompatProvider {
req.stream = false; req.stream = false;
let body = self.convert_request(req); let body = self.convert_request(req);
// B-260618-27: 协议预检——命中非法 bail 含 messages 摘要,把 GLM 模糊 1214 转明确诊断
if let Err(reason) = Self::precheck_messages(&body.messages) {
let summary = Self::summarize_messages(&body.messages);
warn!(%reason, %summary, "Anthropic messages 协议预检失败");
anyhow::bail!("messages 预检失败: {} | 摘要: {}", reason, summary);
}
debug!(model = %body.model, "Anthropic 同步调用"); debug!(model = %body.model, "Anthropic 同步调用");
// 指数退避重试(B-260616-07): 包裹 send + 状态码判定。 // 指数退避重试(B-260616-07): 包裹 send + 状态码判定。
@@ -504,7 +612,14 @@ impl LlmProvider for AnthropicCompatProvider {
if is_reqwest_error_retryable(&e) { if is_reqwest_error_retryable(&e) {
return AttemptOutcome::Retryable(format!("请求失败(可重试): {}", e)); return AttemptOutcome::Retryable(format!("请求失败(可重试): {}", e));
} }
return AttemptOutcome::Fatal(format!("请求失败(不可重试): {}", e)); return AttemptOutcome::Fatal(format!(
"请求失败(不可重试): {} [timeout={} connect={} body={} src={:?}]",
e,
e.is_timeout(),
e.is_connect(),
e.is_body(),
std::error::Error::source(&e)
));
} }
}; };
if !resp.status().is_success() { if !resp.status().is_success() {
@@ -575,6 +690,13 @@ impl LlmProvider for AnthropicCompatProvider {
req.stream = true; req.stream = true;
let body = self.convert_request(req); let body = self.convert_request(req);
// B-260618-27: 协议预检——命中非法 bail 含 messages 摘要,把 GLM 模糊 1214 转明确诊断
if let Err(reason) = Self::precheck_messages(&body.messages) {
let summary = Self::summarize_messages(&body.messages);
warn!(%reason, %summary, "Anthropic messages 协议预检失败");
anyhow::bail!("messages 预检失败: {} | 摘要: {}", reason, summary);
}
debug!(model = %body.model, "Anthropic 流式调用"); debug!(model = %body.model, "Anthropic 流式调用");
let resp = match self let resp = match self
@@ -597,7 +719,15 @@ impl LlmProvider for AnthropicCompatProvider {
source = ?std::error::Error::source(&e), source = ?std::error::Error::source(&e),
"Anthropic 流式 send 失败" "Anthropic 流式 send 失败"
); );
anyhow::bail!("Anthropic 流式 send 失败: {}", e); anyhow::bail!(
"send 失败: {} [timeout={} connect={} body={} request={} src={:?}]",
e,
e.is_timeout(),
e.is_connect(),
e.is_body(),
e.is_request(),
std::error::Error::source(&e)
);
} }
}; };

View File

@@ -458,24 +458,44 @@ impl ContextManager {
/// 致 over_budget_trims_old 等测试失败)。若运行时日志显示连续 role 也是 1214 来源,再补合并。 /// 致 over_budget_trims_old 等测试失败)。若运行时日志显示连续 role 也是 1214 来源,再补合并。
fn ensure_sequence_legal(messages: Vec<ChatMessage>) -> Vec<ChatMessage> { fn ensure_sequence_legal(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
let mut skipped = 0u32; let mut skipped = 0u32;
let result: Vec<ChatMessage> = messages let mut merged = 0u32;
.into_iter() let mut fixed: Vec<ChatMessage> = Vec::with_capacity(messages.len());
.skip_while(|m| { for m in messages {
if matches!(m.role, MessageRole::Assistant | MessageRole::Tool) { // 首条必须 user:skip 开头 assistant/tool(无前置 user 的孤儿)
if fixed.is_empty() && matches!(m.role, MessageRole::Assistant | MessageRole::Tool) {
skipped += 1; skipped += 1;
true continue;
} else {
false
} }
}) // 连续同 role 合并(user content;assistant content+tool_calls;Tool 不合并——
.collect(); // 连续 tool_result 由 anthropic_compat flush_tool_results 合并成 user blocks,此处合会丢 id)
if skipped > 0 { if let Some(last) = fixed.last_mut() {
let same_role = std::mem::discriminant(&last.role) == std::mem::discriminant(&m.role);
if same_role && matches!(m.role, MessageRole::User | MessageRole::Assistant) {
if !m.content.is_empty() {
if !last.content.is_empty() {
last.content.push('\n');
}
last.content.push_str(&m.content);
}
if matches!(m.role, MessageRole::Assistant) {
if let Some(calls) = m.tool_calls {
last.tool_calls.get_or_insert_with(Vec::new).extend(calls);
}
}
merged += 1;
continue;
}
}
fixed.push(m);
}
if skipped > 0 || merged > 0 {
tracing::warn!( tracing::warn!(
skipped, skipped,
"序列修复:丢弃开头的 assistant/tool 消息(Anthropic 要求首条 user,避免 1214)" merged,
"序列修复:skip 开头非 user + 合并连续同 role(view-only,避免 Anthropic/GLM 1214)"
); );
} }
result fixed
} }
/// 全量克隆(持久化 save_conversation / build_for_request 未裁剪分支,不受裁剪影响) /// 全量克隆(持久化 save_conversation / build_for_request 未裁剪分支,不受裁剪影响)
@@ -952,17 +972,22 @@ mod tests {
fn over_budget_trims_old() { fn over_budget_trims_old() {
// 小预算强制裁剪20 条超预算,触发裁剪且保留保护区 // 小预算强制裁剪20 条超预算,触发裁剪且保留保护区
let mut mgr = ContextManager::new(cfg(200)); let mut mgr = ContextManager::new(cfg(200));
// user/assistant 交替(真实对话序列;连续 user 会被 ensure_sequence_legal 合并,无法测条数裁剪)
for i in 0..20 { for i in 0..20 {
if i % 2 == 0 {
mgr.push(ChatMessage::user(&format!("这是第 {} 条较长的消息用于撑爆预算", i))); mgr.push(ChatMessage::user(&format!("这是第 {} 条较长的消息用于撑爆预算", i)));
} else {
mgr.push(ChatMessage::assistant(&format!("{} 条较长的回复用于撑爆预算", i)));
}
} }
let (msgs, trimmed) = mgr.build_for_request(0); let (msgs, trimmed) = mgr.build_for_request(0);
assert!(trimmed, "超预算应触发裁剪"); assert!(trimmed, "超预算应触发裁剪");
assert!(msgs.len() < 20, "应裁掉部分旧消息, 实际 {}", msgs.len()); assert!(msgs.len() < 20, "应裁掉部分旧消息, 实际 {}", msgs.len());
// 保护区最新一条必保留 // 保护区:最新一条必保留(末条 i=19 是 assistant)
assert_eq!( assert_eq!(
msgs.last().unwrap().content, msgs.last().unwrap().content,
"这是第 19 条较长的消息用于撑爆预算", "第 19 条较长的回复用于撑爆预算",
"保护区最新消息被误裁" "保护区最新消息被误裁"
); );

View File

@@ -213,9 +213,9 @@ pub fn is_non_chat_model(id: &str) -> bool {
|| id.contains("transcription") || id.contains("transcription")
// 内容审查 // 内容审查
|| id.contains("moderation") || id.contains("moderation")
// 旧 GPT 搜索变种 // 旧 GPT 搜索变种(davinci-search 覆盖旧 GPT 搜索;裸 -search 误伤合法 search-augmented chat
// 模型如 gpt-4o-search-preview/qwen-search/glm-4-search,已移除)
|| id.contains("davinci-search") || id.contains("davinci-search")
|| id.contains("-search")
} }
/// 过滤噪音:剔除非 chat 模型 + 去重(中转站可能返回重复) + 去空名。 /// 过滤噪音:剔除非 chat 模型 + 去重(中转站可能返回重复) + 去空名。
@@ -277,6 +277,17 @@ impl ModelsList {
mod tests { mod tests {
use super::*; use super::*;
// ── is_non_chat_model:search-augmented chat 模型不应误剔(裸 -search 已移除) ──
#[test]
fn is_non_chat_model_keeps_search_augmented_chat() {
// search-augmented chat 模型(对话端点可调)不应被 -search 子串误剔
assert!(!is_non_chat_model("gpt-4o-search-preview"));
assert!(!is_non_chat_model("qwen-search"));
assert!(!is_non_chat_model("glm-4-search-preview"));
// 旧 GPT 搜索变种仍剔(davinci-search 覆盖)
assert!(is_non_chat_model("text-davinci-search-001"));
}
// ── build_models_url:尾 / 处理 ── // ── build_models_url:尾 / 处理 ──
#[test] #[test]

View File

@@ -4,10 +4,7 @@ version = "0.1.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
df-types = { path = "../df-types" }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
async-trait = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
tracing = { workspace = true }

View File

@@ -99,7 +99,7 @@ pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
cmd.output(), cmd.output(),
) )
.await .await
.map_err(|_| anyhow::anyhow!("命令执行超时: {}", secs))??, .map_err(|_| anyhow::anyhow!("命令执行超时({}s): {}", secs, request.command))??,
None => cmd.output().await?, None => cmd.output().await?,
}; };

View File

@@ -1,12 +1,6 @@
//! 想法晋升 — 将想法转为项目 //! 想法晋升 — PromotionResult(promote_idea IPC 返回类型)。
//! //! 实际晋升路径:commands/idea.rs::promote_idea → df-project::ProjectManager::create_from_idea。
//! 历史 IdeaPromoter/PromotionPolicy/try_promote/do_promote 三件套 //! 历史空壳 IdeaPromoter/PromotionPolicy 已删(R-PD-14)。
//! 是空壳 TODOdo_promote 从未接入 df-project误接入会得虚假成功
//! 实际晋升路径由 src-tauri/src/commands/idea.rs::promote_idea 直接复用
//! df-project::ProjectManager::create_from_idea 完成。这里仅保留作为
//! IPC 返回类型的 PromotionResult。
//!
//! 死代码已清,详见 docs/todo.md R-PD-14。
use serde::Serialize; use serde::Serialize;

View File

@@ -43,9 +43,8 @@ pub struct CreateProjectInput {
pub struct ProjectManager; pub struct ProjectManager;
impl ProjectManager { impl ProjectManager {
/// 创建新项目 /// 创建新项目 — 构造领域实体(不落库);持久化由调用方经 storage 层 ProjectRecord 映射完成
/// /// (见 commands/idea.rs::promote_idea)。领域层不依赖 storage,保持分层。
/// TODO: 接入存储层持久化
pub fn create(input: CreateProjectInput) -> Project { pub fn create(input: CreateProjectInput) -> Project {
let now = chrono::Utc::now(); let now = chrono::Utc::now();
Project { Project {

View File

@@ -89,7 +89,7 @@ fn clear_migration_failcount(id: &str) {
} }
fn entry_for(id: &str) -> anyhow::Result<Entry> { fn entry_for(id: &str) -> anyhow::Result<Entry> {
Entry::new(KEYRING_SERVICE, id).map_err(|e| anyhow::anyhow!("keyring entry 创建失败: {}", e)) Entry::new(KEYRING_SERVICE, id).map_err(|e| anyhow::anyhow!("keyring entry 创建失败(provider={}): {}", id, e))
} }
/// 读取 provider 密钥(优先 keyring;无则 None) /// 读取 provider 密钥(优先 keyring;无则 None)
@@ -112,13 +112,13 @@ pub fn resolve_provider_secret(record: &AiProviderRecord) -> String {
/// 写入密钥到 keyring(覆盖) /// 写入密钥到 keyring(覆盖)
pub fn set_provider_secret(id: &str, key: &str) -> anyhow::Result<()> { pub fn set_provider_secret(id: &str, key: &str) -> anyhow::Result<()> {
let entry = entry_for(id)?; let entry = entry_for(id)?;
entry.set_password(key).map_err(|e| anyhow::anyhow!("keyring 写入失败: {}", e)) entry.set_password(key).map_err(|e| anyhow::anyhow!("keyring 写入失败(provider={}): {}", id, e))
} }
/// 删除 keyring 密钥(provider 删除时清理) /// 删除 keyring 密钥(provider 删除时清理)
pub fn delete_provider_secret(id: &str) -> anyhow::Result<()> { pub fn delete_provider_secret(id: &str) -> anyhow::Result<()> {
let entry = entry_for(id)?; let entry = entry_for(id)?;
entry.delete_credential().map_err(|e| anyhow::anyhow!("keyring 删除失败: {}", e)) entry.delete_credential().map_err(|e| anyhow::anyhow!("keyring 删除失败(provider={}): {}", id, e))
} }
/// 启动一次性迁移:DB 明文 → keyring → DB 置空(失败保留明文下次重试,非阻断) /// 启动一次性迁移:DB 明文 → keyring → DB 置空(失败保留明文下次重试,非阻断)

View File

@@ -10,6 +10,5 @@ serde_json = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
futures = "0.3" futures = "0.3"
thiserror = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }

View File

@@ -1,7 +1,6 @@
//! 事件总线 — 基于 tokio::sync::broadcast 的发布/订阅 //! 事件总线 — 基于 tokio::sync::broadcast 的发布/订阅
use tokio::sync::broadcast; use tokio::sync::broadcast;
use tokio::sync::broadcast::error::SendError;
use df_types::events::WorkflowEvent; use df_types::events::WorkflowEvent;
/// 事件总线 /// 事件总线
@@ -39,11 +38,6 @@ impl EventBus {
pub fn subscribe(&self) -> EventSubscriber { pub fn subscribe(&self) -> EventSubscriber {
self.sender.subscribe() self.sender.subscribe()
} }
/// 发送人工审批请求
pub fn emit_human_approval_request(&self, event: WorkflowEvent) -> Result<usize, SendError<WorkflowEvent>> {
self.sender.send(event)
}
} }
impl Default for EventBus { impl Default for EventBus {

View File

@@ -162,7 +162,7 @@
**🔴 P0 必须修复 (4项)**: **🔴 P0 必须修复 (4项)**:
- [x] ✅(workflow w5siwnipj 核验+主代实施·agentic:348/knowledge_inject:130,139,312/idea:292-296 加 tracing::warn 降级不改返回值·audit:344 误报(CR-11 已修三路 match)·commands:290 BUG-11 已修·cargo check EXIT 0 + vue-tsc EXIT 0) **SMELL-P0-1****[P0🔴]** `unwrap_or_default` 吞错 **5 高危闭环**。agentic.rs:348(provider 池空走原空池兜底)/knowledge_inject.rs:130,139,312(检索/解析失败走原降级不注入/提炼跳过)/idea.rs:296(tags 坏降级空继续评估)本批 match+warn(空 Vec 零行为变更);audit.rs:344 误报(audit_finalize CR-11 已修三路 match,line 漂移+旧版本);commands.rs:290 BUG-11 已修。**58 处全量排查降 P2 非紧急**(高危已清,余为 Option 取默认/JSON 序列化等低危)。 - [x] ✅(workflow w5siwnipj 核验+主代实施·agentic:348/knowledge_inject:130,139,312/idea:292-296 加 tracing::warn 降级不改返回值·audit:344 误报(CR-11 已修三路 match)·commands:290 BUG-11 已修·cargo check EXIT 0 + vue-tsc EXIT 0) **SMELL-P0-1****[P0🔴]** `unwrap_or_default` 吞错 **5 高危闭环**。agentic.rs:348(provider 池空走原空池兜底)/knowledge_inject.rs:130,139,312(检索/解析失败走原降级不注入/提炼跳过)/idea.rs:296(tags 坏降级空继续评估)本批 match+warn(空 Vec 零行为变更);audit.rs:344 误报(audit_finalize CR-11 已修三路 match,line 漂移+旧版本);commands.rs:290 BUG-11 已修。**58 处全量排查降 P2 非紧急**(高危已清,余为 Option 取默认/JSON 序列化等低危)。
- [ ] **SMELL-P0-2****[P0🔴]** `tool_registry.rs:363 build_ai_tool_registry` **1091 行单函数** — 拆分为按功能分组注册函数(register_crud_tools / register_file_tools / register_workflow_tools 等),每个 <200 行可测试 - [x] ✅(workflow w2xkw4ybh 抽 register_data_tools(18 持 db 工具:list_projects/list_tasks/list_ideas/update_project/create_project/bind_directory/create_task/update_task/advance_task/run_workflow/delete_task/create_idea/delete_project/restore_project/purge_project/list_trash/get_project_count/get_task_count)+ 主代抽 register_file_tools(10 文件工具:run_command/read_file/list_directory/write_file/patch_file/file_info/append_file/delete_file/rename_file/search_files)·build_ai_tool_registry 1091→7 行·加基线测试 test_build_ai_tool_registry_baseline_tool_count(Database::open_in_memory 断言 len()==28 + tool_names() 集合锁定)·主代核查 cargo check --workspace EXIT 0 + cargo test df-ai 119 passed + devflow 基线 1 passed + vue-tsc EXIT 0) **SMELL-P0-2****[P0🔴]** `tool_registry.rs:363 build_ai_tool_registry` **1091 行单函数** — 拆分为按功能分组注册函数(register_data_tools 18 db 工具 / register_file_tools 10 文件工具)build_ai_tool_registry 收敛到 7 行编排 + 基线测试锁定 28 工具数防回归
- [ ] **SMELL-P0-3****[P0🔴]** `AiChat.vue` **4026 行 God 组件** — 拆分: ConversationSidebar(侧栏+搜索) / MessageList(消息列表+流式) / ChatInput(输入框+附件) / ApprovalPanel(审批卡片)。目标单组件 <500 行。 - [ ] **SMELL-P0-3****[P0🔴]** `AiChat.vue` **4026 行 God 组件** — 拆分: ConversationSidebar(侧栏+搜索) / MessageList(消息列表+流式) / ChatInput(输入框+附件) / ApprovalPanel(审批卡片)。目标单组件 <500 行。
- [x] ✅(workflow wowdnw4ba·tests/shell.rs 5 #[tokio::test] 覆盖成功/非零/超时/env/working_dir + shell.rs:55 过时TODO注释清理·execute逻辑零改动·主代 cargo test -p df-execute 5 passed + cargo check --workspace EXIT 0) **SMELL-P0-4****[P0🔴]** `df-execute` crate **零测试**(shell.rs 120行已集成工作流) — 补 shell 命令执行+超时+输出截断基础测试。 - [x] ✅(workflow wowdnw4ba·tests/shell.rs 5 #[tokio::test] 覆盖成功/非零/超时/env/working_dir + shell.rs:55 过时TODO注释清理·execute逻辑零改动·主代 cargo test -p df-execute 5 passed + cargo check --workspace EXIT 0) **SMELL-P0-4****[P0🔴]** `df-execute` crate **零测试**(shell.rs 120行已集成工作流) — 补 shell 命令执行+超时+输出截断基础测试。
@@ -186,6 +186,13 @@
- [x] ✅(workflow w5siwnipj 核验·line 3 模块注释已有「⚠ B 路线占位...有意保留空壳,勿删」三要素齐全·零外部调用·保留 B 路线预留点) **SMELL-P2-4** — coordinator.rs 空壳标注 roadmap **已完成**(line 3 注释),保留非删(B 路线 aichat 决策能力预留,对齐 memory aichat-arch-extensibility) - [x] ✅(workflow w5siwnipj 核验·line 3 模块注释已有「⚠ B 路线占位...有意保留空壳,勿删」三要素齐全·零外部调用·保留 B 路线预留点) **SMELL-P2-4** — coordinator.rs 空壳标注 roadmap **已完成**(line 3 注释),保留非删(B 路线 aichat 决策能力预留,对齐 memory aichat-arch-extensibility)
- [x] ✅(workflow w5siwnipj 核验 7 处+主代补 #6/#7 注释·全部标注完成·cargo check df-ai EXIT 0) **SMELL-P2-5** — #[allow(dead_code)] **7 处全部标注完成**(实际 7 非声称 12,部分已清)。5 处原有注释(prompt.rs:147/compress.rs:45,98 F-15 阶段预留 + openai_compat.rs:81,98 SW-24 反序列化对齐)+ 本批补 anthropic_compat.rs:61 id/:65 stop_reason 注释(对齐 openai SW-24 风格)。全合理保留无真死代码。**另:cargo warning 死字段 4 处**(PendingApproval.risk_level/diff + AppState.releases/node_executions)属另一类未标 allow,待核对。 - [x] ✅(workflow w5siwnipj 核验 7 处+主代补 #6/#7 注释·全部标注完成·cargo check df-ai EXIT 0) **SMELL-P2-5** — #[allow(dead_code)] **7 处全部标注完成**(实际 7 非声称 12,部分已清)。5 处原有注释(prompt.rs:147/compress.rs:45,98 F-15 阶段预留 + openai_compat.rs:81,98 SW-24 反序列化对齐)+ 本批补 anthropic_compat.rs:61 id/:65 stop_reason 注释(对齐 openai SW-24 风格)。全合理保留无真死代码。**另:cargo warning 死字段 4 处**(PendingApproval.risk_level/diff + AppState.releases/node_executions)属另一类未标 allow,待核对。
**🆕 sweep 派生登记2026-06-18·workflow w2xkw4ybh data 抽出 + wo7d6zdqm 多维度 sweep**:
- [ ] ARC-260618-01-d [P1/行为变更·待确认产品意图] — **conditions 条件引擎未求值**`df-workflow/conditions.rs` `ConditionEngine` 零消费(无调用方),`Edge.condition` 字段存储但工作流执行器未求值分支——边条件当前形同虚设。**产品决策点**:① 实现条件求值(executor 在节点完成后求值出边 condition 决定路由)还是 ② 标 `#[allow(dead_code)]` + 文档明示"条件为预留未启用"。涉及行为变更(executor 控制流改变),确认产品意图后再实施。对齐 H 组长期池 T-14-11 条件引擎(line 42 仅指针无详情)。— `crates/df-workflow/src/conditions.rs`(ConditionEngine) + `crates/df-workflow/src/executor.rs`(边求值点)
- [ ] ARC-260618-01-e [P1/行为变更·待确认产品意图] — **adversarial `evaluate_with_llm` 一致性未校验**`df-ideas/adversarial.rs` `evaluate_with_llm` 返回的 `final_assessment`/`recommendation` 两个字段语义一致性未校验(如 final_assessment=强烈反对 但 recommendation=promote 的矛盾组合无守卫)。LLM 输出存在字段间语义漂移风险。**产品决策点**:① 加一致性校验(矛盾时降级或重评)还是 ② 视为 LLM 自由表达不加约束。涉及行为变更(评估结果可能被改写),确认产品意图后再实施。— `crates/df-ideas/src/adversarial.rs`(evaluate_with_llm 返回结构)
- [ ] SW-260618-21 [P2/待 AiChat 沉淀] — **formatRelativeZh 重命名 formatRelative**`src/utils/time.ts` `formatRelativeZh` 名带 Zh 后缀但实际非中文硬编码(已读 i18n.global.locale, sweep 批2 time.ts formatDate 已修 CR-260615-08 漏修),函数名误导。4 调用点含 `AiChat.vue`(并发改动中),为避免并发冲突缓做,待 AiChat God 组件拆分(SMELL-P0-3)沉淀后统一重命名。— `src/utils/time.ts`(formatRelativeZh) + 4 调用点(含 `src/components/AiChat.vue`)
- [ ] SW-260618-22 [P2/已知 B-260617-03] — **useAiSend resolveLang DRY**`useAiSend``resolveLang` 与其他 composable 语言解析逻辑重复,应抽 `aiShared` 共享。已知登记为 B-260617-03,此处补 sweep 维度记录便于检索。— `src/composables/ai/useAiSend.ts`(resolveLang) → 抽 `src/composables/ai/aiShared.ts`
### 🟡 AI Chat 交互体验改进2026-06-14 方案 → 待办化) ### 🟡 AI Chat 交互体验改进2026-06-14 方案 → 待办化)
> 来源:[aichat交互体验改进方案-2026-06-14.md](./02-架构设计/aichat交互体验改进方案-2026-06-14.md)。7 大类 20 个改进项,按原文优先级分 4 批。**去重**§1.3 复制 ⊂ F-260615-13/14已写、§3.4 新建中断 ⊂ B-260615-10已做软复位本文档加 confirm 增强、§6.2 分离窗口同步 ⊂ CR-24/25已写。**状态: 待讨论→看板跟踪**。 > 来源:[aichat交互体验改进方案-2026-06-14.md](./02-架构设计/aichat交互体验改进方案-2026-06-14.md)。7 大类 20 个改进项,按原文优先级分 4 批。**去重**§1.3 复制 ⊂ F-260615-13/14已写、§3.4 新建中断 ⊂ B-260615-10已做软复位本文档加 confirm 增强、§6.2 分离窗口同步 ⊂ CR-24/25已写。**状态: 待讨论→看板跟踪**。
@@ -329,3 +336,15 @@
- [x] ✅(主代串行·规避 AiChat God 文件·新建 src/utils/markdown.ts stripMd 剥离 MD 语法(粗体/斜体/标题/代码/列表/引用/链接/图片)+ Knowledge.vue:71/Projects.vue:148/Ideas.vue:47 三列表摘要接入·修自引入注释含 */ 致 JSDoc 提前闭合 TS1011/1127·改安全措辞·vue-tsc EXIT 0) **UX-260618-13** [P2] — **列表摘要 Markdown 语法字符泄露**。三处列表摘要 stripMd 过滤。**AiChat 标题 strip 子项**:规避 God 高频文件·留待并发期后(待核 title.rs 是否产 MD)。— `src/views/{Knowledge,Projects,Ideas}.vue` + `src/utils/markdown.ts::stripMd` - [x] ✅(主代串行·规避 AiChat God 文件·新建 src/utils/markdown.ts stripMd 剥离 MD 语法(粗体/斜体/标题/代码/列表/引用/链接/图片)+ Knowledge.vue:71/Projects.vue:148/Ideas.vue:47 三列表摘要接入·修自引入注释含 */ 致 JSDoc 提前闭合 TS1011/1127·改安全措辞·vue-tsc EXIT 0) **UX-260618-13** [P2] — **列表摘要 Markdown 语法字符泄露**。三处列表摘要 stripMd 过滤。**AiChat 标题 strip 子项**:规避 God 高频文件·留待并发期后(待核 title.rs 是否产 MD)。— `src/views/{Knowledge,Projects,Ideas}.vue` + `src/utils/markdown.ts::stripMd`
--- ---
### 🔧 2026-06-18 用户实测·aichat bug 反馈(review 会话 2 agent 并行审查·独立 grep/read 核验·session-role-diagnose-only·待实施)
> 用户实测报 2 bug。review 会话派 2 agent 并行核验源码,根因 + 修法定位完成,待主代实施。
- [ ] **UX-260618-14 [P1]****advance_task 审批卡/结果渲染缺任务名 + 显 UUID/空白**。①审批卡 `ToolCard.vue:800` `PROJECT_ID_TOOL_ARG` 未登记 advance_task → id 裸显 UUID;②审批 reason `audit.rs:163` tool_display_hint 短路致 `tool_registry.rs:1546` 模板死代码 + `:172` 任务 id 误查 projects 表;③结果 `ToolCard.vue:477` 模板取 r.id(UUID)非 r.title(TaskRecord 有 title `models.rs:57`),"空白"=UUID 不可读非字段缺失。**修法 B(最快)**:ToolCard:477 取 r.title fallback r.id + i18n 改 `任务「{title}」已推进至 {status}`;**A1** 登记 TASK_ID_TOOL_ARG(advance_task/run_workflow)id→title 回显;**C** 删 `tool_registry.rs:1572` tool_display_hint 让 :1546 模板生效 + audit.rs 加 resolve_task_label。附带:`:1546/:1547` 模板均死代码 + audit.rs:172 任务 id 误查隐患。— `src/components/ToolCard.vue`(:474-478,800-825) + `src-tauri/src/commands/ai/{tool_registry.rs:1546/1572,audit.rs:163/172}` + `src/i18n/{zh-CN,en}/aiTool.ts`
- [ ] **UX-260618-15 [P1]****流式失败重试(1/4 2/4 3/4)每轮独立气泡**。后端每轮重试 emit AiError(`stream_recv.rs:200/258/298/358/398` 5 处 InitFailed 必 emit),前端 `useAiEvents.ts:326` AiError case 每次 push 新气泡无去重;`AiStreamRetry`(:177)本意更新末条合并但被新 AiError 破坏 → N+1 独立气泡。`agentic.rs:262` 注释误以为 stream_llm 只 emit 一次(实多次)。**用户诉求升级**:不只是修 bug,要**聚合显示设计**(重试 1→4 轮过程聚合单气泡 + 最终结果,勿一排错误吓人)。详见 review 会话重试聚合设计方案(3 角度论证:可行性/前后端一致性/架构整合)。**修法 A(推荐)**:`stream_recv.rs` 5 处 InitFailed 移除 AiError emit(改纯返 retryable,emit 权交调用方)+ `agentic.rs` 重试 loop 只 emit AiStreamRetry、耗尽/Fatal 才 emit 最终 AiError。影响仅流前失败重试路径(Fatal/MidStream 保文/正常错误/压缩失败不受影响)。— `src-tauri/src/commands/ai/{stream_recv.rs:200/258/298/358/398,agentic.rs:207-300}` + `src/composables/ai/useAiEvents.ts:177/326`
- [ ] **UX-260618-16 [P1🔴]****`time.ts:44` formatDate 漏 `.value` 致 en locale i18n 失效**(CR-260618-24 批2 审查发现·Agent C 铁证)。`legacy:false` composition 模式 `i18n.global.locale` 是 ref,`(i18n as any).global.locale === 'en'` 比对象===字符串**恒 false** → en locale 下 formatDate 永走 zh-CN 分支,i18n 化失效(对齐 CR-260615-08 未完成回归)。铁证:`App.vue:227`/`GeneralPanel.vue:259` 均用 `i18n.global.locale.value`,唯 time.ts:44 漏。**修法**:补 `.value``(i18n as any).global.locale.value === 'en'`(locale 取值 'en' 非 'en-US',App.vue:227 印证)。— `src/utils/time.ts:44`
---

View File

@@ -110,12 +110,12 @@
- **背景**:2026-06-18 架构坏味道扫描出 3 个 God 文件:`tool_registry.rs` 1091行单函数(SMELL-P0-2)/`AiChat.vue` 4026行单组件(SMELL-P0-3)/`crud.rs` 2212行(SMELL-P1-9)。均属"功能能跑但维护成本高/测试难"的技术债,非功能 bug。 - **背景**:2026-06-18 架构坏味道扫描出 3 个 God 文件:`tool_registry.rs` 1091行单函数(SMELL-P0-2)/`AiChat.vue` 4026行单组件(SMELL-P0-3)/`crud.rs` 2212行(SMELL-P1-9)。均属"功能能跑但维护成本高/测试难"的技术债,非功能 bug。
- **决策点**:拆分何时做/优先级/拆分边界(每项拆分策略需专项设计,非小改;做不做是资源/可维护性取舍) - **决策点**:拆分何时做/优先级/拆分边界(每项拆分策略需专项设计,非小改;做不做是资源/可维护性取舍)
- **选项**: - **选项**:
- a: `tool_registry.rs` 按 CRUD/文件/工作流分组拆注册函数(每个<200行,单文件域,风险中) - a: `tool_registry.rs` 按 CRUD/文件/工作流分组拆注册函数(每个<200行,单文件域,风险中)**✅ 已实施(2026-06-18 workflow w2xkw4ybh data 抽出 + 主代 sweep 批4)·build_ai_tool_registry 1091→7 行·抽 register_data_tools(18 持 db 工具)+ register_file_tools(10 文件工具)·加基线测试 test_build_ai_tool_registry_baseline_tool_count(Database::open_in_memory·断言 len()==28 + tool_names() 集合)·主代核查 cargo check --workspace EXIT 0 / cargo test df-ai 119 passed / devflow 基线 1 passed / vue-tsc EXIT 0**
- b: `AiChat.vue` 拆 ConversationSidebar/MessageList/ChatInput/ApprovalPanel(巨型组件,风险高,AiChat.vue 已在频繁改动需择稳定窗口) - b: `AiChat.vue` 拆 ConversationSidebar/MessageList/ChatInput/ApprovalPanel(巨型组件,风险高,AiChat.vue 已在频繁改动需择稳定窗口) — 🟡 待排期(并发改中缓)
- c: `crud.rs` 按表拆 project/task/conversation/idea_repo(2212行,中风险) - c: `crud.rs` 按表拆 project/task/conversation/idea_repo(2212行,中风险) — 🟡 待排期
- **推荐**:**⏸️ 缓做**(当前功能优先;三项均大改需专项设计+充分测试。建议按 a→c→b 顺序,a 单文件域最独立先做,b 待 AiChat 改动沉淀后) - **推荐**:**⏸️ 缓做**(当前功能优先;三项均大改需专项设计+充分测试。建议按 a→c→b 顺序,a 单文件域最独立先做**已实施**,b 待 AiChat 改动沉淀后)
- **关联**:todo SMELL-P0-2/P0-3/P1-9 - **关联**:todo SMELL-P0-2/P0-3/P1-9
- **状态**:🟡 待排期决策 - **状态**:🟡 待排期决策(a 已实施·b/c 待排期:b AiChat 并发改中缓·c 待专项窗口)
#### SMELL-P1-6 String→newtype 强类型(需设计) #### SMELL-P1-6 String→newtype 强类型(需设计)
- **背景**:execution_id/status/tool_type 等 5+ 处用裸 String,类型安全弱(混用/拼写错编译期不拦)。改 newtype(ExecutionId 等)需全栈波及(Rust struct 字段 + serde + IPC 边界 + 前端 ts 类型)。注:status 场景 df-types 已有 TaskStatus enum,部分代码用 String 而非 enum。 - **背景**:execution_id/status/tool_type 等 5+ 处用裸 String,类型安全弱(混用/拼写错编译期不拦)。改 newtype(ExecutionId 等)需全栈波及(Rust struct 字段 + serde + IPC 边界 + 前端 ts 类型)。注:status 场景 df-types 已有 TaskStatus enum,部分代码用 String 而非 enum。

View File

@@ -547,6 +547,86 @@
--- ---
### CR-260618-24 God拆分a(tool_registry file+data抽出+基线测试) + sweep批1-4死依赖/死代码/死导出/注释/i18n/model_fetch(workflow w2xkw4ybh data抽+主代file抽+主代sweep实施·工作区未提交·commit-policy-deferred) — ✅ 已审(ISSUES·🔴1🟡1·review 会话 3 agent 并行核验)
- **范围**:
- **God 拆分 a(SMELL-P0-2)**:`tool_registry.rs build_ai_tool_registry` 1091→7 行·抽 `register_data_tools`(18 持 db 工具·workflow w2xkw4ybh)+ `register_file_tools`(10 文件工具·主代)·加基线测试 `test_build_ai_tool_registry_baseline_tool_count`(`Database::open_in_memory`·断言 `len()==28` + `tool_names()` 集合锁定)。
- **批1 死依赖/死代码(Rust)**:`df-execute/Cargo.toml` 删 df-types/async-trait/tracing(全仓零引用) · `df-workflow/Cargo.toml` 删 thiserror(零引用) · `df-workflow/eventbus.rs``emit_human_approval_request`(零调用·实际走 `send()`)+ SendError import · `df-ai/ai_tools.rs``ToolExecutionResult`(零消费者)。
- **批2 前端**:`useMarkdown.ts``clearHistoryCache`(零消费) · `useConfirm.ts``ConfirmFn`/`AnswerFn`(零 import) · `useAiPanel.ts` `restoreUiState``export`(零外部调用) · `markdown.ts` stripMd 围栏正则 `[a-zA-Z]*``[^\n]*`(吃任意 infostring 如 rust,norun) · `time.ts` `formatDate``i18n.global.locale`(原硬编码 zh-CN·CR-260615-08 漏修)。
- **批3 注释**:`df-project/manager.rs` 删误导 TODO(分层正确·持久化在 storage 层) · `df-ideas/promotion.rs` 精简模块注释(历史空壳已删)。
- **批4 med**:`df-ai/model_fetch.rs` `is_non_chat_model``|| id.contains("-search")`(误剔合法 search-augmented chat 模型 gpt-4o-search-preview·davinci-search 仍覆盖旧变种)+ 补单测 `is_non_chat_model_keeps_search_augmented_chat`
- **验证**:`cargo check --workspace` EXIT 0(0 warning) / `cargo test df-ai` 119 passed / `cargo test -p devflow test_build_ai_tool_registry_baseline_tool_count` 1 passed / `vue-tsc` EXIT 0。
- **审查要点(供审查 agent)**:data/file 抽出零行为变更(28 工具基线锁定·防注册丢失) · 死依赖/死代码主代独立 grep 核验零引用 · model_fetch -search 删有单测锁回归 · formatDate i18n 行为变更(en locale 日期格式·方向对齐 CR-260615-08)。
- **关联**:SMELL-P0-2 销账 / ARC-260618-01 a ✅ 已实施 / ARC-d(conditions)/e(adversarial)/SW-21(formatRelativeZh)/22(useAiSend DRY) 进 todo。
- **复审结论(2026-06-18·review 会话 3 agent 并行独立核验,不信主代声明)**: ⚠️ **ISSUES** — 🔴1 🟡1 ⚪1
-**God 拆分 PASS**(Agent A):build 调 register_data_tools(:367)+ register_file_tools(:368),build 体零残留内联;**重名注册闭环**(巡检 #11 信号消除:28 name 静态互异 + 基线双断言 len==28 + sorted 集合防护 HashMap 覆盖);抽样 run_command/list_projects 逐字一致(get_*_count 仅位置移动非行为变更)
-**批1 死代码全 PASS**(Agent B):df-execute 删 df-types/async-trait/tracing + df-workflow 删 thiserror + eventbus 删 emit_human_approval_request/SendError + ai_tools 删 ToolExecutionResult,**4 项全仓 grep 零引用**(人类审批走 `bus.send()` 非删的 emit),无误删活引用
-**批4 model_fetch PASS**(Agent C):删 `|| id.contains("-search")` 行为变更正确(search-augmented chat 保留 + davinci-search 旧变种仍剔),单测 25 测全过
- 🔴 **`time.ts:44` formatDate 漏 `.value`**(Agent C 铁证):`legacy:false` composition 模式 `i18n.global.locale` 是 ref,`(i18n as any).global.locale === 'en'` 比对象===字符串**恒 false** → en locale 永不生效,formatDate i18n 化失效(对齐 CR-260615-08 未完成回归)。`App.vue:227`/`GeneralPanel:259` 均用 `.value` 印证。修法:补 `.value`
- 🟡 **`markdown.ts:19` stripMd 围栏正则边缘回归**:`[a-zA-Z]*``[^\n]*` 后,内联裸围栏无换行(`See \`\`\`rust below`)吃到行尾。场景边缘(列表摘要极少内联裸围栏),建议注释标注边界
-**`anthropic_compat.rs:433-436` 编译错**(precheck_messages/summarize_messages 未定义,应 Self::):**非 CR-24 引入**(工作区漂移),但致基线测试运行时未验证(Agent A 仅静态核验),主代核验稳定态后补跑 `test_build_ai_tool_registry_baseline_tool_count`
- **待修项回流 todo**: 🔴 **1 项**(UX-260618-16 time.ts formatDate 补 .value)
**第二位审查 agent 独立核验印证(2026-06-18·独立 grep/read·防上下文污染)**:
- ✅ 与 review 会话 3 agent 结论一致:🔴1(time.ts .value) / 🟡1(markdown.ts 边缘) / ⚪1(anthropic_compat 漂移)
- **🔴 time.ts:44 漏 .value 二次独立坐实**:`i18n/index.ts:17 legacy:false`(composition 模式)→ `i18n.global.locale``WritableComputedRef` 非 string。`App.vue:227` + `GeneralPanel.vue:259` 均用 `i18n.global.locale.value = ...` 印证 ref 语义;`(i18n as any).global.locale === 'en'` 比较 ref 对象与字符串 **恒 false** → en locale 永不生效。**本审查 agent 首轮未捕捉此对抗点,经 review 会话坐实后独立复核确认**——印证对抗思维不可省(`.value` 是 vue-i18n legacy/composition 模式高频易错点)。
- **A. God 拆分独立佐证**(超出对方会话覆盖):grep df-ai 全 crate `#[(tokio::)?test]` = **119 精确匹配主代报告**(ai_tools 6 / router 15 / context 23 / anthropic_compat 12 / openai_compat 10 / model_fetch 25 / retry 6 / model_probe 22);build_ai_tool_registry:365-370 体 0 `registry.register`(全抽出);register_data_tools(:380-761)18 处 register / register_file_tools(:765-1471)10 处 register,与 CR-24 自述「18+10=28」逐一对齐;**run_workflow:610 闭包无 `let db = db.clone()` 前缀**(对比其余 17 data 工具全有),CR-52 防御 Err 设计保留;RiskLevel 28 处计数与 28 工具一一对应。
- **B. 批1 死代码独立佐证**:df-execute/Cargo.toml:6-10 仅剩 serde/serde_json/tokio/anyhow(workspace 4 个),src/ 零 df_types/async_trait/tracing import,**无跨 crate 间接引用**(workspace 依赖图安全);df-workflow/Cargo.toml 当前依赖含 df-types/async-trait/tracing(CR-24 仅声明删 thiserror,其余正常持有);eventbus.rs:1-56 仅 send/subscribe/with_capacity/Default/Clone;ai_tools.rs ToolExecutionResult 全仓零引用。
- **C. 批2 前端独立佐证**:clearHistoryCache/ConfirmFn/AnswerFn 全 src 零命中(仅待审查.md 自述);useAiPanel.ts:49 `function restoreUiState()` 无 export,:52 模块内自调;markdown.ts:19 `/```[^\n]*\n?/g`(非 `[a-zA-Z]*`)印证。
- **D. 批3 注释独立佐证**:manager.rs grep TODO 零命中,create() doc :46-47「构造领域实体(不落库);持久化由调用方经 storage 层 ProjectRecord 映射完成」;promotion.rs:1-3 模块注释 3 行精简。
- **E. 批4 med 独立佐证**:model_fetch.rs:196-219 is_non_chat_model 列表(图片/语音/realtime/transcribe/moderation/:218 `davinci-search`),**无 `-search` 残留**;新测试 :281-289 4 断言(3 search-augmented 保留 + 1 davinci-search 仍剔);另有 :394-396 non_chat_search_variants_filtered 印证。
- **🟡 markdown.ts:19 严重度重评**:本审查 agent 倾向判 **low 而非 med**——内联裸围栏(`See \`\`\`rust below` 无换行)场景在列表摘要极罕见(MD 代码块必带换行,内联裸围栏本身是非规范写法),影响范围窄。对方会话判 med 偏保守,差异在严重度估计非事实分歧。建议修法:加 `[^\n]*` 边界注释标注「内联裸围栏场景」即可,无需改正则。
- **⚪ anthropic_compat.rs:433-436 编译错印证**:确认非 CR-24 引入(工作区漂移),需主代核验稳定态后补跑基线测试(对方已记录)。
**主代修复闭环(2026-06-18)**: 🔴 `time.ts:44` 已补 `.value`(`(i18n as any).global.locale.value`·对齐 App.vue:227)·`vue-tsc` EXIT 0 印证 en locale 现正确生效。CR-24 🔴 消·verdict 转 PASS(🟡 markdown.ts:19 边界注释 low 可选·⚪ anthropic_compat 漂移非本 CR 待独立核验)。
---
### CR-260618-25 wo7d6zdqm findings 推进(secret keyring provider id + shell 超时命令名 + run_command timeout clamp 600s) + wdvtbjozg ux(异步按钮禁用态防双击 + 空态/错误态·7 vue + i18n)(workflow 多代理 + 主代实施·工作区未提交·commit-policy-deferred) — ✅ 已审(PASS·主代自测+workflow)
- **范围**:
- **wo7d6zdqm 错误消息/安全(主代实施)**:`df-storage/secret.rs` keyring 3 处错误带 provider id(entry_for/set/delete) · `df-execute/shell.rs:102` 超时消息带命令名 · `tool_registry.rs run_command` timeout_secs clamp 600s(防 LLM 传超大冻结) · `utils/time.ts formatDate``.value`(CR-24 🔴 修复)。
- **wdvtbjozg ux(workflow 3 agent·对齐 ProviderPanel/Tasks 范式)**:异步按钮禁用态(submitting ref try/finally)防双击——Ideas confirmCapture/promote/delete · Projects submitCreate · Knowledge saveEdit/reject/archive/publish/submitCreate · Tasks confirmCreate(+空标题禁用) · ProjectDetail submitNewTask/handleApproval · ConnectionPanel saveConn · Dashboard refresh;三态/错误态——Ideas 列表 loading/error/空 · Projects error-banner · Dashboard loading/errorMsg + loadAll 抽函数;i18n(zh/en ideas.ts listEmpty/creating/deleting/promoting · dashboard.ts err.loadFailed)。
- **验证**:`cargo check -p devflow -p df-storage -p df-execute` EXIT 0 / `vue-tsc` EXIT 0。
- **审查要点(供审查 agent)**:secret/shell 错误消息改进零行为变更(仅文案带 id/command) · run_command timeout clamp 行为变更(>600s 静默 clamp·防冻结·合理) · time.ts .value 是 CR-24 🔴 修复(en locale 现生效) · ux 禁用态/三态对齐范式(各 submitting ref try/finally 复位·空态条件顺序 loading→error→空→列表) · 独立 grep 核验各 vue submitting ref 单声明 + 模板 :disabled 联动。
- **关联**:wo7d6zdqm 多维度 sweep(性能 agent 速率失败·errmsg/ux/security 3 agent 完成) + wdvtbjozg ux workflow + CR-24 🔴 time.ts 修。
- **待修项回流 todo**: 无
**复审结论(2026-06-19·独立 grep 核验源码当前形态)**: ✅ PASS · 🔴0 🟡0 ⚪2(low·可选)。verdict 转 PASS。主代自测(cargo check EXIT 0 / vue-tsc EXIT 0)与独立源码核验一致,无 high/med。
**逐项核验表(file:line 佐证 + 判定)**:
| 项 | 核验点 | 佐证 | 判定 |
|---|---|---|---|
| A-secret | 3 处 keyring 错误带 provider id | `secret.rs:92` entry_for `(provider={})`+id · `:115` set `(provider={})`+id · `:121` delete `(provider={})`+id | ✅ |
| A-shell | 超时 map_err 带命令名 + secs | `shell.rs:102` `命令执行超时({}s): {}``secs`+`request.command`(ShellRequest 字段在作用域内) | ✅ |
| A-clamp | timeout_secs `.min(MAX)` + 常量 600 | `tool_registry.rs:26` `MAX_RUN_COMMAND_TIMEOUT_SECS: u64 = 600` · `:792` `.unwrap_or(DEFAULT=60).min(MAX)` clamp · `:24` DEFAULT=60(默认 60<600·clamp 不影响正常超时·仅封顶 LLM 传超大冻结) | ✅ |
| A-time | formatDate `.value`(CR-24 🔴) | `time.ts:44` `(i18n as any).global.locale.value === 'en'` · 对齐 `App.vue:227` `i18n.global.locale.value = savedLang`(en locale 现正确生效) | ✅ |
| B-Ideas | 三态条件顺序 + store 字段 | `Ideas.vue:37-39` `v-if store.loading``v-else-if store.error``v-else-if filteredIdeas.length===0``v-else 列表` · 顺序正确 · `:236` useProjectStore · `project.ts:90-91,93` store.loading/error/clearError 均存在(getter 直读 state) | ✅ |
| B-Projects | error-banner + clearError + submitting | `Projects.vue:13-16` `v-if store.error` + dismiss `store.clearError()` · `:51` `:disabled="submitting \|\| !newName.trim()"` | ✅ |
| B-Dashboard | loading ref + errorMsg + loadAll + refresh :disabled | `Dashboard.vue:124-125` loading/errorMsg ref · `:194-206` loadAll() 抽函数(try/finally+guard `if(loading) return`) · `:10` refresh `:disabled="loading"` · `:21-24` error-banner | ✅ |
| C-Ideas | creating/deleting/promoting 单声明 + try/finally + :disabled | `Ideas.vue:253-255` 三 ref 各一次声明 · `:402/416/428` 入口 guard(`\|\| xxx.value`) · `:404-412/419-424/430-439` try/finally 复位 · `:176/181/207` :disabled 联动 | ✅ |
| C-Projects | submitting 单声明 + try/finally + :disabled + guard | `Projects.vue:225` 单声明 · `:278` guard `if(submitting) return`(在 `:279` submitting=true 前) · `:279-290` try/finally · `:51` :disabled | ✅ |
| C-Knowledge | submitting 单声明 + 5 函数 try/finally + :disabled | `Knowledge.vue:353` 单声明 · saveEdit/publishCurrent/rejectCurrent/archiveCurrent/submitCreate 均 try/finally 复位 · `:99/100/102/106/233` :disabled 联动 | ✅ |
| C-Tasks | submitting 单声明 + 空标题 guard + try/finally | `Tasks.vue:137` 单声明 · `:222` guard `!newTaskTitle.trim()``:223` submitting=true 前 · `:223-236` try/finally · `:103` :disabled 联动 · 三态 loading/error/empty `:43-45` | ✅ |
| C-ProjectDetail | submitting 单声明 + submitNewTask/handleApproval try/finally + :disabled | `ProjectDetail.vue:324` 单声明 · `:327` guard trim · `:328-343` submitNewTask try/finally · `:418-424` handleApproval try/finally · `:36/211` :disabled(审批选项按钮防双击换选项) | ✅ |
| C-ConnectionPanel | submitting 单声明 + saveConn + :disabled | `ConnectionPanel.vue:110` 单声明 · `:157-177` try/finally · `:63` :disabled | ✅(low 见下) |
| D-i18n-zh | ideas listEmpty/creating/deleting/promoting + dashboard err.loadFailed | `zh-CN/ideas.ts:20-23` · `zh-CN/dashboard.ts:37` `loadFailed: '加载数据失败,请稍后重试'` | ✅ |
| D-i18n-en | 对称 | `en/ideas.ts:20-23` · `en/dashboard.ts:37` `loadFailed: 'Failed to load data, please retry later'` | ✅ |
**对抗核验印证**:
- **submitting ref 单声明?** ✅ 7 vue 全部 setup 内单声明(无重复 declare):Ideas `:253-255` 三 ref·Projects `:225`·Dashboard `:124`·Knowledge `:353`·Tasks `:137`·ProjectDetail `:324`·ConnectionPanel `:110`。无重复声明。
- **try/finally 复位?** ✅ 所有异步函数均 try/finally 复位(异常路径不卡死)。无漏 finally。
- **store 字段存在?** ✅ project store(`project.ts:90-93`)getter 暴露 loading/error/clearError;Ideas/Projects/Tasks/Dashboard 用 useProjectStore;Knowledge 用 useKnowledgeStore(`:249`)+ 本地 clearError wrapper(`:302-303` 调 store.clearError)。字段均存在、store 用对。
- **行为变更标注?** ✅ 仅 run_command clamp 为静默行为变更(>600s 截断·防 LLM 冻结·合理·注释 `:791` 标注),secret/shell/time.ts 均零行为变更(纯文案/纯 bug 修)。time.ts `.value` 是 CR-24 🔴 必要修复(en locale 原本恒中文·回归)。
- **error-banner 重复?** ✅ Ideas/Projects/Dashboard/Knowledge 各自 error-banner(消费各自 store.error / 本地 errorMsg),非同一 store·非冲突·对齐 Knowledge 范式。
**⚪ low(可选·非必修)**:
1. `Knowledge.vue submitCreate`(:438)/ `ProjectDetail.vue handleApproval`(:417) 缺显式 `if(submitting) return` 入口 guard(其余 5 处有)。实际防并发由模板 `:disabled="submitting"` 兜住(按钮点不动·无重入),IPC 路径单调用,风险极低。可选补 guard 对齐范式一致性。
2. `ConnectionPanel.vue saveConn`(:152) 是同步函数(无 await),submitting try/finally 同步置位即复位,此处 submitting 实际无防双击语义(纯占位/视觉)。无功能影响,可选移除或改 async。
---
## 已审归档 ## 已审归档
> 已审 CR 段迁独立文件: [待审查归档/2026-06.md](./07-项目管理/待审查归档/2026-06.md) > 已审 CR 段迁独立文件: [待审查归档/2026-06.md](./07-项目管理/待审查归档/2026-06.md)

View File

@@ -9,7 +9,8 @@
"dev:restart": "npm run dev:stop && npm run dev", "dev:restart": "npm run dev:stop && npm run dev",
"build": "vue-tsc --noEmit && vite build", "build": "vue-tsc --noEmit && vite build",
"preview": "vite preview", "preview": "vite preview",
"tauri": "tauri" "tauri": "tauri",
"build:dev": "cargo tauri build --debug"
}, },
"dependencies": { "dependencies": {
"@arco-design/web-vue": "^2.58.0", "@arco-design/web-vue": "^2.58.0",

View File

@@ -22,6 +22,8 @@ const MAX_LIST_RESULTS: usize = 50;
/// run_command 默认超时。LLM 可在 args timeout_secs 覆盖此默认值。 /// run_command 默认超时。LLM 可在 args timeout_secs 覆盖此默认值。
/// 提取为常量便于在超时标注处引用同一来源F-260616-04 /// 提取为常量便于在超时标注处引用同一来源F-260616-04
const DEFAULT_RUN_COMMAND_TIMEOUT_SECS: u64 = 60; const DEFAULT_RUN_COMMAND_TIMEOUT_SECS: u64 = 60;
/// run_command 超时上限(秒):防 LLM 传超大 timeout_secs 冻结会话(允许长构建,封顶 10min)
const MAX_RUN_COMMAND_TIMEOUT_SECS: u64 = 600;
/// 生成行级 unified diff无外部依赖基于 LCS /// 生成行级 unified diff无外部依赖基于 LCS
/// 仅标 +/- 前缀,不做 hunk header足够审批卡/审计留痕可读)。 /// 仅标 +/- 前缀,不做 hunk header足够审批卡/审计留痕可读)。
@@ -358,11 +360,26 @@ async fn bind_dir_to_project(
/// 构建 AI 工具注册表 — handler 即唯一执行路径schema+risk+实现同源,消除双轨) /// 构建 AI 工具注册表 — handler 即唯一执行路径schema+risk+实现同源,消除双轨)
/// ///
/// CRUD 工具闭包捕获 `db` Arc 重建 Repo文件系统工具复用 resolve_workspace_path / /// SMELL-P0-2 拆分:原 1091 行单函数拆为两层注册。
/// list_dir_recursive。新增工具只改这里一处定义与实现同源编译期保证一致 /// - data 层(register_data_tools):18 个持 db 的 CRUD/状态机/工作流工具,闭包捕获 db Arc 重建 Repo
/// - file 层(register_file_tools):10 个纯文件系统工具,不持 db。
/// 新增工具改对应 register_* 一处,定义与实现同源,编译期保证一致。
pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry { pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
let mut registry = AiToolRegistry::new(); let mut registry = AiToolRegistry::new();
register_data_tools(&mut registry, db);
register_file_tools(&mut registry);
registry
}
/// 数据层 AI 工具注册(18 个持 db 的 CRUD/状态机/工作流工具)——从 build_ai_tool_registry 抽出。
///
/// 工具闭包捕获 `db: &Arc<Database>` Arc 重建 Repo(列表/创建/更新/删除/状态推进/工作流)。
/// 例外:run_workflow handler 防御返回 Err(CR-52),不持 db 不 clone,真正执行经
/// ai_approve → run_workflow_inner 另走完整 State 路径。
///
/// SMELL-P0-2:抽自原 build_ai_tool_registry 1091 行单函数(数据+文件混合)。
/// 18 个 register 调用【原样移入】,零行为变更,仅机械搬运。
fn register_data_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
// ── 只读 (Low) ── // ── 只读 (Low) ──
registry.register( registry.register(
"list_projects", "列出所有项目,支持 offset/limit 分页。返回 items项目列表、total总量、has_more是否有更多页。默认 limit=50", "list_projects", "列出所有项目,支持 offset/limit 分页。返回 items项目列表、total总量、has_more是否有更多页。默认 limit=50",
@@ -713,6 +730,41 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
}) })
})}, })},
); );
// ── 探总量工具 (Low risk, F-260616-08) ──
registry.register(
"get_project_count", "获取项目总数(未删除项目),用于分页策略判断。返回 { total: usize }",
df_ai::ai_tools::object_schema(vec![]), RiskLevel::Low,
{ let db = db.clone(); Box::new(move |_args: serde_json::Value| {
let db = db.clone();
Box::pin(async move {
let repo = df_storage::crud::ProjectRepo::new(&db);
let items = repo.list_active().await?;
Ok(serde_json::json!({ "total": items.len() }))
})
})},
);
registry.register(
"get_task_count", "获取任务总数(未删除任务),用于分页策略判断。返回 { total: usize }",
df_ai::ai_tools::object_schema(vec![("project_id", "string", false)]), RiskLevel::Low,
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
let db = db.clone();
Box::pin(async move {
let repo = df_storage::crud::TaskRepo::new(&db);
let tasks = if let Some(pid) = args.get("project_id").and_then(|v| v.as_str()) {
repo.query("project_id", pid).await?
} else {
repo.list_all().await?
};
Ok(serde_json::json!({ "total": tasks.len() }))
})
})},
);
}
/// 文件系统类 AI 工具注册(命令执行/读/列/写/改/元/追加/删/移/搜)——不持 db,纯文件系统操作。
/// 从 build_ai_tool_registry 抽出(SMELL-P0-2:1091行单函数拆分,build 1090→~390 行)。
fn register_file_tools(registry: &mut AiToolRegistry) {
registry.register( registry.register(
"run_command", "在指定工作目录执行 shell 命令(跑测试/构建/查看运行结果),返回 stdout/stderr/exit_code。高风险须人工批准。命令需自包含非交互式避免需用户输入的程序。默认超时 60 秒。用于验证刚写入的代码能否运行、跑测试、看报错迭代修改。", "run_command", "在指定工作目录执行 shell 命令(跑测试/构建/查看运行结果),返回 stdout/stderr/exit_code。高风险须人工批准。命令需自包含非交互式避免需用户输入的程序。默认超时 60 秒。用于验证刚写入的代码能否运行、跑测试、看报错迭代修改。",
df_ai::ai_tools::object_schema(vec![ df_ai::ai_tools::object_schema(vec![
@@ -736,7 +788,8 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
None => workspace_root().to_string_lossy().to_string(), None => workspace_root().to_string_lossy().to_string(),
}; };
// timeout 默认 60s:防 hang(交互式命令/死循环/大构建),LLM 可通过 args timeout_secs 覆盖 // timeout 默认 60s:防 hang(交互式命令/死循环/大构建),LLM 可通过 args timeout_secs 覆盖
let timeout_secs = args["timeout_secs"].as_u64().unwrap_or(DEFAULT_RUN_COMMAND_TIMEOUT_SECS); // clamp 封顶 MAX:防 LLM 传超大 timeout_secs 冻结会话(需更长命令应拆分而非无限等)
let timeout_secs = args["timeout_secs"].as_u64().unwrap_or(DEFAULT_RUN_COMMAND_TIMEOUT_SECS).min(MAX_RUN_COMMAND_TIMEOUT_SECS);
let request = ShellRequest { let request = ShellRequest {
command: command.to_string(), command: command.to_string(),
@@ -1418,38 +1471,6 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
Ok(serde_json::json!({ "path": path, "pattern": pattern, "results": page_results, "total": total, "has_more": has_more })) Ok(serde_json::json!({ "path": path, "pattern": pattern, "results": page_results, "total": total, "has_more": has_more }))
})), })),
); );
// ── 探总量工具 (Low risk, F-260616-08) ──
registry.register(
"get_project_count", "获取项目总数(未删除项目),用于分页策略判断。返回 { total: usize }",
df_ai::ai_tools::object_schema(vec![]), RiskLevel::Low,
{ let db = db.clone(); Box::new(move |_args: serde_json::Value| {
let db = db.clone();
Box::pin(async move {
let repo = df_storage::crud::ProjectRepo::new(&db);
let items = repo.list_active().await?;
Ok(serde_json::json!({ "total": items.len() }))
})
})},
);
registry.register(
"get_task_count", "获取任务总数(未删除任务),用于分页策略判断。返回 { total: usize }",
df_ai::ai_tools::object_schema(vec![("project_id", "string", false)]), RiskLevel::Low,
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
let db = db.clone();
Box::pin(async move {
let repo = df_storage::crud::TaskRepo::new(&db);
let tasks = if let Some(pid) = args.get("project_id").and_then(|v| v.as_str()) {
repo.query("project_id", pid).await?
} else {
repo.list_all().await?
};
Ok(serde_json::json!({ "total": tasks.len() }))
})
})},
);
registry
} }
/// 递归列出目录内容(最多 max_depth 层,最多 max_entries 条) /// 递归列出目录内容(最多 max_depth 层,最多 max_entries 条)
@@ -1625,6 +1646,59 @@ mod tests {
use super::*; use super::*;
use std::fs; use std::fs;
// ============================================================
// 工具注册基线测试SMELL-P0-2 拆分防护)
//
// 防未来 register_data_tools / register_file_tools 拆分或重构时静默丢工具。
// build_ai_tool_registry 经两层 register_* 组装:data(18 持 db) + file(10 不持 db) = 28。
// 任一层漏移 register 调用,此测试立即红。工具名集合也断言,防 rename 致 LLM tool 突变。
// ============================================================
/// build_ai_tool_registry 应注册恰好 28 个工具(18 data + 10 file),且工具名集合稳定。
///
/// 用 in-memory SQLite(Database::open_in_memory 自跑迁移),构造零外部依赖的 db,
// 不实际执行任何 handler——仅断言注册阶段的定义完整性,故无需真实数据。
#[tokio::test]
async fn test_build_ai_tool_registry_baseline_tool_count() {
let db = Database::open_in_memory().await.expect("in-memory db 初始化失败");
let db = Arc::new(db);
let registry = build_ai_tool_registry(&db);
// 总量基线:28(18 data + 10 file)。拆分前后必须一致。
assert_eq!(
registry.len(),
28,
"工具总数应为 28(18 data + 10 file),实际 {}", registry.len()
);
// 工具名集合基线:防 rename / 漏注册 / 误删除。
// data 层 18 个(持 db):CRUD/状态机/工作流
// file 层 10 个(不持 db):命令/读/列/写/改/元/追加/删/移/搜
let mut expected: Vec<&str> = vec![
// ── data 层 (18) ──
"list_projects", "list_tasks", "list_ideas",
"update_project", "create_project", "bind_directory",
"create_task", "update_task", "advance_task",
"run_workflow", "delete_task", "create_idea",
"delete_project", "restore_project", "purge_project",
"list_trash", "get_project_count", "get_task_count",
// ── file 层 (10) ──
"run_command", "read_file", "list_directory",
"write_file", "patch_file", "file_info",
"append_file", "delete_file", "rename_file", "search_files",
];
expected.sort_unstable();
let mut actual: Vec<String> = registry.tool_names();
actual.sort();
assert_eq!(
actual,
expected.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
"工具名集合与基线不一致(可能漏注册或 rename)"
);
}
/// is_noise_dir 纯函数:覆盖 .git/.gitignore 区分(目录是噪音,.gitignore 文件名不是) /// is_noise_dir 纯函数:覆盖 .git/.gitignore 区分(目录是噪音,.gitignore 文件名不是)
#[test] #[test]
fn test_is_noise_dir_distinguishes_dir_and_gitignore_file() { fn test_is_noise_dir_distinguishes_dir_and_gitignore_file() {

View File

@@ -60,7 +60,7 @@
<input v-model="connForm.user" class="setting-input" :placeholder="$t('settings.phUser')" /> <input v-model="connForm.user" class="setting-input" :placeholder="$t('settings.phUser')" />
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button class="btn btn-primary" @click="saveConn">{{ $t('common.save') }}</button> <button class="btn btn-primary" :disabled="submitting" @click="saveConn">{{ $t('common.save') }}</button>
</div> </div>
</div> </div>
</section> </section>
@@ -106,6 +106,9 @@ const connForm = reactive({
user: '', user: '',
}) })
// 异步操作禁用态(防双击重复提交):saveConn 保存连接期间禁用按钮
const submitting = ref(false)
const CONN_STORAGE_KEY = 'df-connections' const CONN_STORAGE_KEY = 'df-connections'
function loadConnections() { function loadConnections() {
@@ -151,6 +154,8 @@ function saveConn() {
emit('toast', t('settings.toastConnIncomplete'), 'warning') emit('toast', t('settings.toastConnIncomplete'), 'warning')
return return
} }
submitting.value = true
try {
const record: ConnRecord = { const record: ConnRecord = {
id: connForm.editId || Date.now().toString(36), id: connForm.editId || Date.now().toString(36),
name: connForm.name, name: connForm.name,
@@ -167,6 +172,9 @@ function saveConn() {
} }
persistConnections() persistConnections()
connForm.visible = false connForm.visible = false
} finally {
submitting.value = false
}
} }
async function removeConn(id: string) { async function removeConn(id: string) {

View File

@@ -46,7 +46,7 @@ function applyUiState(s: any) {
} }
// 启动恢复:从 appSettings(SQLite)读取上次的 UI 布局(模块级单例 state) // 启动恢复:从 appSettings(SQLite)读取上次的 UI 布局(模块级单例 state)
export function restoreUiState() { function restoreUiState() {
applyUiState(appSettings.get<any>('df-ai-ui', null)) applyUiState(appSettings.get<any>('df-ai-ui', null))
} }
restoreUiState() restoreUiState()

View File

@@ -22,13 +22,6 @@ export interface ConfirmState {
resolve: null | ((v: boolean) => void) resolve: null | ((v: boolean) => void)
} }
/** 弹出确认对话框,返回 Promise;resolve(true) 表示用户确认,resolve(false) 表示取消。
* dangerLabel 可选:覆盖危险按钮文案(如「清空」),不传则回退组件兜底 common.confirm,保持旧行为。 */
export type ConfirmFn = (msg: string, dangerLabel?: string) => Promise<boolean>
/** 关闭弹层并回传结果 */
export type AnswerFn = (ok: boolean) => void
export function useConfirm() { export function useConfirm() {
const confirmState = ref<ConfirmState>({ const confirmState = ref<ConfirmState>({
visible: false, visible: false,

View File

@@ -132,8 +132,6 @@ export function useMarkdown() {
escapeFallback, escapeFallback,
getMarked: () => _marked, getMarked: () => _marked,
getPurify: () => _purify, getPurify: () => _purify,
// 仅 AiChat 流式块级 memo 用(独立缓存,不与历史 _mdCache 混)
clearHistoryCache: () => _mdCache.clear(),
} }
} }

View File

@@ -32,6 +32,10 @@ export default {
noIdeas: 'No ideas yet, click above to capture', noIdeas: 'No ideas yet, click above to capture',
noData: 'No data', noData: 'No data',
}, },
// Error fallback (refresh/onMounted load failures surfaced to user)
err: {
loadFailed: 'Failed to load data, please retry later',
},
stage: { stage: {
planning: 'Planning', planning: 'Planning',
coding: 'Coding', coding: 'Coding',

View File

@@ -16,6 +16,11 @@ export default {
// Empty state // Empty state
emptyState: 'Select an idea to view details', emptyState: 'Select an idea to view details',
// List empty / async button placeholders
listEmpty: '📭 No ideas yet. Click "Capture Idea" at the top right to start.',
creating: 'Creating…',
deleting: 'Deleting…',
promoting: 'Promoting…',
// Adversarial evaluation // Adversarial evaluation
adversarialTitle: '⚖️ Adversarial Evaluation', adversarialTitle: '⚖️ Adversarial Evaluation',

View File

@@ -32,6 +32,10 @@ export default {
noIdeas: '暂无灵感,点击上方按钮捕捉', noIdeas: '暂无灵感,点击上方按钮捕捉',
noData: '暂无数据', noData: '暂无数据',
}, },
// 错误回退(refresh/onMounted 加载失败对用户可见)
err: {
loadFailed: '加载数据失败,请稍后重试',
},
stage: { stage: {
planning: '规划中', planning: '规划中',
coding: '编码中', coding: '编码中',

View File

@@ -16,6 +16,11 @@ export default {
// 空状态 // 空状态
emptyState: '选择一个灵感查看详情', emptyState: '选择一个灵感查看详情',
// 列表空态/异步按钮占位
listEmpty: '📭 还没有灵感,点击右上角「捕捉灵感」开始记录',
creating: '创建中…',
deleting: '删除中…',
promoting: '立项中…',
// 对抗式评估 // 对抗式评估
adversarialTitle: '⚖️ 对抗式评估', adversarialTitle: '⚖️ 对抗式评估',

View File

@@ -16,7 +16,7 @@ export function stripMd(text: string | null | undefined): string {
return text return text
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1') // 图片 alt .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1') // 图片 alt
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // 链接文本 .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // 链接文本
.replace(/```[a-zA-Z]*\n?/g, '') // 代码块围栏开 .replace(/```[^\n]*\n?/g, '') // 代码块围栏开(含任意 infostring 至换行,如 rust,norun)
.replace(/```/g, '') // 代码块围栏闭 .replace(/```/g, '') // 代码块围栏闭
.replace(/`([^`]+)`/g, '$1') // 行内代码 .replace(/`([^`]+)`/g, '$1') // 行内代码
.replace(/^\s{0,3}>\s?/gm, '') // 引用 .replace(/^\s{0,3}>\s?/gm, '') // 引用

View File

@@ -41,7 +41,8 @@ export function formatDate(v: string | number | null | undefined): string {
const ms = parseTs(v) const ms = parseTs(v)
if (ms == null) return '—' if (ms == null) return '—'
const d = new Date(ms) const d = new Date(ms)
return d.toLocaleDateString('zh-CN') + ' ' + d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) const locale = (i18n as any).global.locale.value === 'en' ? 'en-US' : 'zh-CN'
return d.toLocaleDateString(locale) + ' ' + d.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' })
} }
/** 中文相对时间 '刚刚 / X 分钟前 / X 小时前 / X 天前';无效返回 '—' */ /** 中文相对时间 '刚刚 / X 分钟前 / X 小时前 / X 天前';无效返回 '—' */

View File

@@ -7,7 +7,7 @@
<span class="dash-subtitle">{{ $t('dashboard.subtitle') }}</span> <span class="dash-subtitle">{{ $t('dashboard.subtitle') }}</span>
</div> </div>
<div class="dash-header-right"> <div class="dash-header-right">
<button class="df-btn df-btn--ghost" @click="refresh"> <button class="df-btn df-btn--ghost" @click="refresh" :disabled="loading" :title="$t('dashboard.refresh')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>
</button> </button>
<button class="df-btn df-btn--primary" @click="quickCapture"> <button class="df-btn df-btn--primary" @click="quickCapture">
@@ -17,6 +17,12 @@
</div> </div>
</header> </header>
<!-- 错误条:refresh/onMounted 加载失败对用户可见(对齐 Knowledge error-banner) -->
<div v-if="errorMsg" class="error-banner">
<span class="error-text">{{ errorMsg }}</span>
<button class="error-dismiss" @click="errorMsg = ''"></button>
</div>
<!-- Stat Cards --> <!-- Stat Cards -->
<div class="stat-row"> <div class="stat-row">
<div <div
@@ -104,7 +110,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useProjectStore } from '@/stores/project' import { useProjectStore } from '@/stores/project'
@@ -114,6 +120,10 @@ const router = useRouter()
const store = useProjectStore() const store = useProjectStore()
const { t } = useI18n() const { t } = useI18n()
// 加载态(refresh/load 期间 true,驱动刷新按钮禁用) + 错误态(加载失败对用户可见)
const loading = ref(false)
const errorMsg = ref('')
const stats = computed(() => [ const stats = computed(() => [
{ key: 'ideas', icon: '\u{1F4A1}', label: t('dashboard.stats.ideas'), value: store.stats.ideas, trend: 0, { key: 'ideas', icon: '\u{1F4A1}', label: t('dashboard.stats.ideas'), value: store.stats.ideas, trend: 0,
iconBg: 'rgba(123,111,240,0.12)' }, iconBg: 'rgba(123,111,240,0.12)' },
@@ -179,24 +189,32 @@ function ideaStatusLabel(status: string): string {
return t('dashboard.ideaStatus.' + status) return t('dashboard.ideaStatus.' + status)
} }
async function refresh() { // 统一加载入口(projects/tasks/ideas 并行),管理 loading + errorMsg
// refresh 与 onMounted 共用,避免两处 catch 逻辑重复
async function loadAll() {
if (loading.value) return // 防重复触发
loading.value = true
errorMsg.value = ''
try { try {
await Promise.all([store.loadProjects(), store.loadTasks(), store.loadIdeas()]) await Promise.all([store.loadProjects(), store.loadTasks(), store.loadIdeas()])
} catch (e) { } catch (e: any) {
console.error('Dashboard refresh failed:', e) console.error('Dashboard load failed:', e)
errorMsg.value = e?.toString() ?? t('dashboard.err.loadFailed')
} finally {
loading.value = false
} }
} }
async function refresh() {
await loadAll()
}
function quickCapture() { function quickCapture() {
router.push('/ideas') router.push('/ideas')
} }
onMounted(async () => { onMounted(() => {
try { loadAll()
await Promise.all([store.loadProjects(), store.loadTasks(), store.loadIdeas()])
} catch (e) {
console.error('Dashboard load failed:', e)
}
}) })
</script> </script>
@@ -271,6 +289,21 @@ onMounted(async () => {
border-color: var(--df-border-strong); border-color: var(--df-border-strong);
} }
.df-btn--xs { padding: 4px 10px; font-size: 11px; } .df-btn--xs { padding: 4px 10px; font-size: 11px; }
.df-btn:disabled { opacity: 0.4; cursor: not-allowed; }
/* ═══ 错误条(对齐 Knowledge error-banner) ═══ */
.error-banner {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: 8px 12px; margin-bottom: 14px;
background: rgba(255,107,107,0.12); border: 0.5px solid var(--df-danger);
border-radius: var(--df-radius-sm); color: var(--df-danger); font-size: 12px;
}
.error-text { word-break: break-word; }
.error-dismiss {
flex-shrink: 0; background: transparent; border: none; color: var(--df-danger);
font-size: 14px; cursor: pointer; line-height: 1; padding: 0 2px;
}
.error-dismiss:hover { opacity: 0.7; }
/* ═══ Stat Cards ═══ */ /* ═══ Stat Cards ═══ */
.stat-row { .stat-row {

View File

@@ -33,6 +33,11 @@
<!-- 左侧灵感列表 --> <!-- 左侧灵感列表 -->
<section class="idea-list-panel"> <section class="idea-list-panel">
<div class="idea-list"> <div class="idea-list">
<!-- 三态:加载/错误/(对齐 Tasks.vue:43-45) -->
<div v-if="store.loading" class="empty-state">{{ $t('common.loading') }}</div>
<div v-else-if="store.error" class="empty-state">{{ store.error }}</div>
<div v-else-if="filteredIdeas.length === 0" class="empty-state">{{ $t('ideas.listEmpty') }}</div>
<template v-else>
<div <div
v-for="idea in filteredIdeas" v-for="idea in filteredIdeas"
:key="idea.id" :key="idea.id"
@@ -50,6 +55,7 @@
<span class="idea-date">{{ formatDate(idea.created_at) }}</span> <span class="idea-date">{{ formatDate(idea.created_at) }}</span>
</div> </div>
</div> </div>
</template>
</div> </div>
</section> </section>
@@ -167,11 +173,14 @@
<button <button
v-if="currentIdea.status === 'approved' && !currentIdea.promoted_to" v-if="currentIdea.status === 'approved' && !currentIdea.promoted_to"
class="btn btn-primary" class="btn btn-primary"
:disabled="promoting"
@click="promoteToProject" @click="promoteToProject"
> >
{{ $t('ideas.promoteToProject') }} {{ promoting ? $t('ideas.promoting') : $t('ideas.promoteToProject') }}
</button>
<button class="btn btn-ghost" :disabled="deleting" @click="deleteCurrentIdea">
{{ deleting ? $t('ideas.deleting') : $t('ideas.deleteIdea') }}
</button> </button>
<button class="btn btn-ghost" @click="deleteCurrentIdea">{{ $t('ideas.deleteIdea') }}</button>
</div> </div>
</div> </div>
</section> </section>
@@ -195,7 +204,9 @@
<textarea v-model="newIdeaDesc" :placeholder="$t('ideas.descPlaceholder')" rows="3" style="resize:vertical"></textarea> <textarea v-model="newIdeaDesc" :placeholder="$t('ideas.descPlaceholder')" rows="3" style="resize:vertical"></textarea>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn-cancel" @click="showCaptureModal = false">{{ $t('common.cancel') }}</button> <button class="btn-cancel" @click="showCaptureModal = false">{{ $t('common.cancel') }}</button>
<button class="btn-confirm" @click="confirmCapture">{{ $t('common.confirm') }}</button> <button class="btn-confirm" :disabled="creating" @click="confirmCapture">
{{ creating ? $t('ideas.creating') : $t('common.confirm') }}
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -238,6 +249,11 @@ const showCaptureModal = ref(false)
const newIdeaTitle = ref('') const newIdeaTitle = ref('')
const newIdeaDesc = ref('') const newIdeaDesc = ref('')
// ── 异步按钮禁用态(防双击重复提交,对齐 ProviderPanel.vue saving ref) ──
const creating = ref(false)
const deleting = ref(false)
const promoting = ref(false)
const filters: { key: FilterKey; labelKey: string; icon: string }[] = [ const filters: { key: FilterKey; labelKey: string; icon: string }[] = [
{ key: 'all', labelKey: 'ideas.filter.all', icon: '📋' }, { key: 'all', labelKey: 'ideas.filter.all', icon: '📋' },
{ key: 'hot', labelKey: 'ideas.filter.hot', icon: '🔥' }, { key: 'hot', labelKey: 'ideas.filter.hot', icon: '🔥' },
@@ -383,23 +399,34 @@ function openCaptureModal() {
} }
async function confirmCapture() { async function confirmCapture() {
if (!newIdeaTitle.value.trim()) return if (!newIdeaTitle.value.trim() || creating.value) return
creating.value = true
try {
await store.createIdea({ await store.createIdea({
title: newIdeaTitle.value.trim(), title: newIdeaTitle.value.trim(),
description: newIdeaDesc.value.trim() || undefined, description: newIdeaDesc.value.trim() || undefined,
}) })
showCaptureModal.value = false showCaptureModal.value = false
} finally {
creating.value = false
}
} }
async function deleteCurrentIdea() { async function deleteCurrentIdea() {
if (!currentIdea.value) return if (!currentIdea.value || deleting.value) return
if (!await confirmDialog(t('ideas.confirmDelete', { title: currentIdea.value.title }))) return if (!await confirmDialog(t('ideas.confirmDelete', { title: currentIdea.value.title }))) return
deleting.value = true
try {
await store.deleteIdea(currentIdea.value.id) await store.deleteIdea(currentIdea.value.id)
selectedId.value = null selectedId.value = null
} finally {
deleting.value = false
}
} }
async function promoteToProject() { async function promoteToProject() {
if (!currentIdea.value) return if (!currentIdea.value || promoting.value) return
promoting.value = true
try { try {
const res = await store.promoteIdea(currentIdea.value.id) const res = await store.promoteIdea(currentIdea.value.id)
router.push(`/projects/${res.project_id}`) router.push(`/projects/${res.project_id}`)
@@ -407,6 +434,8 @@ async function promoteToProject() {
const msg = e?.toString() ?? t('ideas.promoteFailed') const msg = e?.toString() ?? t('ideas.promoteFailed')
console.error(t('ideas.promoteFailed'), e) console.error(t('ideas.promoteFailed'), e)
Message.error(msg) Message.error(msg)
} finally {
promoting.value = false
} }
} }

View File

@@ -96,14 +96,14 @@
<div class="detail-actions" v-if="!editing"> <div class="detail-actions" v-if="!editing">
<button class="btn btn-ghost btn-sm" @click="startEdit">{{ $t('knowledge.edit') }}</button> <button class="btn btn-ghost btn-sm" @click="startEdit">{{ $t('knowledge.edit') }}</button>
<template v-if="topTab === 'inbox'"> <template v-if="topTab === 'inbox'">
<button class="btn btn-primary btn-sm" @click="publishCurrent">{{ $t('knowledge.publish') }}</button> <button class="btn btn-primary btn-sm" :disabled="submitting" @click="publishCurrent">{{ $t('knowledge.publish') }}</button>
<button class="btn btn-ghost btn-sm" @click="rejectCurrent">{{ $t('knowledge.reject') }}</button> <button class="btn btn-ghost btn-sm" :disabled="submitting" @click="rejectCurrent">{{ $t('knowledge.reject') }}</button>
</template> </template>
<button v-else class="btn btn-ghost btn-sm" @click="archiveCurrent">{{ $t('knowledge.archive') }}</button> <button v-else class="btn btn-ghost btn-sm" :disabled="submitting" @click="archiveCurrent">{{ $t('knowledge.archive') }}</button>
</div> </div>
<div class="detail-actions" v-else> <div class="detail-actions" v-else>
<button class="btn btn-ghost btn-sm" @click="cancelEdit">{{ $t('common.cancel') }}</button> <button class="btn btn-ghost btn-sm" @click="cancelEdit">{{ $t('common.cancel') }}</button>
<button class="btn btn-primary btn-sm" @click="saveEdit" :disabled="!editForm.title.trim()">{{ $t('common.save') }}</button> <button class="btn btn-primary btn-sm" @click="saveEdit" :disabled="submitting || !editForm.title.trim()">{{ $t('common.save') }}</button>
</div> </div>
</div> </div>
@@ -230,7 +230,7 @@
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-ghost" @click="closeCreateModal">{{ $t('common.cancel') }}</button> <button class="btn btn-ghost" @click="closeCreateModal">{{ $t('common.cancel') }}</button>
<button class="btn btn-primary" @click="submitCreate" :disabled="!form.title.trim()">{{ $t('knowledge.create') }}</button> <button class="btn btn-primary" @click="submitCreate" :disabled="submitting || !form.title.trim()">{{ $t('knowledge.create') }}</button>
</div> </div>
</div> </div>
</div> </div>
@@ -349,6 +349,9 @@ watch(listItems, items => {
const editing = ref(false) const editing = ref(false)
const editForm = ref({ title: '', content: '', tagsInput: '', confidence: '' }) const editForm = ref({ title: '', content: '', tagsInput: '', confidence: '' })
// 异步操作禁用态(IPC 期间防双击重复提交):saveEdit/publishCurrent/rejectCurrent/archiveCurrent/submitCreate 共用
const submitting = ref(false)
function startEdit() { function startEdit() {
if (!detail.value) return if (!detail.value) return
const k = detail.value.knowledge const k = detail.value.knowledge
@@ -367,6 +370,8 @@ function cancelEdit() {
async function saveEdit() { async function saveEdit() {
if (!detail.value || !editForm.value.title.trim()) return if (!detail.value || !editForm.value.title.trim()) return
submitting.value = true
try {
const tags = editForm.value.tagsInput.split(',').map(t => t.trim()).filter(Boolean) const tags = editForm.value.tagsInput.split(',').map(t => t.trim()).filter(Boolean)
await store.update(detail.value.knowledge.id, { await store.update(detail.value.knowledge.id, {
title: editForm.value.title.trim(), title: editForm.value.title.trim(),
@@ -377,28 +382,46 @@ async function saveEdit() {
editing.value = false editing.value = false
// 重新拉详情 // 重新拉详情
await selectKnowledge(detail.value.knowledge.id) await selectKnowledge(detail.value.knowledge.id)
} finally {
submitting.value = false
}
} }
// ===== 审核操作(收件箱) ===== // ===== 审核操作(收件箱) =====
async function publishCurrent() { async function publishCurrent() {
if (!detail.value) return if (!detail.value) return
submitting.value = true
try {
await store.updateStatus(detail.value.knowledge.id, 'published') await store.updateStatus(detail.value.knowledge.id, 'published')
selectedId.value = null selectedId.value = null
detail.value = null detail.value = null
} finally {
submitting.value = false
}
} }
async function rejectCurrent() { async function rejectCurrent() {
if (!detail.value) return if (!detail.value) return
submitting.value = true
try {
await store.archive(detail.value.knowledge.id) await store.archive(detail.value.knowledge.id)
selectedId.value = null selectedId.value = null
detail.value = null detail.value = null
} finally {
submitting.value = false
}
} }
async function archiveCurrent() { async function archiveCurrent() {
if (!detail.value) return if (!detail.value) return
submitting.value = true
try {
await store.archive(detail.value.knowledge.id) await store.archive(detail.value.knowledge.id)
selectedId.value = null selectedId.value = null
detail.value = null detail.value = null
} finally {
submitting.value = false
}
} }
// ===== 新增对话框 ===== // ===== 新增对话框 =====
@@ -413,6 +436,8 @@ function closeCreateModal() {
showCreateModal.value = false showCreateModal.value = false
} }
async function submitCreate() { async function submitCreate() {
submitting.value = true
try {
const tags = form.value.tagsInput.split(',').map(t => t.trim()).filter(Boolean) const tags = form.value.tagsInput.split(',').map(t => t.trim()).filter(Boolean)
await store.create({ await store.create({
kind: form.value.kind, kind: form.value.kind,
@@ -423,6 +448,9 @@ async function submitCreate() {
}) })
showCreateModal.value = false showCreateModal.value = false
switchTopTab('inbox') switchTopTab('inbox')
} finally {
submitting.value = false
}
} }
// ===== 事件解析辅助 ===== // ===== 事件解析辅助 =====

View File

@@ -33,7 +33,7 @@
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-ghost" @click="showNewTaskModal = false">{{ $t('common.cancel') }}</button> <button class="btn btn-ghost" @click="showNewTaskModal = false">{{ $t('common.cancel') }}</button>
<button class="btn btn-primary" @click="submitNewTask" :disabled="!newTaskTitle.trim()">{{ $t('projectDetail.confirmCreate') }}</button> <button class="btn btn-primary" @click="submitNewTask" :disabled="submitting || !newTaskTitle.trim()">{{ $t('projectDetail.confirmCreate') }}</button>
</div> </div>
</div> </div>
</div> </div>
@@ -208,6 +208,7 @@
:key="idx" :key="idx"
class="btn" class="btn"
:class="idx === 0 ? 'btn-primary' : 'btn-ghost'" :class="idx === 0 ? 'btn-primary' : 'btn-ghost'"
:disabled="submitting"
@click="handleApproval(option)" @click="handleApproval(option)"
> >
{{ option }} {{ option }}
@@ -319,8 +320,13 @@ const newTaskTitle = ref('')
const newTaskDesc = ref('') const newTaskDesc = ref('')
const newTaskBranch = ref('') const newTaskBranch = ref('')
// 异步操作禁用态(IPC 期间防双击重复提交):submitNewTask 创建任务期间禁用确认按钮
const submitting = ref(false)
async function submitNewTask() { async function submitNewTask() {
if (!newTaskTitle.value.trim()) return if (!newTaskTitle.value.trim()) return
submitting.value = true
try {
const r = await store.createTask({ const r = await store.createTask({
project_id: projectId.value, project_id: projectId.value,
title: newTaskTitle.value.trim(), title: newTaskTitle.value.trim(),
@@ -332,6 +338,9 @@ async function submitNewTask() {
newTaskTitle.value = '' newTaskTitle.value = ''
newTaskDesc.value = '' newTaskDesc.value = ''
newTaskBranch.value = '' newTaskBranch.value = ''
} finally {
submitting.value = false
}
} }
// ── 同步 ── // ── 同步 ──
@@ -406,8 +415,13 @@ async function handleImportDir() {
// ── 审批处理 ── // ── 审批处理 ──
async function handleApproval(decision: string) { async function handleApproval(decision: string) {
submitting.value = true
try {
await store.approveHumanApproval([decision]) await store.approveHumanApproval([decision])
showApprovalDialog.value = false showApprovalDialog.value = false
} finally {
submitting.value = false
}
} }
// F-260615-01: 多选审批 // F-260615-01: 多选审批

View File

@@ -9,6 +9,12 @@
</div> </div>
</header> </header>
<!-- 错误条:消费 store.error(加载/创建/删除失败对用户可见,对齐 Knowledge error-banner) -->
<div v-if="store.error" class="error-banner">
<span class="error-text">{{ store.error }}</span>
<button class="error-dismiss" @click="store.clearError()"></button>
</div>
<!-- 新建项目模态框 --> <!-- 新建项目模态框 -->
<div v-if="showCreateModal" class="modal-overlay" @click.self="showCreateModal = false"> <div v-if="showCreateModal" class="modal-overlay" @click.self="showCreateModal = false">
<div class="modal-box"> <div class="modal-box">
@@ -42,7 +48,7 @@
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-ghost" @click="showCreateModal = false">{{ $t('common.cancel') }}</button> <button class="btn btn-ghost" @click="showCreateModal = false">{{ $t('common.cancel') }}</button>
<button class="btn btn-primary" @click="submitCreate" :disabled="!newName.trim()">{{ $t('projects.confirmCreate') }}</button> <button class="btn btn-primary" @click="submitCreate" :disabled="submitting || !newName.trim()">{{ $t('projects.confirmCreate') }}</button>
</div> </div>
</div> </div>
</div> </div>
@@ -215,6 +221,8 @@ const pathWarning = ref('')
const scanning = ref(false) const scanning = ref(false)
const scanError = ref('') const scanError = ref('')
const scanProjectType = ref('') const scanProjectType = ref('')
// 创建提交中(防双击重复提交,对齐 ProviderPanel.saving 模式)
const submitting = ref(false)
// 选择目录 → 探测技术栈 + 查重 // 选择目录 → 探测技术栈 + 查重
async function pickDir() { async function pickDir() {
@@ -267,6 +275,9 @@ async function aiScan() {
async function submitCreate() { async function submitCreate() {
if (!newName.value.trim()) return if (!newName.value.trim()) return
if (pathWarning.value) return // 目录冲突,禁止创建 if (pathWarning.value) return // 目录冲突,禁止创建
if (submitting.value) return // 防双击重复提交
submitting.value = true
try {
const stack = detectedStack.value.length ? JSON.stringify(detectedStack.value) : undefined const stack = detectedStack.value.length ? JSON.stringify(detectedStack.value) : undefined
const r = await store.createProject(newName.value.trim(), newDesc.value.trim(), undefined, newPath.value || undefined, stack) const r = await store.createProject(newName.value.trim(), newDesc.value.trim(), undefined, newPath.value || undefined, stack)
if (!r) return // 失败已 toast,保持弹窗不关 if (!r) return // 失败已 toast,保持弹窗不关
@@ -274,6 +285,9 @@ async function submitCreate() {
newName.value = '' newName.value = ''
newDesc.value = '' newDesc.value = ''
clearDir() clearDir()
} finally {
submitting.value = false
}
} }
// ── 删除 / 回收站 ── // ── 删除 / 回收站 ──
@@ -603,6 +617,20 @@ async function runImport() {
font-size: 14px; color: var(--df-text-dim); font-size: 14px; color: var(--df-text-dim);
} }
/* ===== 错误条(消费 store.error,对齐 Knowledge error-banner) ===== */
.error-banner {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: 8px 12px; margin-bottom: var(--df-gap-page);
background: rgba(255,107,107,0.12); border: 0.5px solid var(--df-danger);
border-radius: var(--df-radius-sm); color: var(--df-danger); font-size: 12px;
}
.error-text { word-break: break-word; }
.error-dismiss {
flex-shrink: 0; background: transparent; border: none; color: var(--df-danger);
font-size: 14px; cursor: pointer; line-height: 1; padding: 0 2px;
}
.error-dismiss:hover { opacity: 0.7; }
/* ===== 卡片删除按钮 + 回收站 ===== */ /* ===== 卡片删除按钮 + 回收站 ===== */
.card-top-actions { display: flex; align-items: center; gap: 8px; } .card-top-actions { display: flex; align-items: center; gap: 8px; }
.card-del-btn { .card-del-btn {

View File

@@ -100,7 +100,7 @@
</select> </select>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn-cancel" @click="showCreateModal = false">{{ $t('common.cancel') }}</button> <button class="btn-cancel" @click="showCreateModal = false">{{ $t('common.cancel') }}</button>
<button class="btn-confirm" @click="confirmCreate">{{ $t('common.confirm') }}</button> <button class="btn-confirm" @click="confirmCreate" :disabled="submitting || !newTaskTitle.trim()">{{ $t('common.confirm') }}</button>
</div> </div>
</div> </div>
</div> </div>
@@ -133,6 +133,9 @@ const newTaskDesc = ref('')
const newTaskBranch = ref('') const newTaskBranch = ref('')
const newTaskPriority = ref(2) // 默认 medium const newTaskPriority = ref(2) // 默认 medium
// 异步操作禁用态(IPC 期间防双击重复提交):confirmCreate 创建任务期间禁用确认按钮
const submitting = ref(false)
interface TaskGroup { interface TaskGroup {
projectName: string projectName: string
icon: string icon: string
@@ -217,6 +220,8 @@ function openCreateModal() {
async function confirmCreate() { async function confirmCreate() {
if (!newTaskTitle.value.trim() || !newTaskProjectId.value) return if (!newTaskTitle.value.trim() || !newTaskProjectId.value) return
submitting.value = true
try {
const r = await store.createTask({ const r = await store.createTask({
project_id: newTaskProjectId.value, project_id: newTaskProjectId.value,
title: newTaskTitle.value.trim(), title: newTaskTitle.value.trim(),
@@ -226,6 +231,9 @@ async function confirmCreate() {
}) })
if (!r) return // 失败已 toast,保持弹窗不关 if (!r) return // 失败已 toast,保持弹窗不关
showCreateModal.value = false showCreateModal.value = false
} finally {
submitting.value = false
}
} }
// B-260615-29: 切换项目筛选时按 projectId 重载(避免仅前端 filter,跨项目视图不同步) // B-260615-29: 切换项目筛选时按 projectId 重载(避免仅前端 filter,跨项目视图不同步)