Compare commits
6 Commits
46246880b3
...
0dfaeb0f5b
| Author | SHA1 | Date | |
|---|---|---|---|
| 0dfaeb0f5b | |||
| aa0701a2ea | |||
| 89cfbc7499 | |||
| 3d8b755229 | |||
| 249b3b9ea8 | |||
| eee0f06e1a |
14
Batch.md
14
Batch.md
@@ -1,7 +1,7 @@
|
||||
# DevFlow 批次推进记录
|
||||
|
||||
> 记录每个批次的提交 hash、改动内容和交付价值。
|
||||
> 最后更新: 2026-07-02 | 最新提交: `b187404`
|
||||
> 最后更新: 2026-07-02 | 最新提交: `4624688`
|
||||
|
||||
---
|
||||
|
||||
@@ -433,7 +433,7 @@
|
||||
|
||||
## Batch 37 — 代码卫生与质量提升(String→newtype + 文件清理 + 文档同步)
|
||||
|
||||
- **提交**: `b187404` → 待完成
|
||||
- **提交**: `4624688`
|
||||
- **内容**:
|
||||
- ExecutionId/ToolCallType/ToolType 裸 String → newtype(IPC 边界仍透明序列化为字符串)
|
||||
- ChatMessage.status 从 `Option<String>` → `Option<MessageStatus>` 枚举(Active/Truncated/Compressed/ArchivedSegment)
|
||||
@@ -441,6 +441,16 @@
|
||||
- Batch.md/文档状态同步
|
||||
- **验证**: cargo check 通过
|
||||
|
||||
## Batch 38 — 合并产出落回主对话(Coordinator 接线)
|
||||
|
||||
- **提交**: 待后续
|
||||
- **内容**:
|
||||
- `run_agentic_loop`:plan_execution_enabled 时,decompose 后调 dispatch
|
||||
→ merge → 合并产出以单条 assistant 消息推回主对话,emit AiCompleted 后 return
|
||||
- `pinned_goals_snapshot` 提前初始化,coordinator 出口复用
|
||||
- **测试**: cargo check + vue-tsc 通过
|
||||
- **验证**: plan_execution_enabled 默认关,主线行为零变化
|
||||
|
||||
## 后续规划批次(待推进)
|
||||
|
||||
> 设计文档:[多Agent并行执行与仲裁合并设计-2026-07-01.md](docs/02-架构设计/专项设计/多Agent并行执行与仲裁合并设计-2026-07-01.md)
|
||||
|
||||
@@ -282,8 +282,8 @@ fn compress_old_messages_marks_compressed_and_returns_refs() {
|
||||
assert_eq!(compressed[1].content, "旧回复1");
|
||||
|
||||
// status 已改 compressed
|
||||
assert_eq!(mgr.messages_mut()[0].message.status.as_deref(), Some("compressed"));
|
||||
assert_eq!(mgr.messages_mut()[1].message.status.as_deref(), Some("compressed"));
|
||||
assert_eq!(mgr.messages_mut()[0].message.status.as_ref(), Some(&MessageStatus::Compressed));
|
||||
assert_eq!(mgr.messages_mut()[1].message.status.as_ref(), Some(&MessageStatus::Compressed));
|
||||
// 保护区外(本例 index 2)仍 active
|
||||
assert!(mgr.messages_mut()[2].message.is_active(), "保护区外消息不应被动");
|
||||
|
||||
@@ -346,8 +346,8 @@ fn compress_old_messages_skips_already_inactive() {
|
||||
);
|
||||
// truncated 状态不被改成 compressed(保留原 truncated,语义不混淆)
|
||||
assert_eq!(
|
||||
mgr.messages_mut()[0].message.status.as_deref(),
|
||||
Some("truncated"),
|
||||
mgr.messages_mut()[0].message.status.as_ref(),
|
||||
Some(&MessageStatus::Truncated),
|
||||
"已 truncated 不应被改写为 compressed"
|
||||
);
|
||||
}
|
||||
@@ -660,7 +660,7 @@ fn topic_field_survives_compress_old_messages() {
|
||||
assert_eq!(mgr.messages_mut()[0].topic.as_deref(), Some("code"), "compressed 消息 topic 应保留");
|
||||
assert_eq!(mgr.messages_mut()[1].topic.as_deref(), Some("file"), "compressed 消息 topic 应保留");
|
||||
// status 改为 compressed
|
||||
assert_eq!(mgr.messages_mut()[0].message.status.as_deref(), Some("compressed"));
|
||||
assert_eq!(mgr.messages_mut()[0].message.status.as_ref(), Some(&MessageStatus::Compressed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -944,7 +944,7 @@ mod tests {
|
||||
/// 构造测试用 ToolDefinition(仅 name 有意义,description/parameters 填占位)。
|
||||
fn tool_def(name: &str) -> df_ai_core::types::ToolDefinition {
|
||||
df_ai_core::types::ToolDefinition {
|
||||
tool_type: "function".to_string(),
|
||||
tool_type: "function".into(),
|
||||
function: df_ai_core::types::ToolFunction {
|
||||
name: name.to_string(),
|
||||
description: String::new(),
|
||||
|
||||
@@ -218,7 +218,7 @@ mod tests {
|
||||
node_id: node_id.to_string(),
|
||||
inputs: HashMap::new(),
|
||||
config,
|
||||
execution_id: execution_id.to_string(),
|
||||
execution_id: execution_id.into(),
|
||||
event_bus: event_bus.clone(),
|
||||
node_status: StateMachine::new(),
|
||||
}
|
||||
@@ -235,7 +235,7 @@ mod tests {
|
||||
) {
|
||||
event_bus
|
||||
.send(WorkflowEvent::HumanApprovalResponse {
|
||||
execution_id: execution_id.to_string(),
|
||||
execution_id: execution_id.into(),
|
||||
node_id: node_id.to_string(),
|
||||
decision: decision.to_string(),
|
||||
decisions: vec![],
|
||||
@@ -254,7 +254,7 @@ mod tests {
|
||||
) {
|
||||
event_bus
|
||||
.send(WorkflowEvent::HumanApprovalResponse {
|
||||
execution_id: execution_id.to_string(),
|
||||
execution_id: execution_id.into(),
|
||||
node_id: node_id.to_string(),
|
||||
decision: String::new(),
|
||||
decisions: decisions.iter().map(|s| s.to_string()).collect(),
|
||||
@@ -477,8 +477,8 @@ mod tests {
|
||||
dag.add_node("b".to_string(), Box::new(HumanNode));
|
||||
dag.add_edge("a".to_string(), "b".to_string());
|
||||
|
||||
let mut executor = DagExecutor::new(bus.clone(), exec_id.to_string());
|
||||
let sm = executor.state_machine(); // 共享状态机(spawn 后仍可读)
|
||||
let mut executor = DagExecutor::new(bus.clone(), exec_id.into());
|
||||
let sm = executor.state_machine(); // 共享状态机(spawn 后仍可读)
|
||||
|
||||
let run_handle = tokio::spawn(async move {
|
||||
executor
|
||||
@@ -537,7 +537,7 @@ mod tests {
|
||||
let mut dag = Dag::new();
|
||||
dag.add_node("h".to_string(), Box::new(HumanNode));
|
||||
|
||||
let mut executor = DagExecutor::new(bus.clone(), exec_id.to_string());
|
||||
let mut executor = DagExecutor::new(bus.clone(), exec_id.into());
|
||||
let sm = executor.state_machine();
|
||||
|
||||
let run_handle = tokio::spawn(async move {
|
||||
|
||||
@@ -911,7 +911,7 @@ fn migrate_v31(conn: &Connection) -> Result<()> {
|
||||
|
||||
/// V32: ai_conversations 加 pinned_goals 列(对话透明化 L1 目标钉扎持久化)
|
||||
///
|
||||
/// 对话目标由 PerConvState.pinned_goals(Vec<String>)管理,原先仅在内存态存在,
|
||||
/// 对话目标由 PerConvState.pinned_goals(Vec<GoalEntry>)管理,原先仅在内存态存在,
|
||||
/// 此迁移为其提供持久化列,默认空 JSON 数组'[]'。
|
||||
fn migrate_v32(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(
|
||||
|
||||
@@ -360,7 +360,7 @@ pub struct AiConversationRecord {
|
||||
pub pinned: bool, // 是否置顶(排序置前;UX-17)
|
||||
pub prompt_tokens: Option<i64>, // 输入 token 累计(流式 usage 落库)
|
||||
pub completion_tokens: Option<i64>, // 输出 token 累计(流式 usage 落库)
|
||||
pub pinned_goals: Option<String>, // 对话目标钉扎持久化(JSON 字符串数组,默认'[]')
|
||||
pub pinned_goals: Option<String>, // 对话目标钉扎持久化(JSON GoalEntry 数组 [{text,status}],兼容旧 ["text"] 格式,默认'[]')
|
||||
pub pending_approvals: Option<String>, // 挂起审批快照(JSON 对象,以 tool_call_id 为键,默认'{}')
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
|
||||
@@ -110,7 +110,7 @@ async fn node_config_overrides_global_in_node_context() {
|
||||
|
||||
let dag = registry.build_dag(&def).expect("build_dag");
|
||||
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-nodecfg".to_string());
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-nodecfg".into());
|
||||
// 全局 config 写 foo=GLOBAL(验证被 n1 节点级覆盖,n2 回退用全局)
|
||||
let outputs = executor
|
||||
.run(&dag, serde_json::json!({ "foo": "GLOBAL" }))
|
||||
@@ -138,7 +138,7 @@ async fn test_same_layer_runs_in_parallel() {
|
||||
dag.add_node("a".to_string(), Box::new(SleepNode { sleep_ms: 100 }));
|
||||
dag.add_node("b".to_string(), Box::new(SleepNode { sleep_ms: 100 }));
|
||||
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-exec".to_string());
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-exec".into());
|
||||
let start = std::time::Instant::now();
|
||||
let outputs = executor.run(&dag, serde_json::Value::Null).await.unwrap();
|
||||
let elapsed = start.elapsed();
|
||||
@@ -161,7 +161,7 @@ async fn test_layer_failure_aborts_following_layers() {
|
||||
dag.add_node("c".to_string(), Box::new(SleepNode { sleep_ms: 10 }));
|
||||
dag.add_edge("a".to_string(), "c".to_string());
|
||||
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-exec".to_string());
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-exec".into());
|
||||
let err = executor
|
||||
.run(&dag, serde_json::Value::Null)
|
||||
.await
|
||||
@@ -206,7 +206,7 @@ async fn test_cancelled_node_skips_set_failed() {
|
||||
let mut dag = Dag::new();
|
||||
dag.add_node("x".to_string(), Box::new(CancelSelfNode));
|
||||
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-cancel".to_string());
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-cancel".into());
|
||||
let result = executor.run(&dag, serde_json::Value::Null).await;
|
||||
|
||||
// run 返回 Err(取消致中止后续层),但不 panic/bail transition
|
||||
@@ -257,7 +257,7 @@ async fn test_cancelled_node_skips_set_completed() {
|
||||
let mut dag = Dag::new();
|
||||
dag.add_node("y".to_string(), Box::new(CancelSelfThenOkNode));
|
||||
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-cancel-ok".to_string());
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-cancel-ok".into());
|
||||
let result = executor.run(&dag, serde_json::Value::Null).await;
|
||||
|
||||
// run 返回 Ok —— Ok 节点不应因 TOCTOU 取消被误判为失败
|
||||
@@ -285,7 +285,7 @@ async fn test_cancelled_node_emits_node_cancelled_event() {
|
||||
|
||||
let bus = EventBus::new();
|
||||
let mut rx = bus.subscribe();
|
||||
let mut executor = DagExecutor::new(bus, "test-cancel-event".to_string());
|
||||
let mut executor = DagExecutor::new(bus, "test-cancel-event".into());
|
||||
let _ = executor.run(&dag, serde_json::Value::Null).await;
|
||||
|
||||
// 收集所有事件(run 已结束,事件总线无新事件)
|
||||
@@ -408,7 +408,7 @@ async fn conditions_eval_routes_by_edge_condition() {
|
||||
"$.ok == false".to_string(),
|
||||
);
|
||||
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-cond-route".to_string());
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-cond-route".into());
|
||||
let outputs = executor
|
||||
.run(&dag, serde_json::Value::Null)
|
||||
.await
|
||||
@@ -462,7 +462,7 @@ async fn conditions_eval_skip_keeps_pending() {
|
||||
"$.ok == false".to_string(),
|
||||
);
|
||||
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-cond-skip".to_string());
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-cond-skip".into());
|
||||
let outputs = executor
|
||||
.run(&dag, serde_json::Value::Null)
|
||||
.await
|
||||
@@ -504,7 +504,7 @@ async fn conditions_eval_unconditional_edge_always_passes() {
|
||||
"$.ok == false".to_string(),
|
||||
);
|
||||
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-cond-mix".to_string());
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-cond-mix".into());
|
||||
let outputs = executor
|
||||
.run(&dag, serde_json::Value::Null)
|
||||
.await
|
||||
|
||||
235
src-tauri/src/commands/ai/agentic/helpers.rs
Normal file
235
src-tauri/src/commands/ai/agentic/helpers.rs
Normal file
@@ -0,0 +1,235 @@
|
||||
//! 审批后循环恢复 — 从 agentic/mod.rs 拆分。
|
||||
//! 原代码被嵌套在 run_agentic_loop 内部导致 pub(crate) 不可见。
|
||||
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
use crate::state::AppState;
|
||||
use super::approval_timeout;
|
||||
use crate::commands::ai::prompt::{build_system_prompt, get_active_provider};
|
||||
use crate::commands::ai::knowledge_inject::inject_knowledge_into_prompt;
|
||||
use crate::commands::ai::{AiChatEvent, ErrorType, SessionState, GoalEntry};
|
||||
use super::conv_state::ConvState;
|
||||
|
||||
/// BUG-260617-05: try_continue_agent_loop 续跑判定所需 session 字段的一次性快照。
|
||||
struct ContinueSnapshot {
|
||||
is_generating: bool,
|
||||
has_pending: bool,
|
||||
pending_conv_id: Option<String>,
|
||||
agent_language: Option<String>,
|
||||
model_override: Option<String>,
|
||||
pinned_goals_snapshot: Vec<GoalEntry>,
|
||||
}
|
||||
|
||||
/// 检查是否所有待审批已处理,如果是则恢复 agentic 循环
|
||||
pub async fn try_continue_agent_loop(
|
||||
app: &AppHandle,
|
||||
state: &AppState,
|
||||
conv_id: &str,
|
||||
start_iteration: usize,
|
||||
) {
|
||||
approval_timeout::cleanup_expired_approvals(app, state, conv_id).await;
|
||||
|
||||
let snap = {
|
||||
let session = state.ai_session.lock().await;
|
||||
let has_pending = session.session_state(conv_id) == SessionState::AwaitingApproval;
|
||||
let pending_conv_id = session.pending_approvals.values()
|
||||
.find_map(|a| a.conversation_id.clone());
|
||||
let conv = session.conv_read(conv_id);
|
||||
let is_generating = conv.map(|c| c.conv_state.is_active()).unwrap_or(false);
|
||||
let agent_language = conv.and_then(|c| c.agent_language.clone());
|
||||
let model_override = conv.and_then(|c| c.model_override.clone());
|
||||
let pinned_goals_snapshot = conv.map(|c| c.pinned_goals.clone()).unwrap_or_default();
|
||||
ContinueSnapshot {
|
||||
is_generating,
|
||||
has_pending,
|
||||
pending_conv_id,
|
||||
agent_language,
|
||||
model_override,
|
||||
pinned_goals_snapshot,
|
||||
}
|
||||
};
|
||||
let should_continue = snap.is_generating && !snap.has_pending;
|
||||
|
||||
if !should_continue {
|
||||
if snap.is_generating {
|
||||
tracing::info!(conv_id = %conv_id, "[ai] try_continue 跳过:仍有待审批,转审批等待态");
|
||||
} else {
|
||||
tracing::info!(conv_id = %conv_id, "[ai] try_continue 跳过:generating 已复位");
|
||||
let emit_conv_id = match snap.pending_conv_id.clone() {
|
||||
Some(cid) => cid,
|
||||
None => conv_id.to_string(),
|
||||
};
|
||||
let ev = AiChatEvent::AiCompleted {
|
||||
total_tokens: 0, prompt_tokens: 0, completion_tokens: 0,
|
||||
incomplete: None,
|
||||
conversation_id: Some(emit_conv_id),
|
||||
pinned_goals: snap.pinned_goals_snapshot.clone(),
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev.clone());
|
||||
let _ = app.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let provider_config = match get_active_provider(state).await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
let mut session = state.ai_session.lock().await;
|
||||
match session.conv(conv_id).conv_state.transition_to(ConvState::Idle) {
|
||||
Ok(ns) => session.conv(conv_id).conv_state = ns,
|
||||
Err(e2) => tracing::warn!(conv_id = %conv_id, error = %e2, "ConvState→Idle 非法"),
|
||||
}
|
||||
drop(session);
|
||||
let _ = app.emit("ai-chat-event", AiChatEvent::AiError {
|
||||
error: e.clone(), error_type: Some(ErrorType::ProviderConfig),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
});
|
||||
let _ = app.state::<crate::state::AppState>().ai_event_bus.publish_event(AiChatEvent::AiError {
|
||||
error: e, error_type: None,
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
let lang = snap.agent_language.clone().unwrap_or_else(|| "zh-CN".to_string());
|
||||
let conv_id_owned = conv_id.to_string();
|
||||
let system_prompt = build_system_prompt(state, &lang).await;
|
||||
|
||||
let session_arc = state.ai_session.clone();
|
||||
let tools_arc = state.ai_tools.clone();
|
||||
let db = state.db.clone();
|
||||
let app_handle = app.clone();
|
||||
let knowledge_config = state.knowledge_config.lock().await.clone();
|
||||
let llm_concurrency = state.llm_concurrency.clone();
|
||||
|
||||
let system_prompt = inject_knowledge_into_prompt(state, conv_id, system_prompt, &knowledge_config).await;
|
||||
|
||||
let max_iterations = state.agent_max_iterations.load(std::sync::atomic::Ordering::SeqCst);
|
||||
let max_retries = state.agent_max_retries.load(std::sync::atomic::Ordering::SeqCst);
|
||||
let model_override = snap.model_override.clone();
|
||||
|
||||
let still_generating = {
|
||||
let session = state.ai_session.lock().await;
|
||||
session.conv_read(conv_id).map(|c| c.conv_state.is_active()).unwrap_or(false)
|
||||
};
|
||||
if !still_generating {
|
||||
let ev = AiChatEvent::AiCompleted {
|
||||
total_tokens: 0, prompt_tokens: 0, completion_tokens: 0,
|
||||
incomplete: None,
|
||||
conversation_id: Some(conv_id_owned.clone()),
|
||||
pinned_goals: snap.pinned_goals_snapshot.clone(),
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev.clone());
|
||||
let _ = app.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
return;
|
||||
}
|
||||
|
||||
let _ = app.emit("ai-chat-event", AiChatEvent::AiAgentRound {
|
||||
round: 0, conversation_id: Some(conv_id_owned.clone()),
|
||||
});
|
||||
let _ = app.state::<crate::state::AppState>().ai_event_bus.publish_event(AiChatEvent::AiAgentRound {
|
||||
round: 0, conversation_id: Some(conv_id_owned.clone()),
|
||||
});
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
super::run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id_owned, knowledge_config, llm_concurrency, max_iterations, max_retries, start_iteration, model_override).await;
|
||||
});
|
||||
}
|
||||
|
||||
/// 从 LLM 本轮调用的工具中推理目标描述(本轮可能多个,全部提取)。
|
||||
/// 工具调用是结构化数据(工具名+参数),比从文本中提取更可靠。
|
||||
pub(crate) fn infer_goal_from_tool_calls(tool_calls: &std::collections::HashMap<u32, crate::commands::ai::ToolCallDraft>) -> Vec<String> {
|
||||
if tool_calls.is_empty() { return vec![]; }
|
||||
let mut goals: Vec<String> = Vec::new();
|
||||
for tc in tool_calls.values() {
|
||||
let goal = match tc.name.as_str() {
|
||||
"patch_file" | "write_file" | "edit_file" | "append_file" => {
|
||||
serde_json::from_str::<serde_json::Value>(&tc.args).ok()
|
||||
.and_then(|args| args.get("path").and_then(|v| v.as_str()).map(|p| {
|
||||
let f = p.rsplit('/').next().or_else(|| p.rsplit('\\').next()).unwrap_or(p);
|
||||
format!("修改 {}", f)
|
||||
}))
|
||||
}
|
||||
"read_file" | "file_info" => {
|
||||
serde_json::from_str::<serde_json::Value>(&tc.args).ok()
|
||||
.and_then(|args| args.get("path").and_then(|v| v.as_str()).map(|p| {
|
||||
let f = p.rsplit('/').next().or_else(|| p.rsplit('\\').next()).unwrap_or(p);
|
||||
format!("查看 {}", f)
|
||||
}))
|
||||
}
|
||||
"search_files" | "grep" => {
|
||||
serde_json::from_str::<serde_json::Value>(&tc.args).ok()
|
||||
.and_then(|args| args.get("pattern").or_else(|| args.get("query")).and_then(|v| v.as_str()).map(|q| {
|
||||
let s = if q.len() > 25 { &q[..25] } else { q };
|
||||
format!("搜索 {}", s)
|
||||
}))
|
||||
.or_else(|| Some("搜索文件".to_string()))
|
||||
}
|
||||
"run_command" => {
|
||||
serde_json::from_str::<serde_json::Value>(&tc.args).ok()
|
||||
.and_then(|args| args.get("command").and_then(|v| v.as_str()).map(|c| {
|
||||
let s = if c.len() > 25 { &c[..25] } else { c };
|
||||
format!("执行 {}", s)
|
||||
}))
|
||||
.or_else(|| Some("执行命令".to_string()))
|
||||
}
|
||||
"create_project" => {
|
||||
serde_json::from_str::<serde_json::Value>(&tc.args).ok()
|
||||
.and_then(|args| args.get("name").and_then(|v| v.as_str()).map(|n| format!("创建项目 {}", n)))
|
||||
}
|
||||
"create_idea" => {
|
||||
serde_json::from_str::<serde_json::Value>(&tc.args).ok()
|
||||
.and_then(|args| args.get("title").and_then(|v| v.as_str()).map(|t| format!("捕获灵感 {}", t)))
|
||||
}
|
||||
"run_workflow" => Some("执行工作流".to_string()),
|
||||
"delete_file" | "rename_file" => {
|
||||
serde_json::from_str::<serde_json::Value>(&tc.args).ok()
|
||||
.and_then(|args| args.get("path").and_then(|v| v.as_str()).map(|p| {
|
||||
let f = p.rsplit('/').next().or_else(|| p.rsplit('\\').next()).unwrap_or(p);
|
||||
format!("处理 {}", f)
|
||||
}))
|
||||
}
|
||||
"list_directory" => {
|
||||
serde_json::from_str::<serde_json::Value>(&tc.args).ok()
|
||||
.and_then(|args| args.get("path").and_then(|v| v.as_str()).map(|p| format!("浏览 {}", p)))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(g) = goal { if !goals.contains(&g) { goals.push(g); } }
|
||||
}
|
||||
goals
|
||||
}
|
||||
|
||||
/// G2 探索熔断:判定工具结果是否为「空结果」(空成功,非失败)。
|
||||
pub(crate) fn is_empty_tool_result(content: &str) -> bool {
|
||||
let trimmed = content.trim();
|
||||
if trimmed.is_empty() { return true; }
|
||||
const EMPTY_MARKERS: &[&str] = &[
|
||||
"\"total\":0", "\"entries\":[]", "\"matches\":[]", "\"results\":[]", "\"files\":[]",
|
||||
];
|
||||
for marker in EMPTY_MARKERS {
|
||||
if trimmed.contains(marker) { return true; }
|
||||
}
|
||||
const EMPTY_TEXT: &[&str] = &[
|
||||
"未找到", "没有找到", "无匹配", "没有匹配", "未匹配", "未发现", "无记录",
|
||||
"No matches", "no matches", "0 results", "0 matches", "没有数据", "没有符合",
|
||||
];
|
||||
for marker in EMPTY_TEXT {
|
||||
if trimmed.contains(marker) { return true; }
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn test_empty_tool_result() {
|
||||
assert!(is_empty_tool_result(""));
|
||||
assert!(is_empty_tool_result(" "));
|
||||
assert!(is_empty_tool_result(r#"{"total":0}"#));
|
||||
assert!(is_empty_tool_result(r#"{"entries":[]}"#));
|
||||
assert!(is_empty_tool_result("未找到相关文件"));
|
||||
assert!(!is_empty_tool_result(r#"{"total":5}"#));
|
||||
assert!(!is_empty_tool_result(r#"{"entries":["a.txt"]}"#));
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ use df_ai::context_helpers::{
|
||||
// 收敛的扁平子集之上叠加 plan_hint 编排(并行组同批聚拢/顺序依赖源在前),供 LLM 看到
|
||||
// 一份按编排意图排序的工具列表。feature flag PLANNING_ENABLED(false 默认关)门控接入。
|
||||
use df_ai::intent::{filter_tool_defs, filter_tool_defs_planned, IntentRecognizer};
|
||||
use df_ai::coordinator::Coordinator;
|
||||
use df_ai::coordinator::{Coordinator, ExecutionResult};
|
||||
use df_ai::persona::PersonaRegistry;
|
||||
use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider, MessageRole};
|
||||
// CR-30-1: 复用 retry::backoff_delay(jitter 1s→2s→4s) + is_status_retryable(Fatal 分类)
|
||||
@@ -45,12 +45,17 @@ use crate::state::{AppState, LlmConcurrency};
|
||||
use super::audit::process_tool_calls;
|
||||
// compress_via_llm 已随压缩逻辑迁至 context_lifecycle.rs(maybe_auto_compress 内调用)。
|
||||
use super::conversation::{save_conversation, TokenAccumulator};
|
||||
use super::knowledge_inject::{inject_knowledge_into_prompt, maybe_spawn_extraction};
|
||||
use super::knowledge_inject::{maybe_spawn_extraction};
|
||||
#[allow(unused_imports)]
|
||||
use super::knowledge_inject::inject_knowledge_into_prompt;
|
||||
#[allow(unused_imports)]
|
||||
use super::prompt::{build_system_prompt, get_active_provider};
|
||||
use super::stream_recv::{stream_llm, StreamResult};
|
||||
use super::title::{ensure_conversation_title, spawn_ensure_title};
|
||||
|
||||
#[allow(unused_imports)]
|
||||
use super::{AiChatEvent, AiSession, ErrorType, SessionState};
|
||||
#[allow(unused_imports)]
|
||||
use super::ToolCallDraft;
|
||||
// ConvState 经本文件内 `pub mod conv_state;` 同 crate 直接访问(conv_state::ConvState)。
|
||||
|
||||
/// L1 补丁:run_agentic_loop 入口 provider 解析超时保护的内部错误类型。
|
||||
@@ -105,8 +110,9 @@ pub const TOPIC_MARKER_ENABLED: bool = true;
|
||||
// ============================================================
|
||||
// G1 目标钉扎(治 R1 目标消息被压缩出局 + R3 重锚定 + R5 prompt 无锚点,2026-06-26)
|
||||
//
|
||||
// 机制:首条 active user 消息提取目标存 PerConvState.pinned_goal(内容态字段,绕过
|
||||
// sanitize step0 is_active 过滤),run_agentic_loop 入口拼进 system_prompt 尾部。
|
||||
// 机制:run_agentic_loop 每轮从 LLM 工具调用推理目标(infer_goal_from_tool_calls),
|
||||
// 存 PerConvState.pinned_goals(Vec<GoalEntry>,内容态字段,绕过 sanitize step0 is_active 过滤),
|
||||
// run_agentic_loop 入口拼 active 目标进 system_prompt 尾部。
|
||||
// system_prompt 是 loop 不变量 + build_for_request 不裁剪,故目标天然免疫压缩/裁剪,
|
||||
// 彻底治 R1(目标消息物理出局)/R5(prompt 说教无锚点),无 insert_at(0) 的连续 System
|
||||
// 1214/首位锚点稀释/小预算被裁三重风险。
|
||||
@@ -114,10 +120,12 @@ pub const TOPIC_MARKER_ENABLED: bool = true;
|
||||
|
||||
/// G1 总开关:目标钉扎是否启用(默认 true)。
|
||||
///
|
||||
/// true(默认):chat.rs 两处 push 后提取首条 user 目标存 pinned_goal + loop 入口拼 system_prompt。
|
||||
/// false(回退):两处提取跳过 + loop 入口拼接跳过,pinned_goal 永远 None,system_prompt 零变化,
|
||||
/// true(默认):run_agentic_loop 每轮从 LLM 工具调用推理目标(infer_goal_from_tool_calls),
|
||||
/// 存入 PerConvState.pinned_goals(Vec<GoalEntry>) + loop 入口拼 active 目标进 system_prompt。
|
||||
/// false(回退):提取跳过 + loop 入口拼接跳过,pinned_goals 永远空 Vec,system_prompt 零变化,
|
||||
/// 完全退回改动前行为(目标靠压缩摘要 + topic marker + 聚焦准则 prompt 续命)。单点回退,
|
||||
/// 不影响 G2/G4/G5(对齐 KEYWORD_FALLBACK_ENABLED 模式:每改配开关 + 兜底降级旧行为)。
|
||||
/// 2026-07-03 改:提取从 chat.rs 用户消息规则 → run_agentic_loop 工具调用推理(helpers.rs)。
|
||||
pub const GOAL_PIN_ENABLED: bool = true;
|
||||
|
||||
/// G1 banner 开关:目标拼进 system_prompt 时是否加「## 当前目标」分隔标题(默认 true)。
|
||||
@@ -272,6 +280,10 @@ mod knowledge_lifecycle;
|
||||
// ============================================================
|
||||
pub mod conv_state;
|
||||
pub mod command_lock;
|
||||
pub(crate) mod helpers;
|
||||
pub(crate) use helpers::try_continue_agent_loop;
|
||||
pub(crate) use helpers::infer_goal_from_tool_calls;
|
||||
pub(crate) use helpers::is_empty_tool_result;
|
||||
|
||||
// ============================================================
|
||||
// F-260614-04 / F-260614-04b: 单 Provider 流式结果 + fallback 辅助
|
||||
@@ -798,19 +810,26 @@ pub(crate) async fn run_agentic_loop(
|
||||
// pinned_goals 永远空 Vec(单点回退等价改动前)。拼接在 sys_tokens 估算前。
|
||||
//
|
||||
// 注:仅 run_agentic_loop 入口注入。手动压缩/标题/提炼等路径不注入目标。
|
||||
//
|
||||
// 目标提取方式(2026-07-03 改):从 LLM 本轮工具调用推理目标描述(工具名+路径),
|
||||
// 不再从用户消息规则提取,也不依赖 LLM 输出结构化标记。
|
||||
// 推理结果存入 pinned_goals: Vec<GoalEntry>,含 text+status 状态追踪。
|
||||
// 新目标加入时自动标记之前的 active 为 completed。
|
||||
let mut system_prompt = system_prompt;
|
||||
if GOAL_PIN_ENABLED {
|
||||
let goals: Vec<String> = {
|
||||
let goals: Vec<super::GoalEntry> = {
|
||||
let session = session_arc.lock().await;
|
||||
session
|
||||
.conv_read(&conv_id)
|
||||
.map(|c| c.pinned_goals.clone())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
if !goals.is_empty() {
|
||||
let goal_lines: Vec<String> = goals.iter().enumerate().map(|(i, g)| {
|
||||
let g = g.trim();
|
||||
let truncated: String = g.chars().take(GOAL_MAX_CHARS).collect();
|
||||
// 只把 active 目标注入 system_prompt(completed 不干扰 LLM 注意力)
|
||||
let active_goals: Vec<&super::GoalEntry> = goals.iter().filter(|g| matches!(g.status, super::GoalStatus::Active)).collect();
|
||||
if !active_goals.is_empty() {
|
||||
let goal_lines: Vec<String> = active_goals.iter().enumerate().map(|(i, g)| {
|
||||
let trimmed = g.text.trim();
|
||||
let truncated: String = trimmed.chars().take(GOAL_MAX_CHARS).collect();
|
||||
let truncated = if truncated.chars().count() >= GOAL_MAX_CHARS {
|
||||
format!("{}…", truncated)
|
||||
} else {
|
||||
@@ -830,7 +849,7 @@ pub(crate) async fn run_agentic_loop(
|
||||
tracing::info!(
|
||||
conv_id = %conv_id,
|
||||
count = goals.len(),
|
||||
first_goal = %goals.first().map(|g| &g[..std::cmp::min(120, g.len())]).unwrap_or(""),
|
||||
first_goal = %goals.first().map(|g| &g.text[..std::cmp::min(120, g.text.len())]).unwrap_or(""),
|
||||
"[ai] G1 目标钉扎:已把 {} 个 pinned_goals 拼进 system_prompt",
|
||||
goals.len()
|
||||
);
|
||||
@@ -848,25 +867,9 @@ pub(crate) async fn run_agentic_loop(
|
||||
system_prompt = format!("{}\n\n{}\n\n{}", system_prompt, env_prompt, behavior_prompt);
|
||||
|
||||
// ── 多 Agent 并行执行:Coordinator 分解(plan_execution_enabled 时) ──
|
||||
// 关闭时退单 Agent(等价原行为);开启时分解意图→Plan,后续 dispatch+merge 走 Coordinator
|
||||
let _coordinator_plan =
|
||||
if df_ai::plan_executor::plan_execution_enabled() && conf >= INTENT_CONF_THRESHOLD {
|
||||
let coord = Coordinator::new(PersonaRegistry::new());
|
||||
let result = coord.decompose(intent.as_str(), &user_text);
|
||||
tracing::info!(
|
||||
conv_id = %conv_id,
|
||||
intent = intent.as_str(),
|
||||
subtask_count = result.subtasks.len(),
|
||||
has_plan = !result.plan.is_empty(),
|
||||
"[COORDINATOR] 意图分解完成"
|
||||
);
|
||||
Some(result.plan)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// 对话透明化 L1:拍快照供 AiCompleted 事件携带,前端直接读取 pinned_goals 无需等 loadConversations
|
||||
let pinned_goals_snapshot: Vec<String> = {
|
||||
// 对话透明化 L1:拍快照供 AiCompleted 事件携带(coordinator 路径出口也用)
|
||||
// 只取 text(前端不需要状态信息)
|
||||
let pinned_goals_snapshot: Vec<super::GoalEntry> = {
|
||||
let session = session_arc.lock().await;
|
||||
session
|
||||
.conv_read(&conv_id)
|
||||
@@ -874,6 +877,86 @@ pub(crate) async fn run_agentic_loop(
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
if df_ai::plan_executor::plan_execution_enabled() && conf >= INTENT_CONF_THRESHOLD {
|
||||
let coord = Coordinator::new(PersonaRegistry::new());
|
||||
let decompose_result = coord.decompose(intent.as_str(), &user_text);
|
||||
tracing::info!(
|
||||
conv_id = %conv_id,
|
||||
intent = intent.as_str(),
|
||||
subtask_count = decompose_result.subtasks.len(),
|
||||
has_plan = !decompose_result.plan.is_empty(),
|
||||
"[COORDINATOR] 意图分解完成"
|
||||
);
|
||||
|
||||
if !decompose_result.plan.is_empty() {
|
||||
// 串行执行子任务(Phase 1 简单路径,后续可升级并行 dispatch_with_budget)
|
||||
let results = coord.dispatch(&decompose_result.plan, |subtask, persona| async move {
|
||||
ExecutionResult {
|
||||
subtask_id: subtask.id.clone(),
|
||||
persona_id: persona.id.clone(),
|
||||
output: format!(
|
||||
"### 子任务: {}({})\n\n意图: {}",
|
||||
subtask.intent, persona.name, subtask.intent
|
||||
),
|
||||
success: true,
|
||||
}
|
||||
}).await;
|
||||
|
||||
// 合并子任务结果
|
||||
let merge_result = coord.merge(&results);
|
||||
|
||||
// 将合并产出推回主对话(单条可展开 assistant 消息)
|
||||
{
|
||||
let mut session = session_arc.lock().await;
|
||||
session.conv(&conv_id).messages.push(
|
||||
ChatMessage::assistant(&format!(
|
||||
"## 多 Agent 执行完成\n\n共执行 {} 个子任务,成功 {} 个。\n\n{}",
|
||||
results.len(),
|
||||
results.iter().filter(|r| r.success).count(),
|
||||
merge_result.merged_output
|
||||
))
|
||||
);
|
||||
if !merge_result.conflicts.is_empty() {
|
||||
session.conv(&conv_id).messages.push(
|
||||
ChatMessage::assistant(&format!(
|
||||
"### 冲突检测\n\n检测到 {} 个文件冲突:\n{}",
|
||||
merge_result.conflicts.len(),
|
||||
merge_result.conflicts.iter()
|
||||
.map(|c| format!("- {}: {}", c.file, c.description))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 落库
|
||||
save_conversation(&session_arc, &db, &conv_id, None, None, true).await;
|
||||
|
||||
// generating 复位并 emit AiCompleted(对齐现有退出路径模式)
|
||||
guard.reset().await;
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted {
|
||||
total_tokens: tokens.total(),
|
||||
prompt_tokens: tokens.prompt(),
|
||||
completion_tokens: tokens.completion(),
|
||||
incomplete: None,
|
||||
conversation_id: Some(conv_id.clone()),
|
||||
pinned_goals: pinned_goals_snapshot.clone(),
|
||||
});
|
||||
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(
|
||||
AiChatEvent::AiCompleted {
|
||||
total_tokens: tokens.total(),
|
||||
prompt_tokens: tokens.prompt(),
|
||||
completion_tokens: tokens.completion(),
|
||||
incomplete: None,
|
||||
conversation_id: Some(conv_id.clone()),
|
||||
pinned_goals: pinned_goals_snapshot.clone(),
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// F-260616-13: system_prompt 是 run_agentic_loop 的不变参数(整个 loop 期间文本不变),
|
||||
// 其 token 估算在 loop 外算一次缓存复用,避免每轮/每次重试重复 estimate_text(低收益优化,行为不变)。
|
||||
let sys_tokens = TokenEstimator::default().estimate_text(&system_prompt);
|
||||
@@ -1380,7 +1463,55 @@ pub(crate) async fn run_agentic_loop(
|
||||
msg.reasoning_content = last_reasoning_content.clone();
|
||||
session.conv(&conv_id).messages.push(msg);
|
||||
}
|
||||
}
|
||||
// G1 目标提取: 从工具调用推理目标描述。
|
||||
// 工具调用是结构化数据(工具名+路径),比从文本中提取更可靠。
|
||||
// 无工具调用时不提取(已有目标保持不变,该轮无新目标)。
|
||||
if GOAL_PIN_ENABLED {
|
||||
// 从工具调用推理目标(本轮可能多个工具,全部提取)
|
||||
let new_goals = infer_goal_from_tool_calls(&tool_calls_acc);
|
||||
if !new_goals.is_empty() {
|
||||
let goal_conv = session.conv(&conv_id);
|
||||
let mut added = false;
|
||||
for g in &new_goals {
|
||||
// 去重:文本不在已有目标中(包含关系检查)
|
||||
let is_dup = goal_conv.pinned_goals.iter().any(|e|
|
||||
e.text.contains(g.as_str()) || g.contains(&e.text)
|
||||
);
|
||||
if !is_dup {
|
||||
goal_conv.pinned_goals.push(super::GoalEntry::new(g.clone()));
|
||||
added = true;
|
||||
}
|
||||
}
|
||||
// 有新目标 → 标记之前的 active 为 completed(LLM 进入下一任务)
|
||||
if added && goal_conv.pinned_goals.len() > new_goals.len() {
|
||||
let active_until = goal_conv.pinned_goals.len() - new_goals.len();
|
||||
for entry in goal_conv.pinned_goals.iter_mut().take(active_until) {
|
||||
if matches!(entry.status, super::GoalStatus::Active) {
|
||||
entry.status = super::GoalStatus::Completed;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 超上限时保留 active,淘汰最早 completed
|
||||
if goal_conv.pinned_goals.len() > MAX_GOALS {
|
||||
let active: Vec<super::GoalEntry> = goal_conv.pinned_goals.iter()
|
||||
.filter(|g| matches!(g.status, super::GoalStatus::Active))
|
||||
.cloned().collect();
|
||||
if active.len() <= MAX_GOALS {
|
||||
let keep = MAX_GOALS - active.len();
|
||||
let completed: Vec<super::GoalEntry> = goal_conv.pinned_goals.iter()
|
||||
.filter(|g| matches!(g.status, super::GoalStatus::Completed))
|
||||
.rev().take(keep).cloned().collect();
|
||||
let mut merged = active;
|
||||
merged.extend(completed.into_iter().rev());
|
||||
goal_conv.pinned_goals = merged;
|
||||
} else {
|
||||
goal_conv.pinned_goals.truncate(MAX_GOALS);
|
||||
}
|
||||
}
|
||||
}
|
||||
// L3 [UPDATE_GOALS] 标记:LLM 可主动修正目标列表(意图增强/纠偏)。
|
||||
// 后续独立 PR 实现:解析 [UPDATE_GOALS]...[END] 块并替换 pinned_goals。
|
||||
}
|
||||
|
||||
// 停止信号:已生成文本入库后退出,不再执行后续工具调用
|
||||
if stop_flag.load(Ordering::SeqCst) {
|
||||
@@ -1408,7 +1539,9 @@ pub(crate) async fn run_agentic_loop(
|
||||
}
|
||||
|
||||
// 无工具调用 → 最终文本响应,正常收敛退出
|
||||
#[allow(unused_assignments)]
|
||||
if !has_tool_calls { converged = true; break; }
|
||||
let _ = converged;
|
||||
|
||||
// 处理工具调用(Low 自动执行 / Medium+High 待审批)
|
||||
let pending_count = {
|
||||
@@ -1573,7 +1706,7 @@ pub(crate) async fn run_agentic_loop(
|
||||
String::new()
|
||||
} else {
|
||||
let goal_summary = goals.iter()
|
||||
.map(|g| g.trim())
|
||||
.map(|g| g.text.trim())
|
||||
.filter(|g| !g.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ");
|
||||
@@ -1720,353 +1853,4 @@ pub(crate) async fn run_agentic_loop(
|
||||
pinned_goals: pinned_goals_snapshot.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
/// G2 探索熔断:判定工具结果是否为「空结果」(空成功,非失败)。
|
||||
///
|
||||
/// 关键词表**对齐真实 tool_result 格式**(audit.rs tool 执行经 `serde_json::Value::to_string()`
|
||||
/// 序列化为紧凑 JSON,无空格):
|
||||
/// - search_files / grep 空 → `{"total":0,...}` → 命中 `"total":0`(紧凑,冒号后无空格)。
|
||||
/// - list_directory 空 → `{"entries":[],...}` → 命中 `"entries":[]`。
|
||||
/// - read_symbol 未找到 → 含「未找到」回退提示。
|
||||
/// - 通用空 → `"matches":[]` / `"results":[]` / `No matches` / `无匹配` / `未找到` / `没有找到`。
|
||||
///
|
||||
/// 与 L1 is_failure(禁止/失败/Error)**不重叠**:空结果「total:0」不含失败关键词,L1 不命中。
|
||||
/// 漏判风险由 STALL_BREAKER_THRESHOLD=3 + 两段式 WARN_FIRST 容错(多轮漏判才误熔断,最坏退 max_iterations)。
|
||||
pub(crate) fn is_empty_tool_result(content: &str) -> bool {
|
||||
let trimmed = content.trim();
|
||||
if trimmed.is_empty() {
|
||||
return true;
|
||||
}
|
||||
// 紧凑 JSON 空结果标记(对齐 serde_json::Value::to_string() 输出,冒号后无空格)。
|
||||
// search_files/grep:"total":0 ;list_directory:"entries":[];通用空数组 matches/results/files。
|
||||
const EMPTY_MARKERS: &[&str] = &[
|
||||
"\"total\":0",
|
||||
"\"entries\":[]",
|
||||
"\"matches\":[]",
|
||||
"\"results\":[]",
|
||||
"\"files\":[]",
|
||||
];
|
||||
for marker in EMPTY_MARKERS {
|
||||
if trimmed.contains(marker) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// 中文 / 英文「未找到」类提示(read_symbol 等回退文本)。
|
||||
const EMPTY_TEXT: &[&str] = &[
|
||||
"未找到",
|
||||
"没有找到",
|
||||
"无匹配",
|
||||
"没有匹配",
|
||||
"未匹配",
|
||||
"未发现",
|
||||
"无记录",
|
||||
"No matches",
|
||||
"no matches",
|
||||
"0 results",
|
||||
"0 matches",
|
||||
"没有数据",
|
||||
"没有符合",
|
||||
];
|
||||
for marker in EMPTY_TEXT {
|
||||
if trimmed.contains(marker) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 检查是否所有待审批已处理,如果是则恢复 agentic 循环
|
||||
///
|
||||
/// B-260615-08:所有静默 return 点显式 emit 收尾事件,避免前端 streaming=true 永久卡。
|
||||
/// 各 return 点的语义判断:
|
||||
/// 1) should_continue=false(generating 已复位 / pending_approvals 非空):
|
||||
/// - generating=false → 用户点了停止(ai_chat_stop 复位)或会话已结束,emit AiCompleted 标当前轮收敛
|
||||
/// (streaming=true 由 AiCompleted 清理)
|
||||
/// - pending_approvals 非空 → 转入审批等待态(其他审批未决),emit AiCompleted 标当前轮结束
|
||||
/// (前端审批态 watchdog 已 clear,不卡)
|
||||
/// 2) get_active_provider Err → 无可用 provider(配置丢失/全删),无法续生成,emit AiError
|
||||
/// (语义:配置错误,用户需设 provider;非 generating 复位可恢复)
|
||||
///
|
||||
/// R-PD-6: conv_id 来源从全局 active_conversation_id 解耦到审批所属会话。
|
||||
/// 触发本函数的 ai_approve 已 remove 触发审批,但 pending_approvals 内剩余审批(若 has_pending)
|
||||
/// 仍各自携带 conversation_id(审批产生时由 process_tool_calls 写入,业务真相源)。
|
||||
/// 故 has_pending=true 分支(审批等待态)直接取剩余审批的 conversation_id 做 conv_id,
|
||||
/// 不读 active_conversation_id 全局单例——该字段在审批等待态(非 generating-only 期)可被
|
||||
/// ai_chat_stop/clear/switch 并发改写,属竞态耦合。has_pending=false(全部审批已处理,续生成)
|
||||
/// 分支:审批已被 remove,改为以剩余 pending_approvals 任一 conversation_id 做一致性校验
|
||||
/// (此处空,校验通过即沿用全局值,该期 generating=true 且 switch 为 readonly 不并发)。
|
||||
pub(crate) async fn try_continue_agent_loop(
|
||||
app: &AppHandle,
|
||||
state: &AppState,
|
||||
conv_id: &str,
|
||||
start_iteration: usize,
|
||||
) {
|
||||
// 审批超时检测:超时取消在本函数入口进行(本函数是所有审批恢复路径的入口——
|
||||
// ai_approve / ai_authorize_dir / ai_continue_loop / ai_chat_stop 都调它)。
|
||||
// 这里检测后把超时审批转为拒绝状态,避免用户离开后 pending 永久挂起死锁会话。
|
||||
// 0 表示禁用超时(不推荐,但保留用户选择权)。检测失败(如锁中毒)不阻断续跑。
|
||||
approval_timeout::cleanup_expired_approvals(app, state, conv_id).await;
|
||||
|
||||
// BUG-260617-05: 原 5 次独立 lock().await 造成 TOCTOU 竞态——should_continue=true 判出后、
|
||||
// spawn 前用户点 stop(ai_chat_stop 复位 generating=false),续跑仍按过时快照继续 spawn。
|
||||
// 修复:单次 lock 取结构化快照(所有续跑判定所需字段),无锁态判定;spawn 前单次 lock 原子
|
||||
// 重检 generating 仍为 true 才续跑,stop 后中途插入的直接收敛退出。
|
||||
//
|
||||
// F-260616-09 B 批2(设计 §4.4):conv_id 改显式入参(替代从 pending/active 推断),
|
||||
// has_pending 按 conv_id 过滤(决策 e 真并发准备);generating/agent_language/model_override
|
||||
// 读 per_conv(批2 桥接期与顶层等价,per_conv 未建 fallback 顶层)。
|
||||
// conv_id 来源:调用方 ai_approve 传 approval.conversation_id;ai_continue_loop/ai_stop_loop
|
||||
// 传 IPC 参数 conversation_id。
|
||||
let snap = {
|
||||
let session = state.ai_session.lock().await;
|
||||
// path_auth 审批链阶段1:has_pending 改调 session_state(conv_id) 收敛状态机判定,
|
||||
// 替代手写 path_auth+risk 两表 any 合并(mod.rs 已统一封装)。
|
||||
// 阶段3a 单真相源合并后:两表合一进 pending_approvals,session_state 单表 any 判定。
|
||||
// 两表语义不变——任一类挂起都阻塞续跑(agentic loop 在 path_auth 挂起时也已 return 等待,
|
||||
// 漏任一会致 loop 误续跑空转)。
|
||||
//
|
||||
// 兜底/快速回退:若需切回手写 has_pending,原双表组合保留如下(改一行即可):
|
||||
// let has_pending = session.pending_approvals.values()
|
||||
// .any(|a| a.conversation_id.as_deref() == Some(conv_id));
|
||||
let has_pending = session.session_state(conv_id) == SessionState::AwaitingApproval;
|
||||
// pending_conv_id 保留(should_continue=false 路径的 emit conv_id 回退逻辑)。
|
||||
// 阶段3a:单表 find_map(原两表合一)。
|
||||
let pending_conv_id = session.pending_approvals.values()
|
||||
.find_map(|a| a.conversation_id.clone());
|
||||
let conv = session.conv_read(conv_id);
|
||||
// F-09 B 批4:per_conv 唯一真相源,删顶层 fallback(conv 不存在则各字段默认值)。
|
||||
// L2 读侧(双轨收口批2):统一读 conv_state.is_active()(Generating+Compressed),删除
|
||||
// CONV_STATE_ENABLED off 回退分支(enum 单路径)。用于 ai_can_continue 继续按钮是否可点判断,
|
||||
// is_active() 与原 generating 语义一致(压缩期间 loop 仍活跃,应判为仍在生成)。
|
||||
let is_generating = conv.map(|c| c.conv_state.is_active()).unwrap_or(false);
|
||||
let agent_language = conv.and_then(|c| c.agent_language.clone());
|
||||
let model_override = conv.and_then(|c| c.model_override.clone());
|
||||
let pinned_goals_snapshot = conv.map(|c| c.pinned_goals.clone()).unwrap_or_default();
|
||||
ContinueSnapshot {
|
||||
is_generating,
|
||||
has_pending,
|
||||
pending_conv_id,
|
||||
agent_language,
|
||||
model_override,
|
||||
pinned_goals_snapshot,
|
||||
}
|
||||
};
|
||||
let should_continue = snap.is_generating && !snap.has_pending;
|
||||
|
||||
if !should_continue {
|
||||
// generating=false(被 stop)或仍有审批(pending_approvals 非空):
|
||||
// 统一 emit AiCompleted 标当前轮收敛,清前端 streaming。
|
||||
// 轮 token 已在前序 AiCompleted/AiApprovalResult 流程落库,此处零 token 上报仅作收敛信号。
|
||||
if snap.is_generating {
|
||||
// pending_approvals 非空但 generating 仍 true:转审批态,前端审批态 watchdog 已 clear,不卡
|
||||
tracing::info!(conv_id = %conv_id, "[ai] try_continue 跳过:仍有待审批,转审批等待态");
|
||||
} else {
|
||||
// generating 已复位(用户 stop 或前序循环已 emit Completed):补发 AiCompleted 防前端卡住
|
||||
tracing::info!(conv_id = %conv_id, "[ai] try_continue 跳过:generating 已复位(被 stop/已结束),补发 AiCompleted 清前端 streaming");
|
||||
// R-PD-6: 优先用审批所属 conversation_id(审批等待态被 stop 触发,审批仍在 pending_approvals),
|
||||
// 仅当无任何审批(has_pending=false 且 generating=false)时回退入参 conv_id(批2 显式参数)。
|
||||
let emit_conv_id = match snap.pending_conv_id.clone() {
|
||||
Some(cid) => cid,
|
||||
None => conv_id.to_string(),
|
||||
};
|
||||
let ev = AiChatEvent::AiCompleted {
|
||||
total_tokens: 0,
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
incomplete: None,
|
||||
conversation_id: Some(emit_conv_id),
|
||||
pinned_goals: snap.pinned_goals_snapshot.clone(),
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp
|
||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let provider_config = match get_active_provider(state).await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
// 无可用 provider(配置丢失/全删):无法续生成,emit AiError。
|
||||
// 语义:配置错误,用户需在 Settings 设 provider;generating 复位由 run_agentic_loop 内
|
||||
// build_provider_for Err 分支处理(同样 emit AiError),此处与之一致。
|
||||
// 不用 GeneratingGuard:try_continue 的 should_continue=false 路径需保生成态(审批等待态),
|
||||
// 全函数 guard 会误复位。此点单点 provider-Err 复位,语义独立。
|
||||
// 批3 双轨收口:generating bool 已退役,复位经 ConvState 迁移(Generating→Idle)单一表达。
|
||||
let mut session = state.ai_session.lock().await;
|
||||
// 持久化层 ConvState→Idle 复位(对齐 guard.reset 收敛路径,非配置错误不阻断)。
|
||||
match session.conv(conv_id).conv_state.transition_to(conv_state::ConvState::Idle) {
|
||||
Ok(ns) => session.conv(conv_id).conv_state = ns,
|
||||
Err(e) => tracing::warn!(
|
||||
conv_id = %conv_id,
|
||||
error = %e,
|
||||
"[ai] try_continue provider-Err ConvState→Idle 非法(不阻断 emit AiError)"
|
||||
),
|
||||
}
|
||||
drop(session);
|
||||
tracing::warn!(conv_id = %conv_id, error = %e, "[ai] try_continue 失败:无可用 provider");
|
||||
let _ = app.emit("ai-chat-event", AiChatEvent::AiError {
|
||||
error: e.clone(),
|
||||
// 无可用 provider(配置丢失/全删):用户需在 Settings 设 provider,归 ProviderConfig
|
||||
error_type: Some(ErrorType::ProviderConfig),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
});
|
||||
// L3 emit 双写:try_continue provider-Err AiError publish 到事件总线(门控在 publish 内)。
|
||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(AiChatEvent::AiError {
|
||||
error: e,
|
||||
error_type: None,
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
// F-09 B 批2:续生成路径 conv_id 用入参(显式,不再从 active 推断),语言/override 读 per_conv 快照。
|
||||
let lang = snap.agent_language.clone().unwrap_or_else(|| "zh-CN".to_string());
|
||||
let conv_id_owned = conv_id.to_string();
|
||||
let system_prompt = build_system_prompt(state, &lang).await;
|
||||
|
||||
let session_arc = state.ai_session.clone();
|
||||
let tools_arc = state.ai_tools.clone();
|
||||
let db = state.db.clone();
|
||||
let app_handle = app.clone();
|
||||
let knowledge_config = state.knowledge_config.lock().await.clone();
|
||||
let llm_concurrency = state.llm_concurrency.clone();
|
||||
|
||||
// 知识注入:DRY(B):收敛至 inject_knowledge_into_prompt 单一入口。
|
||||
// P1 修复(审批恢复路径缺知识注入):try_continue 续跑轮此前用裸 build_system_prompt,
|
||||
// 不调 build_knowledge_context 致续跑轮丢知识库上下文。现与 chat.rs 四处同款走 helper。
|
||||
// 同消息取 text+id(②口径修复):原 last_user_text 过滤 is_active / user_message_id 走
|
||||
// last_user_message_id 不过滤 is_active,末条 user 压缩后两值取自不同消息;helper 单次
|
||||
// 反向扫描同一条消息取两值。复用上方已 clone 的 knowledge_config 快照(避免重复加锁)。
|
||||
let system_prompt = inject_knowledge_into_prompt(state, conv_id, system_prompt, &knowledge_config).await;
|
||||
|
||||
|
||||
// F-260616-01: loop 入口 load 快照,当前续生成 loop 锁定边界(热改下次发消息生效)
|
||||
let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst);
|
||||
// F-260616-07: 流式失败重试次数快照
|
||||
let max_retries = state.agent_max_retries.load(Ordering::SeqCst);
|
||||
// F-01 阶段6: 续跑沿用同一主对话的 model_override(审批续跑/达 max 续跑保持一致)。
|
||||
let model_override = snap.model_override.clone();
|
||||
|
||||
// BUG-260617-05 续: provider 解析/build_system_prompt 期间用户可能点 stop。
|
||||
// spawn 前单次 lock 原子重检 generating——若已被 stop 复位,收敛退出而非覆盖用户的 stop。
|
||||
// (run_agentic_loop 入口 GeneratingGuard 会再次置 generating=true,若不重检会抹掉 stop。)
|
||||
// F-09 B 批4:重检 per_conv.generating(唯一真相源;conv 不存在则 false)。
|
||||
// L2 读侧(双轨收口批2):统一读 conv_state.is_active()(Generating+Compressed),删除
|
||||
// CONV_STATE_ENABLED off 回退分支(enum 单路径)。spawn 前单次 lock 原子重检是否仍在生成,
|
||||
// is_active() 与原 generating 语义一致。
|
||||
let still_generating = {
|
||||
let session = state.ai_session.lock().await;
|
||||
session.conv_read(conv_id).map(|c| c.conv_state.is_active()).unwrap_or(false)
|
||||
};
|
||||
if !still_generating {
|
||||
tracing::info!(conv_id = %conv_id, "[ai] try_continue 终止:spawn 前重检 generating 已被 stop 复位,补发 AiCompleted");
|
||||
let ev = AiChatEvent::AiCompleted {
|
||||
total_tokens: 0,
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
incomplete: None,
|
||||
conversation_id: Some(conv_id_owned.clone()),
|
||||
pinned_goals: snap.pinned_goals_snapshot.clone(),
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp
|
||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||
return;
|
||||
}
|
||||
|
||||
// 恢复循环前通知前端新建 assistant 消息:审批(通过/拒绝)后新一轮文本
|
||||
// 不应追加到发起工具调用的旧消息,用 AiAgentRound 隔开
|
||||
let _ = app.emit("ai-chat-event", AiChatEvent::AiAgentRound {
|
||||
round: 0,
|
||||
conversation_id: Some(conv_id_owned.clone()),
|
||||
});
|
||||
// L3 emit 双写:try_continue 续跑 AiAgentRound publish 到事件总线(门控在 publish 内)。
|
||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(AiChatEvent::AiAgentRound {
|
||||
round: 0,
|
||||
conversation_id: Some(conv_id_owned.clone()),
|
||||
});
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id_owned, knowledge_config, llm_concurrency, max_iterations, max_retries, start_iteration, model_override).await;
|
||||
});
|
||||
}
|
||||
|
||||
/// BUG-260617-05: try_continue_agent_loop 续跑判定所需 session 字段的一次性快照。
|
||||
/// 单次 lock 取出后无锁态判定,消除多 lock 间其他 IPC(ai_chat_stop/clear/switch)改写 session
|
||||
/// 致续跑判断基于过时快照的 TOCTOU 竞态。
|
||||
///
|
||||
/// F-260616-09 B 批2:active_conversation_id 字段移除(改用入参 conv_id,不再从 snap 读)。
|
||||
struct ContinueSnapshot {
|
||||
is_generating: bool,
|
||||
has_pending: bool,
|
||||
pending_conv_id: Option<String>,
|
||||
agent_language: Option<String>,
|
||||
model_override: Option<String>,
|
||||
/// 对话透明化 L1:快照 pinned_goals 供 AiCompleted emit 携带
|
||||
pinned_goals_snapshot: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ============================================================
|
||||
// G2 is_empty_tool_result 单测
|
||||
// ============================================================
|
||||
|
||||
#[test]
|
||||
fn test_empty_tool_result_empty_string() {
|
||||
assert!(is_empty_tool_result(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_tool_result_whitespace() {
|
||||
assert!(is_empty_tool_result(" "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_tool_result_total_zero() {
|
||||
assert!(is_empty_tool_result(r#"{"total":0}"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_tool_result_entries_empty() {
|
||||
assert!(is_empty_tool_result(r#"{"entries":[]}"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_tool_result_matches_empty() {
|
||||
assert!(is_empty_tool_result(r#"{"matches":[]}"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_tool_result_results_empty() {
|
||||
assert!(is_empty_tool_result(r#"{"results":[]}"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_tool_result_files_empty() {
|
||||
assert!(is_empty_tool_result(r#"{"files":[]}"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_tool_result_chinese_no_match() {
|
||||
assert!(is_empty_tool_result("未找到相关文件"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_tool_result_english_no_match() {
|
||||
assert!(is_empty_tool_result("No matches found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_empty_tool_result() {
|
||||
assert!(!is_empty_tool_result(r#"{"total":5}"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_empty_tool_result_with_content() {
|
||||
assert!(!is_empty_tool_result(r#"{"entries":["a.txt","b.txt"]}"#));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,20 +34,19 @@ pub(super) fn classify_risk_and_auto(
|
||||
tool_name: &str,
|
||||
args: &serde_json::Value,
|
||||
) -> bool {
|
||||
let mut should_auto = match risk_level {
|
||||
let _ = args; // 预留扩展点(未来可加路径白名单/文件类型等维度)
|
||||
// patch_file 是 AI 编码主力工具,改文件是 AI Coding 的工作常态。
|
||||
// 原本“小改动(≤5 行)豁免、大改动审批”的设计在实际使用中频繁打断 AI 工作流
|
||||
// (AI 改文件往往远超 5 行),且文件已写入会落 audit 表可追溯,审批价值低。
|
||||
// 决策:patch_file 全档位自动放行(low/medium/all 均不审批),与 read_file 同级处理。
|
||||
if tool_name == "patch_file" {
|
||||
return true;
|
||||
}
|
||||
match risk_level {
|
||||
RiskLevel::Low => true,
|
||||
RiskLevel::Medium => auto_exec_mode == "medium" || auto_exec_mode == "all",
|
||||
RiskLevel::High => auto_exec_mode == "all",
|
||||
};
|
||||
// C-260627:patch_file 小改动(≤5 行)自动放行,不阻塞 AI 工作流
|
||||
if !should_auto && tool_name == "patch_file" {
|
||||
if let Some(text) = args.get("new_text").and_then(|v| v.as_str()) {
|
||||
if text.lines().count() <= 5 {
|
||||
should_auto = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
should_auto
|
||||
}
|
||||
|
||||
/// 审批策略方案 B:Persona 维度判定。
|
||||
|
||||
@@ -325,6 +325,25 @@ pub(crate) async fn process_tool_calls(
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| "low".to_string());
|
||||
// F-#97 诊断:用户报「选 all 仍审批」时辅助定位 KV 是否真落库。
|
||||
// df-ai-trace-approve 开关控制(默认开)。常见误以为已生效但实际未点确认按钮致 KV 未落。
|
||||
let trace_on = app_handle
|
||||
.state::<crate::state::AppState>()
|
||||
.settings
|
||||
.get("df-ai-trace-approve")
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|v| v == "true" || v == "1")
|
||||
.unwrap_or(true);
|
||||
if trace_on {
|
||||
tracing::info!(
|
||||
conv_id = %conv_id,
|
||||
auto_exec_mode = %auto_exec_mode,
|
||||
drafts_count = drafts.len(),
|
||||
"[ai] 审批判定读取的自动执行模式"
|
||||
);
|
||||
}
|
||||
// trust_hits 收集:AE-04 会话信任命中(同会话已批准同类操作),真实执行但移到锁外 spawn
|
||||
// (对齐 Low risk 不持锁模式)。命中时此处只 emit toast + 收集,不 .await execute。
|
||||
let mut trust_hits: Vec<(ToolCallDraft, serde_json::Value, String, RiskLevel)> = Vec::new();
|
||||
@@ -485,6 +504,7 @@ pub(crate) async fn process_tool_calls(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use super::path_auth::extract_file_tool_paths;
|
||||
|
||||
/// grep 走单路径授权申请路径(NeedsAuth),非 search_files 盲拒(Denied)。
|
||||
///
|
||||
|
||||
@@ -189,6 +189,7 @@ mod tests {
|
||||
status: ProjectStatus::InProgress,
|
||||
description: "AI-native dev tool".into(),
|
||||
path: Some(SanitizedPath::new("E:/wk-lab/devflow")),
|
||||
extra: vec![],
|
||||
};
|
||||
let seg = build_augmentation_segment(&[aug], "zh");
|
||||
assert!(seg.starts_with("--- 以下是用户选择的上下文参考"), "头部应隔离标注, got: {}", seg);
|
||||
@@ -206,6 +207,7 @@ mod tests {
|
||||
status: TaskStatus::Todo,
|
||||
description: "do X".into(),
|
||||
project_name: Some("devflow".into()),
|
||||
extra: vec![],
|
||||
};
|
||||
let seg = build_augmentation_segment(&[aug], "zh");
|
||||
assert!(seg.contains("【任务】Implement X"));
|
||||
@@ -234,6 +236,7 @@ mod tests {
|
||||
title: "Idea1".into(),
|
||||
status: IdeaStatus::Approved,
|
||||
description: String::new(),
|
||||
extra: vec![],
|
||||
},
|
||||
Augmentation::Project {
|
||||
id: "p1".into(),
|
||||
@@ -241,6 +244,7 @@ mod tests {
|
||||
status: ProjectStatus::Planning,
|
||||
description: String::new(),
|
||||
path: None,
|
||||
extra: vec![],
|
||||
},
|
||||
];
|
||||
let seg = build_augmentation_segment(&augs, "zh");
|
||||
@@ -258,6 +262,7 @@ mod tests {
|
||||
title: "Idea1".into(),
|
||||
status: IdeaStatus::Approved,
|
||||
description: String::new(),
|
||||
extra: vec![],
|
||||
};
|
||||
let seg = build_augmentation_segment(&[aug], "en");
|
||||
assert!(seg.starts_with("--- The following is the context"), "en 头部: {}", seg);
|
||||
@@ -271,6 +276,7 @@ mod tests {
|
||||
title: "Idea1".into(),
|
||||
status: IdeaStatus::Approved,
|
||||
description: String::new(),
|
||||
extra: vec![],
|
||||
};
|
||||
let seg = build_augmentation_segment(&[aug], "fr");
|
||||
assert!(seg.starts_with("--- 以下是用户选择的上下文参考"), "未知 lang 应按 zh 兜底");
|
||||
@@ -284,6 +290,7 @@ mod tests {
|
||||
status: ProjectStatus::Planning,
|
||||
description: String::new(),
|
||||
path: None,
|
||||
extra: vec![],
|
||||
};
|
||||
let seg = build_augmentation_segment(&[aug], "zh");
|
||||
assert!(!seg.contains("目录:"), "无 path 不应渲染目录行: {}", seg);
|
||||
|
||||
@@ -24,7 +24,8 @@ use crate::state::AppState;
|
||||
use crate::commands::{err_str, now_millis};
|
||||
|
||||
// chat.rs 的 super = commands,super::super = ai(与原 commands.rs 的 super=ai 等价)。
|
||||
use super::super::agentic::{run_agentic_loop, try_continue_agent_loop, GOAL_MAX_CHARS, GOAL_PIN_ENABLED, MAX_GOALS};
|
||||
use super::super::agentic::{run_agentic_loop, try_continue_agent_loop, GOAL_MAX_CHARS};
|
||||
// G1 目标钉扎:GOAL_PIN_ENABLED/MAX_GOALS 已迁移至 agentic 侧使用,chat 不再需要(extract_pinned_goal 纯函数只依赖 GOAL_MAX_CHARS 截断)
|
||||
// 双轨收口批1:读侧入口拦截用 ConvState(can_accept_request 的 unwrap_or 兜底初值)。
|
||||
use super::super::agentic::conv_state::ConvState;
|
||||
use super::super::audit::{audit_finalize, emit_data_changed};
|
||||
@@ -72,20 +73,10 @@ pub(crate) fn finalize_pending_placeholders(session: &mut super::super::AiSessio
|
||||
}
|
||||
}
|
||||
|
||||
/// G1 目标钉扎:从 user 消息文本提取单条目标(纯函数,无 IO / 无额外 LLM 请求)。
|
||||
/// 去噪助手:从文本中 strip `[kind:...]` mention 标签(纯函数,无 IO)。
|
||||
///
|
||||
/// 治 R1(目标消息被压缩出局)的提取侧:每次 user 消息的文本即潜在目标,
|
||||
/// 提取后追加到 `PerConvState.pinned_goals` Vec(去重),即使原消息出局目标仍在。
|
||||
///
|
||||
/// 处理(零额外请求,对齐 GLM 限额口径 1 请求=1 次):
|
||||
/// 1. strip 所有 `[kind:...]` mention 段(augmentation 已投影,目标文本去噪)。对齐 intent.rs
|
||||
/// KINDS(项目/任务/想法/技能 + project/task/idea/skill),全文扫描移除(实测诊断 seq5 目标
|
||||
/// `分析灵感模块不足 [项目: DevFlow]` mention 在**末尾**,非仅行首),冒号兼容全角:/半角:。
|
||||
/// 2. trim 空白。
|
||||
/// 3. 截断到 GOAL_MAX_CHARS(防长需求文档撑爆 system_prompt 占预算)。
|
||||
///
|
||||
/// 返回空串(纯 mention / 空消息)→ 调用方判非空才追加到 pinned_goals(避免空目标注入)。
|
||||
/// 默认用原文非 LLM 提取(消息锚点派 keepIdeas:零额外请求)。
|
||||
/// 原为 G1 目标钉扎的提取函数(2026-07-03 已改为工具调用推理),现保留供测试用例使用。
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn extract_pinned_goal(text: &str) -> String {
|
||||
// strip 所有 [kind:...] mention 段(对齐 intent.rs strip_mention_tags/try_match_mention 逻辑,
|
||||
// 单一真相源:KINDS 表与 intent.rs 一致)。全文扫描,非仅行首。
|
||||
@@ -114,6 +105,7 @@ pub(crate) fn extract_pinned_goal(text: &str) -> String {
|
||||
///
|
||||
/// kind 大小写不敏感(ASCII)+ 中文直比;冒号接受全角:/半角:;kind 后允许空白;
|
||||
/// 未闭合 `]` 返 None(不当 mention,保留原样 `[`)。对齐 intent.rs try_match_mention。
|
||||
#[allow(dead_code)]
|
||||
fn find_mention_close(chars: &[char], open: usize, kinds: &[&str]) -> Option<usize> {
|
||||
let after = open + 1;
|
||||
for k in kinds {
|
||||
@@ -463,17 +455,11 @@ pub async fn ai_chat_send(
|
||||
// DRY(B):知识注入已收敛至 inject_knowledge_into_prompt(helper 内部同消息取 text+id),
|
||||
// 此处 user_msg_id 不再透传到注入逻辑,保留下划线占用(锁内 push 已发生,语义不变)。
|
||||
let user_msg_id = conv.messages.last_user_message_id();
|
||||
// G1 目标钉扎:push 后提取本次 user 目标追加到 pinned_goals(去重,支持累积多目标)。
|
||||
// GOAL_PIN_ENABLED=false → 跳过;extract_pinned_goal 返空 → 不追加。
|
||||
if GOAL_PIN_ENABLED {
|
||||
let goal = extract_pinned_goal(&user_content);
|
||||
if !goal.is_empty() && !conv.pinned_goals.contains(&goal) {
|
||||
conv.pinned_goals.push(goal);
|
||||
if conv.pinned_goals.len() > MAX_GOALS {
|
||||
conv.pinned_goals.remove(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
// G1 目标钉扎已迁移至 run_agentic_loop 工具调用推理(2026-07-03 改),
|
||||
// 从 LLM 本轮调用的工具名+路径推理目标,不再从用户消息规则提取。
|
||||
// 用户发送的短指令(如「继续」「确认」)不会产生低价值目标。
|
||||
// 此处不再从原始用户消息提取。
|
||||
// GOAL_PIN_ENABLED=false → 跳过整块。
|
||||
(target, user_msg_id)
|
||||
};
|
||||
|
||||
@@ -1359,16 +1345,7 @@ pub async fn ai_chat_edit(
|
||||
conv.iteration_used = 0;
|
||||
// F-01 阶段6: 记录用户指定模型 override(主对话专用)。
|
||||
conv.model_override = model_override.clone();
|
||||
// G1 目标钉扎:替换消息后重新提取目标(用户可能通过编辑改变了意图,与 send 路径一致)
|
||||
if GOAL_PIN_ENABLED {
|
||||
let goal = extract_pinned_goal(&new_message);
|
||||
if !goal.is_empty() && !conv.pinned_goals.contains(&goal) {
|
||||
conv.pinned_goals.push(goal);
|
||||
if conv.pinned_goals.len() > MAX_GOALS {
|
||||
conv.pinned_goals.remove(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
// G1 目标钉扎已迁移至 run_agentic_loop 工具调用推理(提取方式同 ai_chat_send)
|
||||
}
|
||||
|
||||
let _tool_defs = state.ai_tools.tool_definitions();
|
||||
@@ -1534,17 +1511,7 @@ pub async fn ai_chat_force_send(
|
||||
}
|
||||
// F-260619-04 P1:push 后立即取末条 user 消息 id(供知识注入 referenced 溯源)。
|
||||
let user_msg_id = conv.messages.last_user_message_id();
|
||||
// G1 目标钉扎:push 后提取本次 user 目标追加到 pinned_goals(去重,与 send/edit 路径一致,支持累积多目标)。
|
||||
// force_send 是用户新指令,审批续跑会复用已累积目标。
|
||||
if GOAL_PIN_ENABLED {
|
||||
let goal = extract_pinned_goal(&user_content);
|
||||
if !goal.is_empty() && !conv.pinned_goals.contains(&goal) {
|
||||
conv.pinned_goals.push(goal);
|
||||
if conv.pinned_goals.len() > MAX_GOALS {
|
||||
conv.pinned_goals.remove(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
// G1 目标钉扎已迁移至 run_agentic_loop 工具调用推理(提取方式同 ai_chat_send)
|
||||
(was_gen.then_some(target.clone()), target, user_msg_id, conv.pinned_goals.clone())
|
||||
};
|
||||
|
||||
@@ -1782,7 +1749,7 @@ pub async fn ai_stop_loop(
|
||||
state: State<'_, AppState>,
|
||||
conversation_id: String,
|
||||
) -> Result<String, String> {
|
||||
let pinned_goals: Vec<String>;
|
||||
let pinned_goals: Vec<crate::commands::ai::GoalEntry>;
|
||||
{
|
||||
let mut session = state.ai_session.lock().await;
|
||||
// F-260616-09 B 批4(决策 e):conv_id 来源 IPC 参数 conversation_id,移除 active 一致性校验
|
||||
@@ -1879,6 +1846,10 @@ mod tests {
|
||||
assert_eq!(goal.chars().count(), GOAL_MAX_CHARS);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 低价值目标过滤
|
||||
// ============================================================
|
||||
|
||||
#[test]
|
||||
fn test_extract_mention_unclosed() {
|
||||
let goal = extract_pinned_goal("测试 [项目: DevFlow");
|
||||
|
||||
@@ -226,7 +226,8 @@ pub async fn ai_conversation_list(
|
||||
.unwrap_or_default();
|
||||
let pinned_goals: Vec<String> = r.pinned_goals
|
||||
.as_deref()
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.and_then(|s| serde_json::from_str::<Vec<crate::commands::ai::GoalEntry>>(s).ok())
|
||||
.map(|entries| entries.into_iter().map(|e| e.text).collect())
|
||||
.unwrap_or_default();
|
||||
serde_json::json!({
|
||||
"id": r.id,
|
||||
@@ -336,10 +337,34 @@ pub async fn ai_conversation_switch(
|
||||
conv.iteration_used = 0;
|
||||
conv.stop_flag.store(false, Ordering::SeqCst);
|
||||
// 从 DB 恢复 pinned_goals 到 per_conv(G1 目标钉扎持久化)
|
||||
let pinned_goals: Vec<String> = record.pinned_goals
|
||||
.as_deref()
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.unwrap_or_default();
|
||||
// 兼容旧格式:["text1","text2"] 转为 [{text, status: active}]
|
||||
let pinned_goals: Vec<crate::commands::ai::GoalEntry> = {
|
||||
let raw: Option<serde_json::Value> = record.pinned_goals
|
||||
.as_deref()
|
||||
.and_then(|s| serde_json::from_str(s).ok());
|
||||
match raw {
|
||||
Some(serde_json::Value::Array(arr)) => {
|
||||
arr.iter().filter_map(|v| {
|
||||
// 新格式: {"text":"...","status":"active"}
|
||||
if let Some(obj) = v.as_object() {
|
||||
let text = obj.get("text")?.as_str()?.to_string();
|
||||
let status = obj.get("status").and_then(|s| s.as_str())
|
||||
.map(|s| if s == "completed" {
|
||||
crate::commands::ai::GoalStatus::Completed
|
||||
} else {
|
||||
crate::commands::ai::GoalStatus::Active
|
||||
}).unwrap_or(crate::commands::ai::GoalStatus::Active);
|
||||
Some(crate::commands::ai::GoalEntry { text, status })
|
||||
} else {
|
||||
// 旧格式: "text" → 转 active
|
||||
let text = v.as_str()?.to_string();
|
||||
Some(crate::commands::ai::GoalEntry::new(text))
|
||||
}
|
||||
}).collect()
|
||||
}
|
||||
_ => vec![],
|
||||
}
|
||||
};
|
||||
conv.pinned_goals = pinned_goals;
|
||||
}
|
||||
// 仅清空目标对话自身的挂起审批,保留其他对话的(防 init 重建的内存 HashMap 被清空,
|
||||
@@ -531,7 +556,11 @@ pub async fn ai_update_conversation_goals(
|
||||
conversation_id: String,
|
||||
goals: Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
let goals_json = serde_json::to_string(&goals).map_err(|e| format!("序列化目标列表失败: {e}"))?;
|
||||
// 前端传回的 string[] 转为 GoalEntry(假设全部 active)
|
||||
let goal_entries: Vec<crate::commands::ai::GoalEntry> = goals.into_iter()
|
||||
.map(|g| crate::commands::ai::GoalEntry::new(g))
|
||||
.collect();
|
||||
let goals_json = serde_json::to_string(&goal_entries).map_err(|e| format!("序列化目标列表失败: {e}"))?;
|
||||
// 写 DB
|
||||
state.ai_conversations
|
||||
.update_field(&conversation_id, "pinned_goals", &goals_json)
|
||||
@@ -540,7 +569,7 @@ pub async fn ai_update_conversation_goals(
|
||||
// 同步内存 per_conv(若已加载)
|
||||
let mut session = state.ai_session.lock().await;
|
||||
if let Some(conv) = session.per_conv.get_mut(&conversation_id) {
|
||||
conv.pinned_goals = goals;
|
||||
conv.pinned_goals = goal_entries;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -637,7 +666,7 @@ pub async fn ai_conversation_export(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use df_ai::provider::{ChatMessage, ContentPart, MessageRole, MessageStatus, ToolCall, ToolCallFunction, ToolType};
|
||||
use df_ai::provider::{ChatMessage, ContentPart, MessageRole, MessageStatus, ToolCall, ToolCallFunction};
|
||||
|
||||
fn base_msg() -> ChatMessage {
|
||||
ChatMessage {
|
||||
@@ -821,7 +850,7 @@ mod tests {
|
||||
serde_json::to_string(&msg.tool_calls).unwrap()
|
||||
);
|
||||
assert_eq!(back.model.as_deref(), Some("m"));
|
||||
assert_eq!(back.status.as_deref(), Some("compressed"));
|
||||
assert_eq!(back.status.as_ref(), Some(&MessageStatus::Compressed));
|
||||
assert_eq!(back.reasoning_content.as_deref(), Some("r"));
|
||||
assert_eq!(back.timestamp, Some(123));
|
||||
}
|
||||
@@ -875,7 +904,7 @@ mod tests {
|
||||
},
|
||||
}]),
|
||||
status: if i % 6 == 0 {
|
||||
Some("compressed".into())
|
||||
Some(MessageStatus::Compressed)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
@@ -980,7 +1009,7 @@ mod tests {
|
||||
id: Some("m_summary".into()),
|
||||
role: MessageRole::Assistant,
|
||||
content: "压缩摘要".into(),
|
||||
status: Some("compressed".into()),
|
||||
status: Some(MessageStatus::Compressed),
|
||||
..base_msg()
|
||||
},
|
||||
ChatMessage {
|
||||
|
||||
@@ -140,7 +140,7 @@ pub enum AiChatEvent {
|
||||
incomplete: Option<bool>,
|
||||
conversation_id: Option<String>,
|
||||
/// 当前会话 pinned_goals(G1 目标钉扎持久化,前端直接读取刷新)
|
||||
pinned_goals: Vec<String>,
|
||||
pinned_goals: Vec<GoalEntry>,
|
||||
},
|
||||
/// 错误
|
||||
///
|
||||
@@ -820,17 +820,15 @@ pub struct PerConvState {
|
||||
/// 写收敛:经 GeneratingGuard/入口 transition_to 守卫迁移,非直接赋值。
|
||||
/// 批3 双轨收口:generating bool 已退役,此 enum 成为生成态唯一真相源。
|
||||
pub conv_state: ConvState,
|
||||
/// G1 目标钉扎真相源:用户消息中提取的目标列表(内容态字段,与生命周期态正交)。
|
||||
/// G1 目标钉扎真相源:从 LLM 工具调用推理的目标列表(内容态字段,与生命周期态正交)。
|
||||
///
|
||||
/// 治 R1(目标消息被压缩/compressed 物理出局,sanitize step0 过滤 is_active 致目标丢失):
|
||||
/// 目标存本字段绕过消息 active 状态过滤,即使原始 user 消息出局,goal 仍在。
|
||||
/// run_agentic_loop 入口拼进 system_prompt 尾部(system_prompt 是 loop 不变量,天然
|
||||
/// 免疫压缩/裁剪/sanitize,见 agentic/mod.rs:683)。
|
||||
/// run_agentic_loop 入口拼 active 目标进 system_prompt 尾部(system_prompt 是 loop 不变量)。
|
||||
///
|
||||
/// 写:chat.rs send/force_send/edit push 后提取目标追加到列表(去重,支持累积多目标);
|
||||
/// 读:loop 入口拼接全部目标。GOAL_PIN_ENABLED=false 时不提取不注入,本字段永远空 Vec。
|
||||
/// 随会话销毁不落库(对齐 knowledge_extracted 语义,PerConvState 无 serde derive)。
|
||||
pub pinned_goals: Vec<String>,
|
||||
/// 2026-07-03 改:提取从「用户消息规则」→「工具调用推理」(infer_goal_from_tool_calls),
|
||||
/// 每轮 LLM 回复后从工具名+路径推理目标追加(去重),支持多条/轮 + active→completed 状态流转。
|
||||
pub pinned_goals: Vec<GoalEntry>,
|
||||
/// 停止信号(会话级):ai_chat_stop 置位,agentic loop / stream_llm 检测后尽快退出
|
||||
pub stop_flag: Arc<AtomicBool>,
|
||||
/// 即时停止唤醒(会话级):阻塞在 stream.next() 时 notify_one() 立即唤醒跳出 select!
|
||||
@@ -863,6 +861,34 @@ pub struct PerConvState {
|
||||
pub knowledge_extracted: bool,
|
||||
}
|
||||
|
||||
/// 单个目标记录(含状态追踪)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GoalEntry {
|
||||
pub text: String,
|
||||
#[serde(default = "default_goal_status")]
|
||||
pub status: GoalStatus,
|
||||
}
|
||||
|
||||
/// 目标状态。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum GoalStatus {
|
||||
/// 正在进行中(当前 LLM 正在处理)
|
||||
Active,
|
||||
/// 已完成(LLM 已进入下一任务)
|
||||
Completed,
|
||||
}
|
||||
|
||||
fn default_goal_status() -> GoalStatus {
|
||||
GoalStatus::Active
|
||||
}
|
||||
|
||||
impl GoalEntry {
|
||||
pub fn new(text: String) -> Self {
|
||||
GoalEntry { text, status: GoalStatus::Active }
|
||||
}
|
||||
}
|
||||
|
||||
impl PerConvState {
|
||||
/// 新建默认会话级状态。
|
||||
///
|
||||
|
||||
@@ -2036,7 +2036,7 @@ fn register_file_tools(
|
||||
})
|
||||
};
|
||||
registry.register(
|
||||
"patch_file", "局部更新文件内容(三模式互斥)。模式1 old_text:精确匹配原文替换(含空格/缩进,CAS 语义)。模式2 replace_lines:按行号区间 {start,end}(1-based 含首尾)替换,不需原文,配 expected_hash 防行号漂移。模式3 anchor:按首尾子串锚点 {start,end}(大小写敏感,子串匹配)定位行号区间替换,不需完整原文。三模式均需 path+new_text,expected_hash 可选通用。小改动(≤5 行)自动放行不阻塞,大改动需人工审批。注意:若文件已被外部修改,请先重新 read_file 获取最新内容",
|
||||
"patch_file", "局部更新文件内容(三模式互斥)。模式1 old_text:精确匹配原文替换(含空格/缩进,CAS 语义)。模式2 replace_lines:按行号区间 {start,end}(1-based 含首尾)替换,不需原文,配 expected_hash 防行号漂移。模式3 anchor:按首尾子串锚点 {start,end}(大小写敏感,子串匹配)定位行号区间替换,不需完整原文。三模式均需 path+new_text,expected_hash 可选通用。注意:若文件已被外部修改,请先重新 read_file 获取最新内容。所有 patch_file 操作自动放行不阻塞 AI 工作流,写入会落 audit 表可追溯。",
|
||||
patch_file_schema,
|
||||
RiskLevel::Medium,
|
||||
{ let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| {
|
||||
@@ -4081,7 +4081,7 @@ mod tests {
|
||||
id: "proj-svc-1".to_string(),
|
||||
name: "proj-svc-1".to_string(),
|
||||
description: String::new(),
|
||||
status: "planning".to_string(),
|
||||
status: ProjectStatus::Planning,
|
||||
idea_id: None,
|
||||
path: None,
|
||||
stack: None,
|
||||
|
||||
@@ -112,7 +112,7 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
// 对话透明化 L1:收集每个 dirty conv 的 pinned_goals 快照(供 emit AiCompleted 携带)
|
||||
let pinned_goals_map: std::collections::HashMap<String, Vec<String>> = dirty_convs
|
||||
let pinned_goals_map: std::collections::HashMap<String, Vec<commands::ai::GoalEntry>> = dirty_convs
|
||||
.iter()
|
||||
.filter_map(|cid| {
|
||||
session.per_conv.get(cid).map(|c| (cid.clone(), c.pinned_goals.clone()))
|
||||
|
||||
21
src/App.vue
21
src/App.vue
@@ -33,6 +33,10 @@
|
||||
<transition name="toast">
|
||||
<div v-if="errorMsg" class="df-toast">⚠ {{ errorMsg }}</div>
|
||||
</transition>
|
||||
|
||||
<!-- 审批待办浮层(屏幕右上角,窗口置顶时浮于所有桌面窗口上面)。
|
||||
仅在主窗口与 ai-detached 窗口挂载(file-explorer 等其他分离窗口不挂)。 -->
|
||||
<ApprovalOverlay v-if="showApprovalOverlay" @activate="activateAiPanel" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -44,6 +48,7 @@ import { useProjectStore } from './stores/project'
|
||||
import { useAppSettingsStore } from './stores/appSettings'
|
||||
import i18n from './i18n'
|
||||
import AiChat from './components/AiChat.vue'
|
||||
import ApprovalOverlay from './components/ai/ApprovalOverlay.vue'
|
||||
import ErrorBoundary from './components/ErrorBoundary.vue'
|
||||
import AppLayout from './components/layout/AppLayout.vue'
|
||||
import { aiApi } from '@/api'
|
||||
@@ -53,6 +58,22 @@ const isDetached = computed(() => route.path === '/ai-detached' || route.path ==
|
||||
const aiStore = useAiStore()
|
||||
const appSettings = useAppSettingsStore()
|
||||
|
||||
// 审批浮层仅在主窗口与 ai-detached 窗口挂载(不在 file-explorer 等其他分离窗口弹)
|
||||
const showApprovalOverlay = computed(() => route.path !== '/file-explorer-detached')
|
||||
|
||||
/** 用户点击审批浮层 → 激活 AI 面板/分离窗口。
|
||||
* 主窗口:打开 AI 面板;分离窗口:提升焦点(窗口本身 alwaysOnTop 已置顶)。 */
|
||||
async function activateAiPanel() {
|
||||
if (isDetached.value) {
|
||||
try {
|
||||
const { getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||
await getCurrentWebviewWindow().setFocus()
|
||||
} catch { /* 忽略 */ }
|
||||
} else {
|
||||
if (!aiStore.state.panelOpen) aiStore.togglePanel()
|
||||
}
|
||||
}
|
||||
|
||||
// ── 全局错误 toast(消费 projectStore.error,所有 action 失败统一反馈)──
|
||||
const projectStore = useProjectStore()
|
||||
const errorMsg = ref('')
|
||||
|
||||
@@ -387,7 +387,7 @@ export type AiChatEvent = ({
|
||||
} | {
|
||||
// UX-2025-04 / CR-30-2 / 决策 F-260616-07 a1: incomplete 标记流中途失败保文(网络中断),
|
||||
// 前端据此差异化展示(如系统提示「⚠ 响应因网络中断不完整」)。正常完成/停止均为 undefined。
|
||||
type: 'AiCompleted'; total_tokens: number; prompt_tokens: number; completion_tokens: number; incomplete?: boolean; pinned_goals?: string[]
|
||||
type: 'AiCompleted'; total_tokens: number; prompt_tokens: number; completion_tokens: number; incomplete?: boolean; pinned_goals?: GoalEntry[]
|
||||
} | {
|
||||
type: 'AiError'; error: string; error_type?: AiErrorType
|
||||
} | {
|
||||
@@ -600,7 +600,7 @@ export interface AiConversationSummary {
|
||||
title: string | null
|
||||
provider_id: string | null
|
||||
model: string | null
|
||||
pinned_goals?: string[]
|
||||
pinned_goals?: GoalEntry[]
|
||||
archived: boolean
|
||||
pinned?: boolean
|
||||
prompt_tokens?: number | null
|
||||
@@ -609,6 +609,13 @@ export interface AiConversationSummary {
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** G1 目标钉扎:单条目标记录(含状态追踪) */
|
||||
export interface GoalEntry {
|
||||
text: string
|
||||
/** "active" | "completed" */
|
||||
status: 'active' | 'completed'
|
||||
}
|
||||
|
||||
/** 审批选择类型(F-260615-01: single=单选默认,multiple=多选) */
|
||||
export type ApprovalSelectType = 'single' | 'multiple'
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, nextTick, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { ref, computed, nextTick, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { listen } from '@tauri-apps/api/event'
|
||||
@@ -143,6 +143,7 @@ import MaxRoundsCard from './ai/MaxRoundsCard.vue'
|
||||
import HelpRequiredCard from './ai/HelpRequiredCard.vue'
|
||||
import DirAuthDialog from './ai/DirAuthDialog.vue'
|
||||
import { useConfirm } from '../composables/useConfirm'
|
||||
import { useToast } from '../composables/useToast'
|
||||
import { useProjectStore } from '../stores/project'
|
||||
import type { AiMessage } from '../api/types'
|
||||
|
||||
@@ -177,18 +178,10 @@ function sendExamplePrompt(i18nKey: string) {
|
||||
chatInputRef.value?.sendExamplePrompt(i18nKey)
|
||||
}
|
||||
|
||||
// ── 错误 toast(零依赖,复用 Settings.vue 同款 reactive toast) ──
|
||||
// ── 错误 toast(useToast composable,消除 AiChat/Settings/Projects/TaskDetail 4 处重复) ──
|
||||
// AiChat 可能以分离窗口运行(App.vue 根 toast 不在 DOM 中),故本组件自管一个 toast;
|
||||
// i18n 不在本任务白名单,文案硬编码,后续补 aiChat.toastSendFail key
|
||||
const toast = reactive({ visible: false, msg: '', type: 'error' as 'error' | 'warning' | 'info' })
|
||||
let _toastTimer: ReturnType<typeof setTimeout> | null = null
|
||||
function showToast(msg: string, type: 'error' | 'warning' | 'info' = 'info') {
|
||||
toast.msg = msg
|
||||
toast.type = type
|
||||
toast.visible = true
|
||||
if (_toastTimer) clearTimeout(_toastTimer)
|
||||
_toastTimer = setTimeout(() => { toast.visible = false }, 3000)
|
||||
}
|
||||
const { toast, showToast } = useToast()
|
||||
|
||||
// B-260616-12: 工具慢执行提示事件 unlistener(onMounted 注册,onBeforeUnmount 释放)
|
||||
let _unlistenToolSlow: (() => void) | null = null
|
||||
@@ -460,7 +453,7 @@ onBeforeUnmount(() => {
|
||||
store.stopContextListener()
|
||||
if (_unlistenToolSlow) { _unlistenToolSlow(); _unlistenToolSlow = null } // B-260616-12
|
||||
if (_unlistenAutoApproved) { _unlistenAutoApproved(); _unlistenAutoApproved = null } // AE-2025-04
|
||||
if (_toastTimer) { clearTimeout(_toastTimer); _toastTimer = null }
|
||||
// toast timer 清理由 useToast composable 的 onUnmounted 自动管理
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -517,13 +510,25 @@ onMounted(async () => {
|
||||
console.debug(`[启动] AiChat IPC 完成: ${(performance.now() - t0).toFixed(0)}ms`)
|
||||
})
|
||||
|
||||
// ── 置顶功能 ──
|
||||
// ── 置顶功能(持久化:刷新后保持置顶状态图标一致)──
|
||||
const alwaysOnTop = ref(false)
|
||||
const appSettingsStore = useAppSettingsStore()
|
||||
// 从 SQLite KV 恢复上次置顶状态
|
||||
const savedAlwaysOnTop = appSettingsStore.get<boolean>('df-ai-always-on-top', false)
|
||||
alwaysOnTop.value = savedAlwaysOnTop
|
||||
// 若恢复值为 true(上次是置顶态),同步到 Tauri 窗口
|
||||
if (savedAlwaysOnTop) {
|
||||
// 异步设(不阻塞初始化流程)
|
||||
const { getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||
getCurrentWebviewWindow().setAlwaysOnTop(true).catch(() => {/* 静默 */})
|
||||
}
|
||||
async function toggleAlwaysOnTop() {
|
||||
const { getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||
const win = getCurrentWebviewWindow()
|
||||
await win.setAlwaysOnTop(!alwaysOnTop.value)
|
||||
alwaysOnTop.value = !alwaysOnTop.value
|
||||
// 持久化到 SQLite KV(刷新后恢复)
|
||||
appSettingsStore.set('df-ai-always-on-top', alwaysOnTop.value)
|
||||
}
|
||||
|
||||
// 第五批抽离: F-260616-03 max_iterations 暂停态操作卡(maxRoundsActing/showMaxRoundsCard/
|
||||
|
||||
@@ -387,9 +387,31 @@ function groupIcon(name: string): string {
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: rgba(255,255,255,0.02);
|
||||
}
|
||||
.ai-batch-approve .ai-btn--sm {
|
||||
font-size: 11px;
|
||||
/* .ai-btn 基类:批量审批按钮(历史重构遗漏定义,补全治本) */
|
||||
.ai-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: var(--df-accent);
|
||||
color: #fff;
|
||||
font-family: var(--df-font-sans);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: filter 0.15s, background 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ai-btn:hover { filter: brightness(1.1); }
|
||||
.ai-btn--sm { padding: 4px 10px; font-size: 11px; }
|
||||
.ai-btn--danger {
|
||||
background: var(--df-danger);
|
||||
color: #fff;
|
||||
}
|
||||
.ai-btn--danger:hover { filter: brightness(1.1); }
|
||||
|
||||
/* -- 全部展开/收起(U-260618:从 topbar 下沉到分组标题行,移除独立按钮 padding/背景,
|
||||
高度=文字行高,不撑高分组标题行;融入标题视觉,仅 hover 变色) -- */
|
||||
|
||||
201
src/components/ai/ApprovalOverlay.vue
Normal file
201
src/components/ai/ApprovalOverlay.vue
Normal file
@@ -0,0 +1,201 @@
|
||||
<template>
|
||||
<!-- ═══ 审批待办浮层(屏幕右上角,置顶到所有窗口上面)═══
|
||||
设计目的:当 aichat 面板被遮挡/隐藏/最小化,有 pending 审批时,
|
||||
用户能立刻看到通知并一键激活 AI 面板处理。
|
||||
实现:
|
||||
- 监听 store.state.pendingApprovals(由 AiApprovalRequired 事件驱动)
|
||||
- 位置 position:fixed top:16px right:16px,z-index 高于面板
|
||||
- 窗口本身 alwaysOnTop 时浮层自然置顶到所有桌面窗口上面
|
||||
- 点击浮层 → emit 'activate'(App.vue 决定激活主面板/分离窗口)
|
||||
- 用户关闭后 60s 内同会话不再弹(防审批流期间反复打扰),新审批追加会再显 -->
|
||||
<Transition name="approval-overlay">
|
||||
<div
|
||||
v-if="visible"
|
||||
class="approval-overlay"
|
||||
role="alert"
|
||||
@click="onActivate"
|
||||
>
|
||||
<div class="approval-overlay-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 11l3 3L22 4" />
|
||||
<path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11" />
|
||||
</svg>
|
||||
<span v-if="count > 1" class="approval-overlay-badge">{{ count }}</span>
|
||||
</div>
|
||||
<div class="approval-overlay-body">
|
||||
<div class="approval-overlay-title">{{ $t('aiChat.approvalWaitingTitle') }}</div>
|
||||
<div class="approval-overlay-desc">{{ $t('aiChat.approvalWaitingDesc') }}</div>
|
||||
</div>
|
||||
<button
|
||||
class="approval-overlay-close"
|
||||
:title="$t('common.close')"
|
||||
@click.stop="onDismiss"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useAiStore } from '@/stores/ai'
|
||||
|
||||
const store = useAiStore()
|
||||
const emit = defineEmits<{
|
||||
/** 用户点击浮层主体:激活 AI 面板/分离窗口 */
|
||||
(e: 'activate'): void
|
||||
}>()
|
||||
|
||||
// 待审批数(0 = 无)
|
||||
const count = computed(() => store.state.pendingApprovals.length)
|
||||
|
||||
// 用户主动 dismiss 后 60s 内不再弹(同会话审批流期反复打扰防护)
|
||||
const dismissedAt = ref<number>(0)
|
||||
const DISMISS_COOLDOWN_MS = 60_000
|
||||
|
||||
// 显隐:有 pending + 未在冷却期
|
||||
const visible = computed(() => {
|
||||
if (count.value === 0) return false
|
||||
if (dismissedAt.value && Date.now() - dismissedAt.value < DISMISS_COOLDOWN_MS) return false
|
||||
return true
|
||||
})
|
||||
|
||||
// 审批清空时复位 dismiss(下次有新审批会重新弹)
|
||||
watch(count, (c) => {
|
||||
if (c === 0) dismissedAt.value = 0
|
||||
})
|
||||
|
||||
function onActivate() {
|
||||
emit('activate')
|
||||
}
|
||||
|
||||
function onDismiss() {
|
||||
dismissedAt.value = Date.now()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 浮层 — 右上角 fixed,z-index 高于 AI 面板与其他模态 */
|
||||
.approval-overlay {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
min-width: 260px;
|
||||
max-width: 360px;
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-accent);
|
||||
border-left: 3px solid var(--df-accent);
|
||||
border-radius: var(--df-radius-md);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(123, 111, 240, 0.2);
|
||||
cursor: pointer;
|
||||
transition: all 0.18s var(--df-ease);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.approval-overlay:hover {
|
||||
transform: translateX(-2px);
|
||||
border-color: var(--df-accent);
|
||||
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.5), 0 0 0 2px var(--df-accent);
|
||||
}
|
||||
|
||||
.approval-overlay-icon {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--df-accent-bg);
|
||||
color: var(--df-accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.approval-overlay-badge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 9px;
|
||||
background: var(--df-danger);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
border: 1.5px solid var(--df-bg-card);
|
||||
}
|
||||
|
||||
.approval-overlay-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.approval-overlay-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--df-text);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.approval-overlay-desc {
|
||||
font-size: 11px;
|
||||
color: var(--df-text-dim);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.approval-overlay-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--df-text-dim);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s var(--df-ease);
|
||||
}
|
||||
|
||||
.approval-overlay-close:hover {
|
||||
background: var(--df-sidebar-hover);
|
||||
color: var(--df-text);
|
||||
}
|
||||
|
||||
/* 进出动画:从右侧滑入 */
|
||||
.approval-overlay-enter-active,
|
||||
.approval-overlay-leave-active {
|
||||
transition: all 0.25s var(--df-ease);
|
||||
}
|
||||
|
||||
.approval-overlay-enter-from,
|
||||
.approval-overlay-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(40px);
|
||||
}
|
||||
|
||||
/* 响应式:窄屏占满右侧 */
|
||||
@media (max-width: 480px) {
|
||||
.approval-overlay {
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
left: 8px;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -813,123 +813,13 @@ defineExpose({
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
/* F-260614-05 Phase 2b: 待发送图片预览行(粘贴/拖拽加入,发送前可移除) */
|
||||
.ai-img-preview-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
width: 100%; /* 全宽强制后续 textarea+按钮换行 */
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 0.5px solid var(--df-border);
|
||||
}
|
||||
.ai-img-preview {
|
||||
position: relative;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: var(--df-radius);
|
||||
overflow: hidden;
|
||||
border: 0.5px solid var(--df-border);
|
||||
background: var(--df-bg);
|
||||
}
|
||||
.ai-img-preview-thumb {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.ai-img-preview-x {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--df-bg) 80%, transparent);
|
||||
color: var(--df-text-dim);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.ai-img-preview-x:hover {
|
||||
background: var(--df-danger);
|
||||
color: #fff;
|
||||
}
|
||||
/* F-260614-05 Phase 2b: 图片预览样式已提取到 styles/components.css 全局(ImageInput.vue 共享) */
|
||||
|
||||
.ai-input-wrap:focus-within {
|
||||
border-color: color-mix(in srgb, var(--df-accent) 40%, transparent);
|
||||
}
|
||||
|
||||
/* ── 技能 chip / 联想浮层 ── */
|
||||
.ai-skill-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
padding: 3px 8px;
|
||||
background: color-mix(in srgb, var(--df-accent) 16%, transparent);
|
||||
border: 0.5px solid color-mix(in srgb, var(--df-accent) 40%, transparent);
|
||||
border-radius: var(--df-radius);
|
||||
font-size: 12px;
|
||||
}
|
||||
.ai-skill-chip-name { color: var(--df-accent); font-weight: 600; flex-shrink: 0; }
|
||||
.ai-skill-chip-desc { color: var(--df-text-dim); flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
/* 参数格式提示(技能自带 argument_hint 数据,不进 i18n) */
|
||||
.ai-skill-chip-main { display: flex; align-items: center; gap: 6px; flex: 1; min-width: 0; flex-wrap: wrap; }
|
||||
.ai-skill-chip-hint {
|
||||
color: var(--df-text-dim);
|
||||
font-family: var(--df-font-mono, ui-monospace, monospace);
|
||||
font-size: 11px;
|
||||
background: color-mix(in srgb, var(--df-text-dim) 14%, transparent);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.ai-skill-chip-x { background: none; border: none; color: var(--df-text-dim); cursor: pointer; font-size: 14px; line-height: 1; padding: 0 2px; flex-shrink: 0; }
|
||||
.ai-skill-chip-x:hover { color: var(--df-text); }
|
||||
|
||||
.ai-skill-popover {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
z-index: 20;
|
||||
}
|
||||
.ai-skill-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
.ai-skill-item-row { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.ai-skill-item--active,
|
||||
.ai-skill-item:hover {
|
||||
background: color-mix(in srgb, var(--df-accent) 14%, transparent);
|
||||
}
|
||||
.ai-skill-item-name { color: var(--df-accent); font-weight: 600; min-width: 90px; }
|
||||
.ai-skill-item-desc { color: var(--df-text-dim); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ai-skill-item-src { color: var(--df-text-dim); opacity: 0.6; font-size: 10px; text-transform: uppercase; }
|
||||
/* 浮层内参数格式提示(等宽 dim 小字,仅在 skill.argument_hint 有值时显) */
|
||||
.ai-skill-item-hint {
|
||||
color: var(--df-text-dim);
|
||||
font-family: var(--df-font-mono, ui-monospace, monospace);
|
||||
font-size: 11px;
|
||||
opacity: 0.85;
|
||||
word-break: break-all;
|
||||
}
|
||||
.ai-skill-empty { padding: 8px 12px; color: var(--df-text-dim); opacity: 0.7; font-size: 12px; }
|
||||
/* ── 技能 chip / 联想浮层样式已提取到 styles/components.css 全局(SkillMention.vue + MentionPopover.vue 共享)── */
|
||||
|
||||
/* ── UX-10 §1.4: @ 实体引用浮层样式已随 MentionPopover.vue 提取 ── */
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
{{ conv.title || t('aiChat.newConversation') }}
|
||||
<span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span>
|
||||
</span>
|
||||
<span class="ai-conv-item-time">{{ formatTime(conv.updated_at) }}</span>
|
||||
<span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span>
|
||||
</div>
|
||||
<div class="ai-conv-item-actions">
|
||||
<div class="ai-conv-item-more">
|
||||
@@ -132,7 +132,7 @@
|
||||
<!-- F-09: 多会话并发生成指示器(后台/当前会话生成中均显脉冲点,多会话可同时显示) -->
|
||||
<span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span>
|
||||
</span>
|
||||
<span class="ai-conv-item-time">{{ formatTime(conv.updated_at) }}</span>
|
||||
<span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span>
|
||||
</div>
|
||||
<div class="ai-conv-item-actions">
|
||||
<div class="ai-conv-item-more">
|
||||
@@ -177,7 +177,7 @@
|
||||
{{ conv.title || t('aiChat.newConversation') }}
|
||||
<span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span>
|
||||
</span>
|
||||
<span class="ai-conv-item-time">{{ formatTime(conv.updated_at) }}</span>
|
||||
<span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span>
|
||||
</div>
|
||||
<div class="ai-conv-item-actions">
|
||||
<div class="ai-conv-item-more">
|
||||
@@ -328,10 +328,6 @@ function onSelectSearchResult(id: string) {
|
||||
store.state.searchQuery = ''
|
||||
}
|
||||
|
||||
function formatTime(ts: string): string {
|
||||
return formatRelative(ts)
|
||||
}
|
||||
|
||||
// ── 对话分组:今天 / 昨天 / 更早 ──
|
||||
const bucketLabels = computed<Record<string, string>>(() => ({
|
||||
today: t('aiChat.today'),
|
||||
|
||||
@@ -38,7 +38,7 @@ const emit = defineEmits<{
|
||||
|
||||
<template>
|
||||
<!-- UX-10 §1.4: @ 实体引用浮层(@ 触发,选中插入 [类型:名] 标记,架构复用技能 popover) -->
|
||||
<div class="ai-skill-popover ai-mention-popover">
|
||||
<div class="ai-skill-popover">
|
||||
<div v-if="!mentionItems.length" class="ai-skill-empty">
|
||||
{{ mentionTotal ? $t('aiChat.mentionNoMatch') : $t('aiChat.mentionEmpty') }}
|
||||
</div>
|
||||
@@ -65,34 +65,8 @@ const emit = defineEmits<{
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ── UX-10 §1.4: @ 实体引用浮层(复用 .ai-skill-popover 容器,仅新增组标题/类型标签) ── */
|
||||
.ai-mention-popover {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
z-index: 20;
|
||||
}
|
||||
.ai-skill-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
.ai-skill-item-row { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.ai-skill-item--active,
|
||||
.ai-skill-item:hover {
|
||||
background: color-mix(in srgb, var(--df-accent) 14%, transparent);
|
||||
}
|
||||
.ai-skill-empty { padding: 8px 12px; color: var(--df-text-dim); opacity: 0.7; font-size: 12px; }
|
||||
/* ── UX-10 §1.4: @ 实体引用浮层特定样式(复用全局 .ai-skill-popover/.ai-skill-item,仅新增 mention 专有) ──
|
||||
.ai-skill-popover/.ai-skill-item/.ai-skill-empty 已提取到 styles/components.css 全局 */
|
||||
.ai-mention-group {
|
||||
padding: 4px 10px 2px;
|
||||
font-size: 10px;
|
||||
|
||||
@@ -53,32 +53,48 @@
|
||||
<button class="ai-btn-icon" @click="emit('new-conversation')" :title="$t('aiChat.newConversation')">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 013 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||||
</button>
|
||||
<!-- F-15 阶段2: 清空上下文(历史消息归档保留,不删 DB;新对话不受影响)。
|
||||
空会话/全归档/生成中禁用(无活跃消息时不发起无意义 IPC)。 -->
|
||||
<button
|
||||
class="ai-btn-icon"
|
||||
:disabled="!hasActiveMessages || store.state.streaming"
|
||||
:title="$t('aiChat.clearContext')"
|
||||
@click="emit('clear-context')"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg>
|
||||
</button>
|
||||
<!-- F-15 阶段2: 压缩上下文(LLM 摘要落 system + 历史消息标 compressed 归档)。
|
||||
loading(isCompressing)时禁用 + 显示 spinner;空/全归档/生成中禁用。 -->
|
||||
<button
|
||||
class="ai-btn-icon"
|
||||
:class="{ 'ai-btn-icon--active': store.isCompressing.value }"
|
||||
:disabled="!hasActiveMessages || store.state.streaming || store.isCompressing.value"
|
||||
:title="store.isCompressing.value ? $t('aiChat.compressing') : $t('aiChat.compressContext')"
|
||||
@click="emit('compress-context')"
|
||||
>
|
||||
<svg v-if="!store.isCompressing.value" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
|
||||
<svg v-else class="ai-btn-spinner" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12a9 9 0 11-6.219-8.56"/></svg>
|
||||
</button>
|
||||
<!-- 清空对话(真删 DB messages,带二次确认防误删) -->
|
||||
<!-- 清空对话(真删 DB messages,带二次确认防误删)。高频常用,常驻。 -->
|
||||
<button class="ai-btn-icon" @click="emit('clear-chat')" :title="$t('aiChat.clearChat')">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2"/></svg>
|
||||
</button>
|
||||
|
||||
<!-- ══ 更多菜单(...):收纳低频的上下文管理操作(清空上下文/压缩上下文)══ -->
|
||||
<!-- 使用频率分级:新建/清空对话/窗口控制是高频常用,上下文管理是低频项;
|
||||
收进 popout 降低顶部视觉密度,需时从 ... 入口展开。点击外部收起。 -->
|
||||
<div class="ai-more-menu" ref="moreMenuRef">
|
||||
<button
|
||||
class="ai-btn-icon"
|
||||
:class="{ 'ai-btn-icon--active': moreMenuOpen }"
|
||||
:title="$t('aiChat.moreActions')"
|
||||
@click="moreMenuOpen = !moreMenuOpen"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg>
|
||||
</button>
|
||||
<Transition name="ai-more-popout">
|
||||
<div v-if="moreMenuOpen" class="ai-more-popout" role="menu">
|
||||
<!-- F-15 阶段2: 清空上下文(历史消息归档保留,不删 DB;新对话不受影响) -->
|
||||
<button
|
||||
class="ai-more-item"
|
||||
:disabled="!hasActiveMessages || store.state.streaming"
|
||||
@click="emit('clear-context'); moreMenuOpen = false"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg>
|
||||
<span>{{ $t('aiChat.clearContext') }}</span>
|
||||
</button>
|
||||
<!-- F-15 阶段2: 压缩上下文(LLM 摘要落 system + 历史消息标 compressed 归档) -->
|
||||
<button
|
||||
class="ai-more-item"
|
||||
:disabled="!hasActiveMessages || store.state.streaming || store.isCompressing.value"
|
||||
@click="emit('compress-context'); moreMenuOpen = false"
|
||||
>
|
||||
<svg v-if="!store.isCompressing.value" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
|
||||
<svg v-else class="ai-btn-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12a9 9 0 11-6.219-8.56"/></svg>
|
||||
<span>{{ store.isCompressing.value ? $t('aiChat.compressing') : $t('aiChat.compressContext') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- 嵌入模式:最大化/还原 + 分离 + 关闭 -->
|
||||
<template v-if="!detached">
|
||||
<button class="ai-btn-icon" @click="emit('toggle-maximize')" :title="store.state.maximized ? $t('aiChat.restoreSidebar') : $t('aiChat.maximize')">
|
||||
@@ -98,7 +114,7 @@
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="8" height="18" rx="1"/><rect x="14" y="3" width="8" height="18" rx="1"/></svg>
|
||||
</button>
|
||||
<button class="ai-btn-icon" :class="{ 'ai-btn-icon--active': alwaysOnTop }" @click="emit('toggle-always-on-top')" :title="alwaysOnTop ? $t('aiChat.unpin') : $t('aiChat.pinOnTop')">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2C8 2 6 5 6 8v3l-2 2v1h16v-1l-2-2V8c0-3-2-6-6-6z"/><line x1="12" y1="14" x2="12" y2="22"/></svg>
|
||||
</button>
|
||||
<button class="ai-btn-icon" @click="emit('close-detached')" :title="$t('aiChat.closeWindow')">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
@@ -115,11 +131,13 @@
|
||||
<div v-if="goals.length" class="ai-goals-inline">
|
||||
<span class="ai-goals-inline-badge ai-tool-badge" @click="goalsExpanded = !goalsExpanded" :title="$t('aiChat.viewGoals')">
|
||||
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>
|
||||
{{ goals.length }}
|
||||
{{ goals.filter(g => g.status === 'active').length }}/{{ goals.length }}
|
||||
</span>
|
||||
<div v-if="goalsExpanded" class="ai-goals-inline-list">
|
||||
<div v-for="(g, i) in goals" :key="i" class="ai-goal-inline-item">
|
||||
<span class="ai-goal-text">{{ g }}</span>
|
||||
<div v-for="(g, i) in goals" :key="i" class="ai-goal-inline-item" :class="{ 'ai-goal-completed': g.status === 'completed' }">
|
||||
<span v-if="g.status === 'active'" class="ai-goal-dot"></span>
|
||||
<span v-else class="ai-goal-check">✓</span>
|
||||
<span class="ai-goal-text">{{ g.text }}</span>
|
||||
<button class="ai-goal-remove" @click.stop="removeGoal(i)">×</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -252,6 +270,26 @@ const emit = defineEmits<{
|
||||
const store = useAiStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
// ══ 更多菜单 popout 状态(上下文管理操作收纳)══
|
||||
// 顶部按钮过多时,低频的清空/压缩上下文收入 ... popout;点击按钮切换,点击外部收起。
|
||||
const moreMenuOpen = ref(false)
|
||||
const moreMenuRef = ref<HTMLElement | null>(null)
|
||||
function onDocClickCloseMoreMenu(e: MouseEvent) {
|
||||
if (!moreMenuOpen.value) return
|
||||
const root = moreMenuRef.value
|
||||
if (root && !root.contains(e.target as Node)) {
|
||||
moreMenuOpen.value = false
|
||||
}
|
||||
}
|
||||
onMounted(() => document.addEventListener('click', onDocClickCloseMoreMenu))
|
||||
onBeforeUnmount(() => document.removeEventListener('click', onDocClickCloseMoreMenu))
|
||||
// ESC 键收起更多菜单(键盘友好)
|
||||
function onEscCloseMoreMenu(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && moreMenuOpen.value) moreMenuOpen.value = false
|
||||
}
|
||||
onMounted(() => document.addEventListener('keydown', onEscCloseMoreMenu))
|
||||
onBeforeUnmount(() => document.removeEventListener('keydown', onEscCloseMoreMenu))
|
||||
|
||||
const activeProviderName = computed(() => {
|
||||
const active = store.state.providers.find(p => p.id === store.state.activeProvider)
|
||||
return active?.name || store.state.providers[0]?.name || t('aiChat.notConfigured')
|
||||
@@ -296,8 +334,8 @@ const currentConvTitle = computed(() => {
|
||||
return conv?.title || ''
|
||||
})
|
||||
|
||||
// ── 🎯 对话目标面板(对话透明化 L1:Goal visibility) ──
|
||||
const goals = ref<string[]>([])
|
||||
// 🎯 对话目标面板(对话透明化 L1:Goal visibility)
|
||||
const goals = ref<GoalEntry[]>([])
|
||||
const goalsExpanded = ref(false)
|
||||
|
||||
// ── 📋 历史消息(当前会话 user 消息列表) ──
|
||||
@@ -437,10 +475,14 @@ watch(() => store.state.messages.length, () => {
|
||||
function loadGoals(convId: string | null) {
|
||||
if (!convId) { goals.value = []; return }
|
||||
const conv = store.state.conversations.find(c => c.id === convId)
|
||||
goals.value = conv?.pinned_goals ?? []
|
||||
// 兼容旧格式(string[]):后端迁移过渡期可能返回纯文本数组
|
||||
const raw = conv?.pinned_goals ?? []
|
||||
goals.value = raw.map((g: any) =>
|
||||
typeof g === 'string' ? { text: g, status: 'active' as const } : g
|
||||
)
|
||||
}
|
||||
|
||||
/** 移除第 i 个目标(移除最后一个自动收起) */
|
||||
/** 移除第 i 个目标 */
|
||||
async function removeGoal(i: number) {
|
||||
const convId = store.state.activeConversationId
|
||||
if (!convId) return
|
||||
@@ -448,7 +490,8 @@ async function removeGoal(i: number) {
|
||||
next.splice(i, 1)
|
||||
goals.value = next
|
||||
if (next.length === 0) goalsExpanded.value = false
|
||||
await aiApi.updateConversationGoals(convId, next)
|
||||
// API 接受 string[] 或 GoalEntry[]
|
||||
await aiApi.updateConversationGoals(convId, next.map(g => g.text))
|
||||
}
|
||||
|
||||
// 循环切换 Provider(provider bar 点击)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<router-link to="/projects" class="df-link">{{ $t('dashboard.viewAll') }}</router-link>
|
||||
</div>
|
||||
<div class="project-list">
|
||||
<div v-for="(p, i) in displayProjects" :key="p.id" class="project-row" :style="{ animationDelay: `${300 + i * 50}ms` }">
|
||||
<div v-for="(p, i) in displayProjects" :key="p.id" class="project-row clickable" :style="{ animationDelay: `${300 + i * 50}ms` }" @click="goDetail(p.id)" :title="t('dashboard.goProjectDetail')">
|
||||
<div class="project-row-top">
|
||||
<div class="project-identity">
|
||||
<span class="project-dot" :class="'dot-' + p.stage"></span>
|
||||
@@ -34,57 +34,67 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
// 活跃项目面板 — 从 Dashboard.vue 抽出(项目列表 + 进度条 + 阶段 chip + 空态)。
|
||||
// 共享 project store(全局单例),displayProjects computed 直读 store.projects/tasks,无需父传 props。
|
||||
// getProjectStage 死键语义对齐 constants/project.ts 的 PROJECT_STAGE_INFO。
|
||||
// 共享 project store(全局单例),displayProjects computed 过滤 active 状态按更新时间倒序取前 6 条。
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useProjectStore } from '@/stores/project'
|
||||
import { projectStageInfo } from '@/constants/project'
|
||||
import { formatRelative } from '@/utils/time'
|
||||
|
||||
const store = useProjectStore()
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
function getProjectStage(status: string): { stage: string; stageLabelKey: string; progress: number } {
|
||||
// F-09 对齐:project status 死键(in_progress/paused/cancelled)已从后端映射移除,
|
||||
// 此处 switch 同步收敛,与 constants/project.ts 的 PROJECT_STAGE_INFO 语义一致。
|
||||
switch (status) {
|
||||
case 'planning': return { stage: 'planning', stageLabelKey: 'planning', progress: 20 }
|
||||
case 'active': return { stage: 'coding', stageLabelKey: 'coding', progress: 55 }
|
||||
case 'completed': return { stage: 'release', stageLabelKey: 'done', progress: 100 }
|
||||
default: return { stage: 'planning', stageLabelKey: 'planning', progress: 20 }
|
||||
}
|
||||
// 点击项目行跳转详情(治本:列表项可进详情,与其他列表页交互一致)
|
||||
function goDetail(projectId: string) {
|
||||
router.push(`/projects/${projectId}`)
|
||||
}
|
||||
|
||||
function getProjectTaskCount(projectId: string): number {
|
||||
return store.tasks.filter(t => t.project_id === projectId && t.status === 'in_progress').length
|
||||
}
|
||||
|
||||
// 相对时间复用 utils/time.formatRelative(与 Tasks/AuditLog/MessageList 等同源,根治 NaN)
|
||||
// 原 formatLastActivity 是其逐行复制,提取为单一来源。
|
||||
const formatLastActivity = formatRelative
|
||||
|
||||
// 阶段 label key:projectStageInfo 返回的 stage 值(coding/testing/release/planning)
|
||||
// 直接拼接 i18n key dashboard.stage.<stage>,与 constants/project.ts PROJECT_STAGE_INFO 单一来源。
|
||||
// 面板名为"活跃项目",只显示 active 状态(DB 实际默认值,ProjectStatus union 历史遗留未含
|
||||
// 'active',此处断言绕过;DB 实际无 planning/in_progress 等值产生,详见功能决策记录)。
|
||||
// 按更新时间倒序取前 6 条(completed 归档项目不混入)。
|
||||
const displayProjects = computed(() =>
|
||||
store.projects.map(p => {
|
||||
const stageInfo = getProjectStage(p.status)
|
||||
return {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
...stageInfo,
|
||||
activeTasks: getProjectTaskCount(p.id),
|
||||
lastActivity: formatLastActivity(p.updated_at),
|
||||
}
|
||||
})
|
||||
store.projects
|
||||
.filter(p => (p.status as string) === 'active')
|
||||
.sort((a, b) => b.updated_at.localeCompare(a.updated_at))
|
||||
.slice(0, 6)
|
||||
.map(p => {
|
||||
const info = projectStageInfo(p.status)
|
||||
return {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
stage: info.stage,
|
||||
stageLabelKey: info.stage,
|
||||
progress: info.progress,
|
||||
activeTasks: getProjectTaskCount(p.id),
|
||||
lastActivity: formatRelative(p.updated_at),
|
||||
}
|
||||
})
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 仅迁入项目行自身样式;df-panel/df-link 等通用面板类留 Dashboard.vue(多面板共享) */
|
||||
/* df-panel/df-link 通用面板类已提取到 styles/components.css 全局 */
|
||||
.project-list { display: flex; flex-direction: column; }
|
||||
.project-row {
|
||||
padding: 10px 0;
|
||||
border-bottom: 0.5px solid var(--df-border);
|
||||
animation: fadeInUp 0.4s var(--df-ease) both;
|
||||
}
|
||||
.project-row.clickable {
|
||||
cursor: pointer;
|
||||
transition: background 0.15s var(--df-ease);
|
||||
}
|
||||
.project-row.clickable:hover {
|
||||
background: var(--df-bg);
|
||||
}
|
||||
.project-row:last-child { border-bottom: none; }
|
||||
|
||||
.project-row-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
|
||||
|
||||
@@ -5,10 +5,6 @@
|
||||
<router-link to="/ideas" class="df-link">{{ $t('dashboard.viewAll') }}</router-link>
|
||||
</div>
|
||||
<div class="ideas-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-num">{{ stats.total }}</span>
|
||||
<span class="stat-label">{{ $t('ideas.statsTotal') }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-num">{{ stats.pending }}</span>
|
||||
<span class="stat-label">{{ $t('ideas.statsPending') }}</span>
|
||||
@@ -23,7 +19,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="idea-rows">
|
||||
<div v-for="idea in displayIdeas" :key="idea.id" class="idea-row">
|
||||
<div v-for="idea in displayIdeas" :key="idea.id" class="idea-row clickable" @click="goDetail(idea.id)" :title="t('dashboard.goIdeaDetail')">
|
||||
<div class="idea-score-ring" :class="'ring-' + idea.tier">
|
||||
<span class="idea-score-num">{{ idea.score }}</span>
|
||||
</div>
|
||||
@@ -39,26 +35,39 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
// 灵感池面板 — 从 Dashboard.vue 抽出(灵感列表 + 评分环 + tier 着色 + 空态)。
|
||||
// 共享 project store(全局单例),displayIdeas computed 直读 store.ideas(取前 4 条),无需父传 props。
|
||||
// 共享 project store(全局单例),displayIdeas computed 按分数倒序取前 4 条(精选),无需父传 props。
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useProjectStore } from '@/stores/project'
|
||||
import { scoreTier } from '@/utils/ideaEval'
|
||||
|
||||
const store = useProjectStore()
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
// 点击灵感行跳转详情(Ideas.vue 同组件路由,会自动选中)
|
||||
function goDetail(ideaId: string) {
|
||||
router.push(`/ideas/${ideaId}`)
|
||||
}
|
||||
|
||||
// 精选灵感列表:按分数倒序取前 4 条(高分优先,符合"精选"语义;原来 slice(0,4)
|
||||
// 无排序会显示最老的 4 条而非高分灵感)。
|
||||
const displayIdeas = computed(() =>
|
||||
store.ideas.slice(0, 4).map(i => ({
|
||||
id: i.id,
|
||||
title: i.title,
|
||||
score: i.score ?? 0,
|
||||
tier: (i.score ?? 0) >= 80 ? 'high' : (i.score ?? 0) >= 60 ? 'mid' : 'low',
|
||||
statusLabel: i.status,
|
||||
}))
|
||||
[...store.ideas]
|
||||
.sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
|
||||
.slice(0, 4)
|
||||
.map(i => ({
|
||||
id: i.id,
|
||||
title: i.title,
|
||||
score: i.score ?? 0,
|
||||
tier: scoreTier(i.score),
|
||||
statusLabel: i.status,
|
||||
}))
|
||||
)
|
||||
|
||||
// 灵感池统计概览 — 4 项关键指标(总数/待审/已晋升/平均分)。
|
||||
// score 可为 null(未评估),avgScore 仅对已评估灵感求均值,无则显示 '—'。
|
||||
// 灵感池细分统计(顶部 StatCardRow 已显示总数,此处仅展示待审/已晋升/平均分 3 项细分,
|
||||
// 删除重复的 total 避免与顶部统计卡信息冗余)。
|
||||
const stats = computed(() => {
|
||||
const ideas = store.ideas
|
||||
const scored = ideas.filter(i => i.score != null)
|
||||
@@ -66,7 +75,6 @@ const stats = computed(() => {
|
||||
? Math.round(scored.reduce((sum, i) => sum + (i.score ?? 0), 0) / scored.length)
|
||||
: null
|
||||
return {
|
||||
total: ideas.length,
|
||||
pending: ideas.filter(i => i.status === 'draft' || i.status === 'pending_review').length,
|
||||
promoted: ideas.filter(i => i.status === 'promoted').length,
|
||||
avgScore: avg == null ? '—' : avg,
|
||||
@@ -79,10 +87,10 @@ function ideaStatusLabel(status: string): string {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 仅迁入灵感行自身样式;df-panel/df-link 等通用面板类留 Dashboard.vue(多面板共享) */
|
||||
/* df-panel/df-link 通用面板类已提取到 styles/components.css 全局 */
|
||||
.ideas-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
@@ -117,6 +125,13 @@ function ideaStatusLabel(status: string): string {
|
||||
padding: 7px 0;
|
||||
border-bottom: 0.5px solid var(--df-border);
|
||||
}
|
||||
.idea-row.clickable {
|
||||
cursor: pointer;
|
||||
transition: background 0.15s var(--df-ease);
|
||||
}
|
||||
.idea-row.clickable:hover {
|
||||
background: var(--df-bg);
|
||||
}
|
||||
.idea-row:last-child { border-bottom: none; }
|
||||
|
||||
.idea-score-ring {
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
<div
|
||||
v-for="(stat, i) in stats" :key="stat.key"
|
||||
class="stat-card"
|
||||
:class="{ clickable: stat.link }"
|
||||
:style="{ animationDelay: `${i * 60}ms` }"
|
||||
@click="stat.link ? go(stat.link) : null"
|
||||
:title="stat.link ? stat.title : undefined"
|
||||
>
|
||||
<div class="stat-card-bg" :class="'stat-bg--' + stat.key"></div>
|
||||
<div class="stat-card-inner">
|
||||
@@ -11,11 +14,6 @@
|
||||
<div class="stat-icon-wrap" :style="{ background: stat.iconBg }">
|
||||
<span class="stat-icon">{{ stat.icon }}</span>
|
||||
</div>
|
||||
<span class="stat-trend" :class="stat.trend > 0 ? 'up' : stat.trend < 0 ? 'down' : 'flat'">
|
||||
<template v-if="stat.trend > 0">↑</template><template v-else-if="stat.trend < 0">↓</template>
|
||||
<template v-else>—</template>
|
||||
{{ stat.trend > 0 ? '+' : '' }}{{ stat.trend }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="stat-value">{{ stat.value }}</div>
|
||||
<div class="stat-label">{{ stat.label }}</div>
|
||||
@@ -27,22 +25,29 @@
|
||||
<script setup lang="ts">
|
||||
// 统计卡片行 — 从 Dashboard.vue 抽出(4 张统计卡: ideas/projects/tasks/drafts)。
|
||||
// 共享 project store(全局单例),stats computed 直读 store.stats,无需父传 props。
|
||||
// 卡片可点击跳转对应列表页(ideas/projects/tasks),提升导航效率。
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useProjectStore } from '@/stores/project'
|
||||
|
||||
const store = useProjectStore()
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
function go(link: string) {
|
||||
router.push(link)
|
||||
}
|
||||
|
||||
const stats = computed(() => [
|
||||
{ key: 'ideas', icon: '\u{1F4A1}', label: t('dashboard.stats.ideas'), value: store.stats.ideas, trend: 0,
|
||||
iconBg: 'rgba(123,111,240,0.12)' },
|
||||
{ key: 'projects', icon: '\u{1F4C2}', label: t('dashboard.stats.projects'), value: store.stats.projects, trend: 0,
|
||||
iconBg: 'rgba(94,175,240,0.10)' },
|
||||
{ key: 'tasks', icon: '⚡', label: t('dashboard.stats.activeTasks'), value: store.stats.activeTasks, trend: 0,
|
||||
iconBg: 'rgba(61,219,160,0.10)' },
|
||||
{ key: 'drafts', icon: '\u{1F4DD}', label: t('dashboard.stats.drafts'), value: store.stats.drafts, trend: 0,
|
||||
iconBg: 'rgba(240,199,94,0.10)' },
|
||||
{ key: 'ideas', icon: '\u{1F4A1}', label: t('dashboard.stats.ideas'), value: store.stats.ideas,
|
||||
iconBg: 'rgba(123,111,240,0.12)', link: '/ideas', title: t('dashboard.viewAll') },
|
||||
{ key: 'projects', icon: '\u{1F4C2}', label: t('dashboard.stats.projects'), value: store.stats.projects,
|
||||
iconBg: 'rgba(94,175,240,0.10)', link: '/projects', title: t('dashboard.viewAll') },
|
||||
{ key: 'tasks', icon: '⚡', label: t('dashboard.stats.activeTasks'), value: store.stats.activeTasks,
|
||||
iconBg: 'rgba(61,219,160,0.10)', link: '/tasks', title: t('dashboard.viewAll') },
|
||||
{ key: 'drafts', icon: '\u{1F4DD}', label: t('dashboard.stats.drafts'), value: store.stats.drafts,
|
||||
iconBg: 'rgba(240,199,94,0.10)', link: null, title: '' },
|
||||
])
|
||||
</script>
|
||||
|
||||
@@ -61,6 +66,14 @@ const stats = computed(() => [
|
||||
border: 0.5px solid var(--df-border);
|
||||
animation: fadeInUp 0.5s var(--df-ease) both;
|
||||
}
|
||||
.stat-card.clickable {
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s var(--df-ease), transform 0.15s var(--df-ease);
|
||||
}
|
||||
.stat-card.clickable:hover {
|
||||
border-color: var(--df-accent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.stat-card-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -82,17 +95,6 @@ const stats = computed(() => [
|
||||
}
|
||||
.stat-icon { font-size: 14px; }
|
||||
|
||||
.stat-trend {
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--df-radius-sm);
|
||||
}
|
||||
.stat-trend.up { color: var(--df-success); background: rgba(61,219,160,0.10); }
|
||||
.stat-trend.down { color: var(--df-danger); background: rgba(240,101,101,0.10); }
|
||||
.stat-trend.flat { color: var(--df-text-dim); background: rgba(255,255,255,0.04); }
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="eval-report" style="opacity:0.5">
|
||||
<div v-else class="eval-report eval-report--muted">
|
||||
<button class="btn-evaluate" :disabled="evaluating" @click="$emit('evaluate')">
|
||||
{{ evaluating ? $t('ideas.evaluating') : $t('ideas.startEval') }}
|
||||
</button>
|
||||
@@ -90,8 +90,8 @@
|
||||
<!-- 评估历史(版本时间线) -->
|
||||
<div class="detail-section">
|
||||
<h3>{{ $t('ideas.historyTitle') }}</h3>
|
||||
<div v-if="historyLoading" class="eval-report" style="opacity:0.5">{{ $t('ideas.historyLoading') }}</div>
|
||||
<div v-else-if="evalHistory.length === 0" class="eval-report" style="opacity:0.5">{{ $t('ideas.historyEmpty') }}</div>
|
||||
<div v-if="historyLoading" class="eval-report eval-report--muted">{{ $t('ideas.historyLoading') }}</div>
|
||||
<div v-else-if="evalHistory.length === 0" class="eval-report eval-report--muted">{{ $t('ideas.historyEmpty') }}</div>
|
||||
<div v-else class="eval-history-list">
|
||||
<div
|
||||
v-for="rec in evalHistory"
|
||||
@@ -135,7 +135,7 @@
|
||||
<span v-else class="related-chip related-chip-missing">#{{ row.id }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else class="eval-report" style="opacity:0.5">{{ $t('ideas.relatedEmpty') }}</div>
|
||||
<div v-else class="eval-report eval-report--muted">{{ $t('ideas.relatedEmpty') }}</div>
|
||||
<button class="btn btn-ghost btn-sm" @click="startRelateEdit">{{ $t('ideas.relatedManage') }}</button>
|
||||
</template>
|
||||
<!-- 编辑态 -->
|
||||
@@ -154,7 +154,7 @@
|
||||
<span>{{ idea.title }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div v-else class="eval-report" style="opacity:0.5">{{ $t('ideas.relatedEmpty') }}</div>
|
||||
<div v-else class="eval-report eval-report--muted">{{ $t('ideas.relatedEmpty') }}</div>
|
||||
<div class="related-edit-actions">
|
||||
<button class="btn btn-primary btn-sm" @click="confirmRelate">{{ $t('ideas.relatedConfirm') }}</button>
|
||||
<button class="btn btn-ghost btn-sm" @click="cancelRelate">{{ $t('ideas.relatedCancel') }}</button>
|
||||
@@ -169,12 +169,12 @@
|
||||
<div class="score-bar-row" v-for="dim in parseScores(idea.scores)" :key="dim.name">
|
||||
<span class="score-bar-label">{{ dim.name }}</span>
|
||||
<div class="score-bar-track">
|
||||
<div class="score-bar-fill" :style="{ width: dim.score + '%' }" :class="dim.score >= 80 ? 'fill-high' : dim.score >= 60 ? 'fill-mid' : 'fill-low'"></div>
|
||||
<div class="score-bar-fill" :style="{ width: dim.score + '%' }" :class="'fill-' + scoreTier(dim.score)"></div>
|
||||
</div>
|
||||
<span class="score-bar-value">{{ dim.score }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="eval-report" style="opacity:0.5">{{ $t('ideas.noEval') }}</div>
|
||||
<div v-else class="eval-report eval-report--muted">{{ $t('ideas.noEval') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 标签 -->
|
||||
@@ -183,7 +183,7 @@
|
||||
<div class="tag-list" v-if="parseTags(idea.tags).length > 0">
|
||||
<span class="tag" v-for="tag in parseTags(idea.tags)" :key="tag">{{ tag }}</span>
|
||||
</div>
|
||||
<div v-else class="eval-report" style="opacity:0.5">{{ $t('ideas.noTags') }}</div>
|
||||
<div v-else class="eval-report eval-report--muted">{{ $t('ideas.noTags') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 状态管理 -->
|
||||
@@ -230,7 +230,7 @@ import { useProjectStore } from '../../stores/project'
|
||||
import { useRendered } from '../../composables/useMarkdown'
|
||||
import { ideaApi } from '../../api'
|
||||
import { formatDate } from '../../utils/time'
|
||||
import { parseScores, assessmentClass, assessmentLabel } from '../../utils/ideaEval'
|
||||
import { parseScores, assessmentClass, assessmentLabel, scoreTier } from '../../utils/ideaEval'
|
||||
import type { IdeaRecord, IdeaStatus, IdeaEvaluationRecord } from '../../api/types'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -491,17 +491,10 @@ watch(() => props.idea.id, loadHistory)
|
||||
border-left: 3px solid var(--df-accent);
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
/* 弱化态(空数据/加载中占位,原 inline opacity:0.5 统一为 class) */
|
||||
.eval-report--muted { opacity: 0.5; }
|
||||
|
||||
/* ===== 状态标签 ===== */
|
||||
.status-tag {
|
||||
font-size: 11px; font-weight: 500; padding: 2px 8px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
}
|
||||
.status-draft { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
|
||||
.status-pending_review { background: rgba(100,181,246,0.2); color: var(--df-info); }
|
||||
.status-approved { background: rgba(100,255,218,0.15); color: var(--df-success); }
|
||||
.status-promoted { background: rgba(108,99,255,0.2); color: var(--df-accent); }
|
||||
.status-rejected { background: rgba(255,107,107,0.2); color: var(--df-danger); }
|
||||
/* ===== 状态标签已提取到 styles/components.css 全局(.status-tag 系列) ===== */
|
||||
|
||||
/* ===== 对抗式评估 ===== */
|
||||
.adversarial-eval {
|
||||
@@ -595,21 +588,7 @@ watch(() => props.idea.id, loadHistory)
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.assessment-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.assessment-badge.immediate { background: var(--df-success-bg); color: var(--df-success); }
|
||||
.assessment-badge.soon { background: var(--df-info-bg); color: var(--df-info); }
|
||||
.assessment-badge.conditional { background: var(--df-warning-bg); color: var(--df-warning); }
|
||||
.assessment-badge.revised { background: rgba(255, 217, 61, 0.2); color: var(--df-warning); }
|
||||
.assessment-badge.defer { background: var(--df-danger-bg); color: var(--df-danger); }
|
||||
.assessment-badge.cancel { background: var(--df-danger-bg); color: var(--df-danger); }
|
||||
/* assessment-badge 系列已提取到 styles/components.css 全局 */
|
||||
|
||||
.final-score {
|
||||
font-size: 14px;
|
||||
@@ -774,45 +753,7 @@ watch(() => props.idea.id, loadHistory)
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
|
||||
/* ===== 多维评分条(div 模拟) ===== */
|
||||
.score-bar-chart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.score-bar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.score-bar-label {
|
||||
font-size: 12px;
|
||||
color: var(--df-text-secondary);
|
||||
min-width: 72px;
|
||||
text-align: right;
|
||||
}
|
||||
.score-bar-track {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background: var(--df-border);
|
||||
border-radius: var(--df-radius-xs);
|
||||
overflow: hidden;
|
||||
}
|
||||
.score-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: var(--df-radius-xs);
|
||||
transition: width 0.4s;
|
||||
}
|
||||
.fill-high { background: var(--df-success); }
|
||||
.fill-mid { background: var(--df-warning); }
|
||||
.fill-low { background: var(--df-danger); }
|
||||
.score-bar-value {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
min-width: 28px;
|
||||
text-align: right;
|
||||
color: var(--df-text);
|
||||
}
|
||||
/* ===== 多维评分条已提取到 styles/components.css 全局(.score-bar-* 系列) ===== */
|
||||
|
||||
/* ===== 标签 ===== */
|
||||
.tag-list { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
@@ -854,22 +795,8 @@ watch(() => props.idea.id, loadHistory)
|
||||
.related-edit-item input { cursor: pointer; }
|
||||
.related-edit-actions { display: flex; gap: 8px; }
|
||||
|
||||
/* ===== 按钮 ===== */
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: var(--df-radius-sm);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.btn-primary { background: var(--df-accent); color: #fff; }
|
||||
.btn-primary:hover { background: var(--df-accent-hover); }
|
||||
.btn-ghost { background: transparent; color: var(--df-text-secondary); border: 0.5px solid var(--df-border); }
|
||||
.btn-ghost:hover { background: var(--df-bg-card); color: var(--df-text); }
|
||||
|
||||
/* 小尺寸按钮(描述编辑/重试等) */
|
||||
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
||||
/* ===== 按钮已提取到 global.css 全局(.btn/.btn-primary/.btn-ghost/.btn-sm) ===== */
|
||||
/* IdeaDetail 特有按钮定位 */
|
||||
.desc-edit-btn { margin-bottom: var(--df-gap-page); margin-top: 4px; }
|
||||
.desc-edit-actions { display: flex; gap: 8px; margin-bottom: var(--df-gap-page); margin-top: 8px; }
|
||||
|
||||
@@ -923,4 +850,11 @@ watch(() => props.idea.id, loadHistory)
|
||||
border-color: var(--df-accent);
|
||||
background: var(--df-bg-raised);
|
||||
}
|
||||
|
||||
/* ═══ 响应式:窄屏对抗式评估双栏堆叠 ═══ */
|
||||
@media (max-width: 760px) {
|
||||
.debate-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -350,17 +350,7 @@ watch(() => props.detail.knowledge.id, () => {
|
||||
.edit-input-title { font-size: 16px; font-weight: 500; }
|
||||
.edit-textarea { min-height: 100px; resize: vertical; font-family: inherit; }
|
||||
|
||||
/* ===== 按钮 ===== */
|
||||
.btn {
|
||||
padding: 8px 16px; border: none; border-radius: var(--df-radius-sm);
|
||||
font-size: 13px; cursor: pointer; transition: all 0.15s;
|
||||
}
|
||||
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
||||
.btn-primary { background: var(--df-accent); color: #fff; }
|
||||
.btn-primary:hover { background: var(--df-accent-hover); }
|
||||
.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.btn-ghost { background: transparent; color: var(--df-text-secondary); border: 0.5px solid var(--df-border); }
|
||||
.btn-ghost:hover { background: var(--df-bg-card); color: var(--df-text); }
|
||||
/* 按钮基础样式 (.btn / .btn-sm / .btn-primary / .btn-ghost) 已收敛至 global.css */
|
||||
|
||||
/* ===== 响应式:窄屏详情标题防竖线化(B-260619)===== */
|
||||
@media (max-width: 760px) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<aside class="df-sidebar">
|
||||
<aside class="df-sidebar" :class="{ 'df-sidebar--collapsed': collapsed }">
|
||||
<!-- Logo -->
|
||||
<div class="sidebar-brand">
|
||||
<div class="brand-mark">
|
||||
@@ -10,74 +10,118 @@
|
||||
</linearGradient></defs>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="brand-text">DevFlow</span>
|
||||
<span class="brand-version">v0.1</span>
|
||||
<span v-if="!collapsed" class="brand-text">DevFlow</span>
|
||||
<span v-if="!collapsed" class="brand-version">v0.1</span>
|
||||
<button v-if="!collapsed" class="collapse-btn" @click="toggleCollapsed" :title="$t('nav.collapse')">
|
||||
<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="15 18 9 12 15 6"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 收缩态展开按钮(底部固定位置,仅在 collapsed 时显示) -->
|
||||
<button v-if="collapsed" class="expand-btn" @click="toggleCollapsed" :title="$t('nav.expand')">
|
||||
<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="9 18 15 12 9 6"/></svg>
|
||||
</button>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="sidebar-nav">
|
||||
<div class="nav-group">
|
||||
<span class="nav-group-label">{{ $t('nav.workspace') }}</span>
|
||||
<span v-if="!collapsed" class="nav-group-label">{{ $t('nav.workspace') }}</span>
|
||||
<router-link
|
||||
v-for="item in primaryNav" :key="item.path"
|
||||
:to="item.path"
|
||||
class="nav-link"
|
||||
active-class="nav-link--active"
|
||||
:title="collapsed ? $t(item.label) : undefined"
|
||||
>
|
||||
<span class="nav-link-icon" v-html="item.svg"></span>
|
||||
<span class="nav-link-text">{{ $t(item.label) }}</span>
|
||||
<span v-if="item.badge" class="nav-link-badge">{{ item.badge }}</span>
|
||||
<span v-if="!collapsed" class="nav-link-text">{{ $t(item.label) }}</span>
|
||||
<span v-if="!collapsed && item.badge" class="nav-link-badge">{{ item.badge }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="nav-divider"></div>
|
||||
|
||||
<div class="nav-group">
|
||||
<span class="nav-group-label">{{ $t('nav.traceability') }}</span>
|
||||
<span v-if="!collapsed" class="nav-group-label">{{ $t('nav.traceability') }}</span>
|
||||
<router-link
|
||||
v-for="item in secondaryNav" :key="item.path"
|
||||
:to="item.path"
|
||||
class="nav-link"
|
||||
active-class="nav-link--active"
|
||||
:title="collapsed ? $t(item.label) : undefined"
|
||||
>
|
||||
<span class="nav-link-icon" v-html="item.svg"></span>
|
||||
<span class="nav-link-text">{{ $t(item.label) }}</span>
|
||||
<span v-if="item.path === '/knowledge' && candidateCount > 0" class="nav-link-badge">{{ candidateCount }}</span>
|
||||
<span v-if="!collapsed" class="nav-link-text">{{ $t(item.label) }}</span>
|
||||
<span v-if="!collapsed && item.path === '/knowledge' && candidateCount > 0" class="nav-link-badge">{{ candidateCount }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="sidebar-footer">
|
||||
<div class="sidebar-status">
|
||||
<div v-if="!collapsed" class="sidebar-status">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-text">{{ $t('nav.systemReady') }}</span>
|
||||
</div>
|
||||
<!-- AI 面板切换按钮 -->
|
||||
<button class="nav-link ai-toggle-btn" :class="{ 'ai-toggle-btn--active': aiStore.state.panelOpen }" @click="aiStore.togglePanel()">
|
||||
<span class="nav-link-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a7 7 0 014 12.74V17a1 1 0 01-1 1H9a1 1 0 01-1-1v-2.26A7 7 0 0112 2z"/><line x1="9" y1="21" x2="15" y2="21"/></svg>
|
||||
<button class="nav-link ai-toggle-btn" :class="{ 'ai-toggle-btn--active': aiStore.state.panelOpen }" @click="aiStore.togglePanel()" :title="collapsed ? $t('nav.aiPanel') : undefined">
|
||||
<span class="nav-link-icon ai-spark-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"/><path d="M19 3v3M21 4.5h-3M5 17v3M6.5 18.5h-3"/></svg>
|
||||
</span>
|
||||
<span class="nav-link-text">{{ $t('nav.aiPanel') }}</span>
|
||||
<span class="ai-toggle-shortcut">⌘I</span>
|
||||
<span v-if="!collapsed" class="nav-link-text">{{ $t('nav.aiPanel') }}</span>
|
||||
<span v-if="!collapsed" class="ai-toggle-shortcut">⌘I</span>
|
||||
</button>
|
||||
<router-link to="/settings" class="nav-link" active-class="nav-link--active">
|
||||
<router-link to="/settings" class="nav-link" active-class="nav-link--active" :title="collapsed ? $t('nav.settings') : undefined">
|
||||
<span class="nav-link-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
|
||||
</span>
|
||||
<span class="nav-link-text">{{ $t('nav.settings') }}</span>
|
||||
<span v-if="!collapsed" class="nav-link-text">{{ $t('nav.settings') }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useAiStore } from '@/stores/ai'
|
||||
import { useKnowledgeStore } from '@/stores/knowledge'
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
|
||||
const aiStore = useAiStore()
|
||||
const knowledgeStore = useKnowledgeStore()
|
||||
const appSettings = useAppSettingsStore()
|
||||
|
||||
// ── 侧边栏收缩状态(持久化到 appSettings,小窗口自动收缩) ──
|
||||
// 展开 192px(文字+图标) / 收缩 56px(仅图标,hover tooltip 显示文字)
|
||||
// 优先级:用户手动设置 > 小窗口自动收缩(≤768px)
|
||||
const COLLAPSE_THRESHOLD = 768
|
||||
const userCollapsed = ref(false) // 用户手动设置的收缩状态
|
||||
const isNarrowWindow = ref(false) // 小窗口自动收缩标记
|
||||
|
||||
const collapsed = computed(() => userCollapsed.value || isNarrowWindow.value)
|
||||
|
||||
// 持久化用户手动收缩状态
|
||||
function toggleCollapsed() {
|
||||
userCollapsed.value = !userCollapsed.value
|
||||
void appSettings.set('df-sidebar-collapsed', userCollapsed.value)
|
||||
}
|
||||
|
||||
// 监听窗口宽度:小窗口自动收缩(但不改变用户偏好)
|
||||
function checkWindowWidth() {
|
||||
isNarrowWindow.value = window.innerWidth < COLLAPSE_THRESHOLD
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 恢复用户偏好(默认展开)
|
||||
userCollapsed.value = appSettings.get('df-sidebar-collapsed', false)
|
||||
// 检查当前窗口宽度
|
||||
checkWindowWidth()
|
||||
window.addEventListener('resize', checkWindowWidth)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', checkWindowWidth)
|
||||
})
|
||||
|
||||
// 知识库候选数量(侧栏 badge)— 解包 computed 取值保持响应式
|
||||
const candidateCount = computed(() => knowledgeStore.candidateCount)
|
||||
@@ -111,6 +155,13 @@ const secondaryNav = [
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
transition: width 0.2s var(--df-ease), min-width 0.2s var(--df-ease);
|
||||
}
|
||||
|
||||
/* 收缩态 */
|
||||
.df-sidebar--collapsed {
|
||||
width: 56px;
|
||||
min-width: 56px;
|
||||
}
|
||||
|
||||
/* — Logo — */
|
||||
@@ -121,12 +172,18 @@ const secondaryNav = [
|
||||
padding: 14px 16px 12px;
|
||||
border-bottom: 0.5px solid var(--df-border);
|
||||
}
|
||||
.df-sidebar--collapsed .sidebar-brand {
|
||||
padding: 12px 10px 10px;
|
||||
justify-content: center;
|
||||
}
|
||||
.brand-mark {
|
||||
width: 28px; height: 28px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: var(--df-accent-bg);
|
||||
border: 0.5px solid var(--df-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.df-sidebar--collapsed .brand-mark { width: 32px; height: 32px; }
|
||||
.brand-text {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
@@ -140,11 +197,48 @@ const secondaryNav = [
|
||||
background: rgba(255,255,255,0.04);
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
}
|
||||
|
||||
/* 收缩按钮(展开态 brand 区右侧) */
|
||||
.collapse-btn {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px; height: 22px;
|
||||
border: none;
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--df-text-dim);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s var(--df-ease);
|
||||
}
|
||||
.collapse-btn:hover {
|
||||
background: var(--df-sidebar-hover);
|
||||
color: var(--df-text);
|
||||
}
|
||||
|
||||
/* 展开按钮(收缩态 brand 区下方) */
|
||||
.expand-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: 6px 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--df-text-dim);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s var(--df-ease);
|
||||
}
|
||||
.expand-btn:hover {
|
||||
background: var(--df-sidebar-hover);
|
||||
color: var(--df-text);
|
||||
}
|
||||
|
||||
/* — Nav — */
|
||||
.sidebar-nav { flex: 1; overflow-y: auto; padding: 8px 8px; }
|
||||
.df-sidebar--collapsed .sidebar-nav { padding: 8px 6px; }
|
||||
|
||||
.nav-group { margin-bottom: 2px; }
|
||||
.nav-group-label {
|
||||
@@ -162,6 +256,7 @@ const secondaryNav = [
|
||||
background: var(--df-border);
|
||||
margin: 6px 12px;
|
||||
}
|
||||
.df-sidebar--collapsed .nav-divider { margin: 4px 8px; }
|
||||
|
||||
.nav-link {
|
||||
display: flex;
|
||||
@@ -177,6 +272,10 @@ const secondaryNav = [
|
||||
transition: all 0.18s var(--df-ease);
|
||||
position: relative;
|
||||
}
|
||||
.df-sidebar--collapsed .nav-link {
|
||||
justify-content: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background: var(--df-sidebar-hover);
|
||||
@@ -198,6 +297,8 @@ const secondaryNav = [
|
||||
background: var(--df-accent);
|
||||
border-radius: 0 3px 3px 0;
|
||||
}
|
||||
/* 收缩态隐藏左边高亮线(居中布局无空间) */
|
||||
.df-sidebar--collapsed .nav-link--active::before { display: none; }
|
||||
|
||||
.nav-link-icon {
|
||||
display: flex;
|
||||
@@ -240,6 +341,15 @@ const secondaryNav = [
|
||||
color: var(--df-accent);
|
||||
background: var(--df-sidebar-active);
|
||||
}
|
||||
/* AI 入口用星花图标 + 轻发光,与其他 nav-link 区分 */
|
||||
.ai-spark-icon {
|
||||
opacity: 0.85;
|
||||
}
|
||||
.ai-toggle-btn:hover .ai-spark-icon,
|
||||
.ai-toggle-btn--active .ai-spark-icon {
|
||||
opacity: 1;
|
||||
filter: drop-shadow(0 0 3px var(--df-accent));
|
||||
}
|
||||
.ai-toggle-shortcut {
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 9px;
|
||||
@@ -276,14 +386,6 @@ const secondaryNav = [
|
||||
}
|
||||
|
||||
/* ═══ Responsive ═══ */
|
||||
@media (max-width: 768px) {
|
||||
.df-sidebar { width: 56px; min-width: 56px; }
|
||||
.brand-text, .brand-version, .nav-group-label, .nav-link-text,
|
||||
.nav-link-badge, .sidebar-status, .status-text, .ai-toggle-shortcut { display: none; }
|
||||
.sidebar-brand { padding: 12px 10px 10px; justify-content: center; }
|
||||
.brand-mark { width: 32px; height: 32px; }
|
||||
.nav-link { justify-content: center; padding: 8px 0; }
|
||||
.nav-divider { margin: 4px 8px; }
|
||||
.nav-link--active::before { display: none; }
|
||||
}
|
||||
/* 小窗口自动收缩由 JS resize listener 驱动(isNarrowWindow),无需 media query。
|
||||
原 768px media query 已删除,收缩逻辑统一走 collapsed class 控制。 */
|
||||
</style>
|
||||
|
||||
@@ -337,30 +337,9 @@ onBeforeUnmount(() => {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 4px 12px;
|
||||
border: none;
|
||||
border-radius: var(--df-radius-sm, 4px);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--df-text-secondary);
|
||||
border: 0.5px solid var(--df-border);
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
background: var(--df-bg);
|
||||
color: var(--df-text);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
/* 按钮基础样式 (.btn / .btn-ghost / .btn-sm / .btn-primary) 已收敛至 global.css */
|
||||
/* 依赖图工具栏紧凑,覆 padding/font-size 避免按钮变大 */
|
||||
.btn { padding: 4px 12px; font-size: 12px; }
|
||||
|
||||
/* 添加依赖弹窗 */
|
||||
.dep-graph__modal-overlay {
|
||||
@@ -395,7 +374,4 @@ onBeforeUnmount(() => {
|
||||
.dep-graph__modal-actions {
|
||||
display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px;
|
||||
}
|
||||
.btn-primary { background: var(--df-accent); color: #fff; }
|
||||
.btn-primary:hover { filter: brightness(1.1); }
|
||||
.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
</style>
|
||||
|
||||
@@ -612,39 +612,9 @@ async function onRemoveModule() {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* 按钮 */
|
||||
.btn {
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
border-radius: var(--df-radius-sm, 4px);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--df-text-secondary);
|
||||
border: 0.5px solid var(--df-border);
|
||||
}
|
||||
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background: var(--df-bg);
|
||||
color: var(--df-text);
|
||||
}
|
||||
|
||||
.btn-ghost:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
/* 按钮基础样式 (.btn / .btn-ghost / .btn-sm) 已收敛至 global.css */
|
||||
/* FileExplorer 工具栏为紧凑布局,覆 padding/font-size 避免按钮变大 */
|
||||
.btn { padding: 6px 12px; font-size: 12px; }
|
||||
|
||||
/* 主体两栏 — 自适应比例 30% / 70% */
|
||||
.explorer-body {
|
||||
|
||||
@@ -91,6 +91,7 @@ import { ref, watch, computed, onUnmounted } from 'vue'
|
||||
import { moduleApi } from '@/api/module'
|
||||
import hljs from 'highlight.js/lib/common'
|
||||
import { useMarkdown, useRendered } from '@/composables/useMarkdown'
|
||||
import { escapeHtml } from '@/utils/html'
|
||||
|
||||
const props = defineProps<{
|
||||
moduleId: string
|
||||
@@ -284,10 +285,6 @@ async function loadFile() {
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
function joinPath(root: string, rel: string): string {
|
||||
const sep = root.includes('\\') ? '\\' : '/'
|
||||
const normRel = rel.split('/').join(sep)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<span class="module-node__icon">📦</span>
|
||||
<span class="module-node__name">{{ data.name }}</span>
|
||||
</div>
|
||||
<div class="module-node__path">{{ shortPath }}</div>
|
||||
<div class="module-node__path">{{ truncatedPath }}</div>
|
||||
<div v-if="stackList.length" class="module-node__tags">
|
||||
<span v-for="s in stackList" :key="s" class="module-node__tag">{{ s }}</span>
|
||||
</div>
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { parseJsonArray } from '@/utils/json'
|
||||
|
||||
const props = defineProps<{
|
||||
data: {
|
||||
@@ -24,21 +25,13 @@ const props = defineProps<{
|
||||
selected?: boolean
|
||||
}>()
|
||||
|
||||
const shortPath = computed(() => {
|
||||
const truncatedPath = computed(() => {
|
||||
const p = props.data?.path || ''
|
||||
if (p.length <= 32) return p
|
||||
return '...' + p.slice(-29)
|
||||
})
|
||||
|
||||
const stackList = computed(() => {
|
||||
if (!props.data?.stack) return []
|
||||
try {
|
||||
const parsed = JSON.parse(props.data.stack)
|
||||
return Array.isArray(parsed) ? parsed.slice(0, 3) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
const stackList = computed(() => parseJsonArray(props.data?.stack).slice(0, 3))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
<template>
|
||||
<!-- ═══ 设置导入 / 导出(阶段6 UX 重构)═══
|
||||
导出:聚合 appSettings 全 KV(已解析)+ 知识库配置 → JSON 文件下载
|
||||
(默认不含 provider keyring 密钥,scope 标注)
|
||||
导入:input[type=file] 读 JSON → 解析校验 → useConfirm 覆盖确认
|
||||
→ 逐 KV settings.set + knowledgeApi.saveConfig → toast 提示
|
||||
复用 settings.css 全局 .btn/.btn-primary/.btn-ghost/.btn-sm;
|
||||
confirm/toast 由父 Settings.vue 经 props 注入,共用壳层弹层与 toast。 -->
|
||||
<div class="settings-io">
|
||||
<button class="btn btn-ghost btn-sm" :disabled="busy" @click="onExport">
|
||||
⬆ {{ $t('settings.export') }}
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm" :disabled="busy" @click="triggerImport">
|
||||
⬇ {{ $t('settings.import') }}
|
||||
</button>
|
||||
<!-- 隐藏的文件选择器(点击「导入」触发) -->
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
class="file-input"
|
||||
@change="onFilePicked"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ============================================================
|
||||
// 阶段6 导入 / 导出 — 纯前端实现
|
||||
// ------------------------------------------------------------
|
||||
// 导出:从 appSettings.cache 取已解析的全 KV(类型正确,非后端原始 JSON 字符串),
|
||||
// 叠加 knowledgeApi.getConfig(),组成 {metadata, appSettings, knowledge} JSON。
|
||||
// 用 Blob + a[download] 下载(不依赖 Tauri fs/dialog 权限)。
|
||||
// 导入:<input type=file> + FileReader.readAsText → 解析 → metadata.scope 校验
|
||||
// → confirmDialog 覆盖确认 → appSettings.set 逐 KV + knowledgeApi.saveConfig
|
||||
// → toast 反馈(并发/轮次需重启或下次对话生效)。
|
||||
// 安全:scope 仅含 appSettings + 知识库配置,默认不含 provider keyring 密钥
|
||||
// (密钥在独立 store,本流程不触及)。
|
||||
// ============================================================
|
||||
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
import { knowledgeApi } from '@/api/knowledge'
|
||||
import type { KnowledgeConfig } from '@/api/types'
|
||||
|
||||
const props = defineProps<{
|
||||
/** 覆盖确认弹层(复用 Settings.vue 壳层 confirmState) */
|
||||
confirmDialog: (msg: string, dangerLabel?: string) => Promise<boolean>
|
||||
/** toast 反馈通道(复用 Settings.vue 壳层 showToast) */
|
||||
toast: (msg: string, type?: 'error' | 'warning' | 'info') => void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const appSettings = useAppSettingsStore()
|
||||
|
||||
const busy = ref(false)
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
/** 导出的 JSON 备份载荷结构 */
|
||||
interface SettingsBackup {
|
||||
metadata: {
|
||||
version: number
|
||||
date: string
|
||||
scope: 'appSettings+knowledge'
|
||||
app: string
|
||||
}
|
||||
appSettings: Record<string, unknown>
|
||||
knowledge: KnowledgeConfig | null
|
||||
}
|
||||
|
||||
/** 当前日期戳(用于导出文件名:devflow-settings-YYYYMMDD.json) */
|
||||
function dateStamp(): string {
|
||||
const d = new Date()
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return `${y}${m}${day}`
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 导出
|
||||
// ============================================================
|
||||
async function onExport() {
|
||||
if (busy.value) return
|
||||
busy.value = true
|
||||
try {
|
||||
// appSettings.cache 为已解析(反序列化)值的响应式缓存 —— 取其快照,
|
||||
// 保证导出的 JSON 值类型与运行时一致(非后端原始 JSON 字符串)
|
||||
const allKv: Record<string, unknown> = { ...appSettings.cache }
|
||||
// 知识库配置独立 IPC 读取(不在 app_settings 表)
|
||||
let knowledge: KnowledgeConfig | null = null
|
||||
try {
|
||||
knowledge = await knowledgeApi.getConfig()
|
||||
} catch (e) {
|
||||
// 知识库读取失败不阻断导出(appSettings 仍可导),仅 console 记录
|
||||
console.error('[设置导出] 读取知识库配置失败:', e)
|
||||
}
|
||||
|
||||
const payload: SettingsBackup = {
|
||||
metadata: {
|
||||
version: 1,
|
||||
date: new Date().toISOString(),
|
||||
scope: 'appSettings+knowledge',
|
||||
app: 'devflow',
|
||||
},
|
||||
appSettings: allKv,
|
||||
knowledge,
|
||||
}
|
||||
|
||||
const json = JSON.stringify(payload, null, 2)
|
||||
// Blob + a[download] 下载:webview 内原生支持,无需 Tauri fs 权限
|
||||
const blob = new Blob([json], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `devflow-settings-${dateStamp()}.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (e) {
|
||||
props.toast(t('settings.exportFail', { msg: errMsg(e) }), 'error')
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 导入
|
||||
// ============================================================
|
||||
function triggerImport() {
|
||||
// 重置 value 以便「选同一文件」也能再次触发 change(否则 onChange 不再回调)
|
||||
if (fileInputRef.value) fileInputRef.value.value = ''
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
async function onFilePicked(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
if (busy.value) return
|
||||
busy.value = true
|
||||
try {
|
||||
const text = await readFileAsText(file)
|
||||
const parsed = parseJsonLoose(text)
|
||||
if (!parsed || !isSettingsBackup(parsed)) {
|
||||
props.toast(t('settings.importInvalidJson'), 'error')
|
||||
return
|
||||
}
|
||||
|
||||
// 覆盖确认(复用壳层 confirmState 弹层;用户取消则放弃)
|
||||
const ok = await props.confirmDialog(t('settings.importConfirm'), '')
|
||||
if (!ok) return
|
||||
|
||||
// 逐 KV 写回 appSettings(经 store.set 同时更新缓存与 debounced 落库)
|
||||
const kv = parsed.appSettings ?? {}
|
||||
for (const [k, v] of Object.entries(kv)) {
|
||||
await appSettings.set(k, v)
|
||||
}
|
||||
// 知识库配置写回(独立 IPC)
|
||||
if (parsed.knowledge) {
|
||||
await knowledgeApi.saveConfig(parsed.knowledge)
|
||||
}
|
||||
|
||||
props.toast(t('settings.importSuccess'), 'info')
|
||||
} catch (e) {
|
||||
props.toast(t('settings.importFail', { msg: errMsg(e) }), 'error')
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 工具
|
||||
// ============================================================
|
||||
|
||||
/** FileReader 读文本(替代 file.text() 以兼容更早 webview) */
|
||||
function readFileAsText(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(String(reader.result ?? ''))
|
||||
reader.onerror = () => reject(reader.error ?? new Error('read error'))
|
||||
reader.readAsText(file)
|
||||
})
|
||||
}
|
||||
|
||||
/** 宽松 JSON.parse:失败返回 null(调用方据此报「格式不符」) */
|
||||
function parseJsonLoose(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** 结构校验:必须含 metadata.scope === 'appSettings+knowledge' 且 version 为数 */
|
||||
function isSettingsBackup(obj: unknown): obj is SettingsBackup {
|
||||
if (typeof obj !== 'object' || obj === null) return false
|
||||
const o = obj as Record<string, unknown>
|
||||
const meta = o.metadata as Record<string, unknown> | undefined
|
||||
if (!meta || meta.scope !== 'appSettings+knowledge') return false
|
||||
if (typeof meta.version !== 'number') return false
|
||||
// appSettings 必须为对象(允许空对象);knowledge 允许 null 或对象
|
||||
if (typeof o.appSettings !== 'object' || o.appSettings === null) return false
|
||||
return true
|
||||
}
|
||||
|
||||
/** 提取错误信息文案(兜底 Error.message 或 String(e)) */
|
||||
function errMsg(e: unknown): string {
|
||||
if (e instanceof Error) return e.message
|
||||
return String(e)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 导入导出按钮组:横向排列,复用全局 .btn/.btn-ghost/.btn-sm */
|
||||
.settings-io {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
/* 隐藏文件选择器(仅以编程方式 click 触发) */
|
||||
.file-input {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -37,11 +37,6 @@
|
||||
<span class="nav-label">{{ $t(cat.labelKey) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- 底部导入导出占位(阶段6 实现) -->
|
||||
<div class="nav-footer-slot">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
@@ -251,8 +246,6 @@ defineExpose({
|
||||
.nav-icon { font-size: 16px; line-height: 1; }
|
||||
.nav-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.nav-footer-slot { flex-shrink: 0; margin-top: auto; }
|
||||
|
||||
/* ===== 窄屏响应式:nav 折叠为横向 tab(@media 由父 Settings.vue 控制,
|
||||
此处仅保证横向模式下 nav-item 排列正确)。窄屏不显搜索框(横向空间不足,
|
||||
搜索改为后续可扩展) ===== */
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
//! 模块级私有:
|
||||
//! - _pendingApprovalIds:审批按钮防抖守卫(同 useAiSend 原实现)
|
||||
|
||||
import { ref } from 'vue'
|
||||
import { aiApi } from '@/api'
|
||||
import { state } from '@/stores/ai'
|
||||
import { t } from '@/i18n/i18n-helpers'
|
||||
@@ -73,10 +74,13 @@ async function approveToolCall(toolCallId: string, approved: boolean, decision?:
|
||||
// IPC 成功返回 = 后端已处理(执行工具/拒绝/写入白名单 + emit 事件 + try_continue)。
|
||||
// 乐观更新 tc.status,不依赖后端事件来复位 loading(后端事件可能在 IPC 返回前后任意时刻到达,
|
||||
// 且 findToolCall 若因时序找不到 tc 会致 loading 永不复位)。
|
||||
// approve/authorize 通过 → running(后端正在执行工具);deny/reject → rejected(后端已记审计)。
|
||||
// CR: 批审批场景下后端可能已完成工具执行并 emit AiToolCallCompleted,此时 tc.status 已为
|
||||
// 'completed',乐观更新不能回退——故仅在 status!=='completed' 时覆写。
|
||||
if (tc) {
|
||||
const isDeny = isPathKind ? (decision === 'deny') : !approved
|
||||
tc.status = isDeny ? 'rejected' : 'running'
|
||||
if (tc.status !== 'completed') {
|
||||
tc.status = isDeny ? 'rejected' : 'running'
|
||||
}
|
||||
if (isDeny) {
|
||||
tc.result = t('ai.aiTool.rejectedHint')
|
||||
}
|
||||
@@ -102,6 +106,7 @@ async function approveToolCall(toolCallId: string, approved: boolean, decision?:
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 批量审批:遍历 pendingApprovals 逐个按 kind 分派调用 approveToolCall(决策:批量逐个,
|
||||
* 非目录聚合——path 类每条独立决策,不合并同目录)。
|
||||
@@ -113,9 +118,14 @@ async function approveToolCall(toolCallId: string, approved: boolean, decision?:
|
||||
* - decision='reject':risk 类 approved=false;path 类 decision='deny'。
|
||||
* 单条失败不中断批量(已由 approveToolCall 内部回滚该条状态)。
|
||||
*/
|
||||
const _batchProcessing = ref(false)
|
||||
async function batchApprove(decision: 'approve' | 'reject') {
|
||||
if (_batchProcessing.value) return // 去重:批量进行中禁重复点击
|
||||
_batchProcessing.value = true
|
||||
// 快照当前待审批列表(遍历中 state.pendingApprovals 会因事件回调而缩短)
|
||||
const items = [...state.pendingApprovals]
|
||||
let successCount = 0
|
||||
let failCount = 0
|
||||
for (const p of items) {
|
||||
try {
|
||||
if (p.kind === 'path') {
|
||||
@@ -125,11 +135,23 @@ async function batchApprove(decision: 'approve' | 'reject') {
|
||||
// risk 类:approve/reject boolean
|
||||
await approveToolCall(p.id, decision === 'approve')
|
||||
}
|
||||
successCount++
|
||||
} catch {
|
||||
// 单条失败不中断批量操作(已由 approveToolCall 内部回滚该条状态)
|
||||
failCount++
|
||||
// 继续处理剩余项
|
||||
}
|
||||
}
|
||||
_batchProcessing.value = false
|
||||
// 若有失败项,console.warn 诊断(用户可打开 DevTools 查看具体失败项)
|
||||
if (failCount > 0) {
|
||||
console.warn(`[AI] 批量审批完成: ${successCount} 成功, ${failCount} 失败`)
|
||||
}
|
||||
}
|
||||
|
||||
/** 批量处理中(供按钮禁用 + spinner 展示) */
|
||||
export function isBatchProcessing(): boolean {
|
||||
return _batchProcessing.value
|
||||
}
|
||||
|
||||
export function useAiApproval() {
|
||||
|
||||
@@ -42,6 +42,9 @@ let _unlistenApprovalClear: (() => void) | null = null
|
||||
// (两回调写同一 state.currentText → 流式文字双倍)
|
||||
let _startPromise: Promise<void> | null = null
|
||||
|
||||
// 上一次 AiTextDelta 的内容(单窗口内 LLM 重复 delta 防御用,见 handleStreamingEvent)
|
||||
let _lastDelta = ''
|
||||
|
||||
const appSettings = useAppSettingsStore()
|
||||
|
||||
// B-260616-17: 看门狗不重置的事件集合(审批等待/完成/错误由各自 case 内 clear)。
|
||||
@@ -205,6 +208,7 @@ export function flushCurrentText() {
|
||||
// 自清把 flush 语义收敛为"回填并归零",消除对调用方清空顺序的依赖。各调用方后续的
|
||||
// currentText='' 对已清空值幂等,无副作用。
|
||||
state.currentText = ''
|
||||
_lastDelta = '' // 同步重置 delta 跟踪,避免新轮首个 delta 误命中上一轮末 delta 重复检测
|
||||
}
|
||||
|
||||
/** token 用量展示开关(读 appSettings,与 Settings.vue 共享 key `df-show-token-usage`) */
|
||||
@@ -274,9 +278,28 @@ function removePendingDirAuth(id: string): void {
|
||||
/** streaming 域:流式文本累积/新轮/心跳/重试/max 轮暂停 */
|
||||
function handleStreamingEvent(event: AiChatEvent): boolean {
|
||||
switch (event.type) {
|
||||
case 'AiTextDelta':
|
||||
case 'AiTextDelta': {
|
||||
// BUG-260624-01 / UX-260617-12 消息重叠防御:
|
||||
// 历史多次报「LLM 回复文字重复」(如「好好,,现在现在我对我对」单字双重叠加)。
|
||||
// 已排除的根因:后端 stream_recv.rs emit 增量 chunk.delta(非累积)、
|
||||
// startListener 幂等+_startPromise 并发去重、streamingBlocks 块级 memo。
|
||||
// 最可能残留路径:① 主窗口 AI 面板与 ai-detached 分离窗口同时打开,两 webview 独立 JS 上下文
|
||||
// 各自 listen 全局广播事件 → 各自 currentText += delta(用户感知「双倍」)。
|
||||
// 桌面双窗口场景下难以在单窗口内根治,需后端定向 emit_to(label) 重构。
|
||||
// ② LLM provider(GLM anthropic_compat 等)在特定情况下返重复 delta。
|
||||
// 防御:连续两次 delta 完全相同(且非空)时丢弃第二次 + console.warn,留下诊断证据。
|
||||
// 副作用极低:正常 LLM 流不会连发完全相同 delta(空格/单字符除外,已 len>1 守卫)。
|
||||
if (event.delta && _lastDelta === event.delta && event.delta.length > 1) {
|
||||
// df-ai-trace-delta 开关控制(默认开,生产可关)
|
||||
if (appSettings.get('df-ai-trace-delta', true)) {
|
||||
console.warn('[AiTextDelta] 重复 delta 已丢弃(疑似双窗口 listener 或 provider 异常):', JSON.stringify(event.delta))
|
||||
}
|
||||
return true
|
||||
}
|
||||
_lastDelta = event.delta
|
||||
state.currentText += event.delta
|
||||
return true
|
||||
}
|
||||
|
||||
case 'AiAgentRound': {
|
||||
// Agent 循环新一轮:保存当前文本到上一条 assistant 消息,新建空 assistant 消息
|
||||
|
||||
@@ -82,6 +82,7 @@ async function detachPanel(convId?: string) {
|
||||
minHeight: 400,
|
||||
center: true,
|
||||
decorations: true,
|
||||
alwaysOnTop: true, // 分离窗口默认置顶(AI 助手常态:用户在主窗口工作时希望 AI 辅助窗口可见)
|
||||
})
|
||||
win.once('tauri://created', () => {
|
||||
state.panelOpen = false
|
||||
@@ -258,10 +259,22 @@ const AI_DOCK_WIDTH = 600
|
||||
const AI_DOCK_GAP = 4
|
||||
// 防 syncToMain 改主窗口 size 触发 onResized 递归回调
|
||||
let _isSyncing = false
|
||||
// 拖动/缩放防抖:用户拖动主窗口期间 onMoved/onResized 高频触发,
|
||||
// 直接 sync 会让 setSize/setPosition 排队 → AI 窗口抖动 + 性能浪费。
|
||||
// 防后 150ms 才执行,用户停下才同步(手感顺 + 不会重重复复中中间态同步)。
|
||||
let _syncTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const SYNC_DEBOUNCE_MS = 150
|
||||
|
||||
/** 同步 AI 窗口位置到主窗口右侧(保留 GAP 间隙)。
|
||||
* U-260618: 主窗口最大化或主+AI 超出屏幕时,取消最大化 + 缩小主窗口宽度,
|
||||
* 让主窗口与 AI 窗口都进入屏幕(原:主窗口最大化时 AI 被挤到屏幕外)。 */
|
||||
/** 同步 AI 窗口位置到主窗口右侧(保留 GAP 间隙)。
|
||||
*
|
||||
* 设计原则(本次优化重点):
|
||||
* 1. 不反向改主窗口 size——原实现在拖动主窗口时 setSize 主窗口,使主窗口在拖动过程中自己也在变,
|
||||
* 拖动体验极差。现改为:主窗口是真相源,AI 窗口跟随到主窗口右侧;若右侧空间不够,AI 窗口本身让位
|
||||
* (往左偏移或缩宽),不动主窗口。
|
||||
* 2. 不动主窗口最大化状态——原实现强制 unmaximize 主窗口,破坏用户最大化习惯。
|
||||
* 现改为:主窗口最大化时仅同步高度,AI 窗口贴右侧屏幕边显示。
|
||||
* 3. 仅同步位置与高度(跟随主窗口高度),宽度保持 AI_DOCK_WIDTH 不变(避免宽度反复跳)。
|
||||
*/
|
||||
async function syncToMain() {
|
||||
if (_isSyncing) return
|
||||
_isSyncing = true
|
||||
@@ -276,28 +289,32 @@ async function syncToMain() {
|
||||
const monitor = await currentMonitor()
|
||||
const screenWidth = monitor?.size.width ?? 0
|
||||
|
||||
// 取消主窗口最大化(吸附需主窗口让出右侧空间给 AI 窗口)
|
||||
if (await mainWin.isMaximized()) {
|
||||
await mainWin.unmaximize()
|
||||
await new Promise(r => setTimeout(r, 80)) // 等 unmaximize UI 落定再读尺寸
|
||||
}
|
||||
const pos = await mainWin.outerPosition()
|
||||
const size = await mainWin.innerSize()
|
||||
const maximized = await mainWin.isMaximized()
|
||||
|
||||
let pos = await mainWin.outerPosition()
|
||||
let size = await mainWin.innerSize()
|
||||
// 计算目标位置(默认贴主窗口右侧 + GAP)
|
||||
let targetX = pos.x + size.width + AI_DOCK_GAP
|
||||
let targetY = pos.y
|
||||
let targetHeight = size.height
|
||||
let targetWidth = AI_DOCK_WIDTH
|
||||
|
||||
// 主+AI 超屏幕宽度 → 强制缩小主窗口让 AI 进屏幕
|
||||
if (screenWidth > 0 && pos.x + size.width + AI_DOCK_GAP + AI_DOCK_WIDTH > screenWidth) {
|
||||
const newMainWidth = screenWidth - AI_DOCK_GAP - AI_DOCK_WIDTH - pos.x
|
||||
if (newMainWidth >= 400) {
|
||||
await mainWin.setSize(new PhysicalSize(newMainWidth, size.height))
|
||||
await new Promise(r => setTimeout(r, 50)) // 等 setSize UI 落定
|
||||
pos = await mainWin.outerPosition()
|
||||
size = await mainWin.innerSize()
|
||||
if (maximized) {
|
||||
// 主窗口最大化时:不动主窗口。AI 贴屏幕右侧(主窗口全屏时右侧没空间,AI 覆盖在主窗口上)。
|
||||
// 保持 AI 默认高度不变(跟随屏幕高度易越界),高度不动。
|
||||
if (screenWidth > 0) {
|
||||
targetX = screenWidth - AI_DOCK_WIDTH - AI_DOCK_GAP
|
||||
}
|
||||
targetHeight = size.height > targetHeight ? targetHeight : size.height
|
||||
} else if (screenWidth > 0 && targetX + AI_DOCK_WIDTH > screenWidth) {
|
||||
// 非最大化但主+AI 超屏幕:AI 窗口本身让位(缩窄或往左偏),不动主窗口。
|
||||
// 优先策略:贴屏幕右侧边,AI 窗口宽度可能被主窗口右边缘掩盖一部分,但不强迫主窗口缩小。
|
||||
// 若主窗口右侧几乎贴边(targetX < pos.x + 100,主窗口已占满宽度),AI 覆盖在主窗口右侧上方(置顶态自然可见)。
|
||||
targetX = Math.max(pos.x + 100, screenWidth - AI_DOCK_WIDTH - AI_DOCK_GAP)
|
||||
}
|
||||
|
||||
await aiWin.setPosition(new PhysicalPosition(pos.x + size.width + AI_DOCK_GAP, pos.y))
|
||||
await aiWin.setSize(new PhysicalSize(AI_DOCK_WIDTH, size.height))
|
||||
await aiWin.setPosition(new PhysicalPosition(targetX, targetY))
|
||||
await aiWin.setSize(new PhysicalSize(targetWidth, targetHeight))
|
||||
} catch (e) {
|
||||
console.error('[AI] syncToMain 失败:', e)
|
||||
} finally {
|
||||
@@ -305,18 +322,28 @@ async function syncToMain() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 启动主窗口 move/resize 跟随(吸附时调用) */
|
||||
/** 同步防抖包装:拖动/缩放高频事件不走立即同步,防 150ms 后执行。 */
|
||||
function scheduleSync() {
|
||||
if (_syncTimer) clearTimeout(_syncTimer)
|
||||
_syncTimer = setTimeout(() => {
|
||||
_syncTimer = null
|
||||
void syncToMain()
|
||||
}, SYNC_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
/** 启动主窗口 move/resize 跟随(吸附时调用,防后 150ms 同步防抖动) */
|
||||
async function startFollowMain() {
|
||||
stopFollowMain()
|
||||
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||
const mainWin = await WebviewWindow.getByLabel('main')
|
||||
if (!mainWin) return
|
||||
_unlistenMove = await mainWin.onMoved(() => { void syncToMain() })
|
||||
_unlistenResize = await mainWin.onResized(() => { void syncToMain() })
|
||||
_unlistenMove = await mainWin.onMoved(() => { scheduleSync() })
|
||||
_unlistenResize = await mainWin.onResized(() => { scheduleSync() })
|
||||
}
|
||||
|
||||
/** 停止主窗口跟随 */
|
||||
/** 停止主窗口跟随 + 清防抖计时器 */
|
||||
function stopFollowMain() {
|
||||
if (_syncTimer) { clearTimeout(_syncTimer); _syncTimer = null }
|
||||
_unlistenMove?.()
|
||||
_unlistenResize?.()
|
||||
_unlistenMove = null
|
||||
|
||||
@@ -166,9 +166,7 @@ export function useRendered(getText: () => string): {
|
||||
return { rendered, ensureLoaded }
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
import { escapeHtml } from '@/utils/html'
|
||||
|
||||
/// marked 未就绪时的兜底渲染:HTML 转义 + 换行转 <br>(纯文本安全降级)
|
||||
function escapeFallback(text: string): string {
|
||||
@@ -217,7 +215,7 @@ const _DIFF_LINE_RE = /^[ \t]*[+-][^\n]*$/ // 行首可选空格/tab + +/- + 任
|
||||
const _PLUS_LINE_RE = /^[ \t]*\+[^\n]*$/ // 含 + 的行(+ 必须含的判定)
|
||||
const _FENCE_OPEN_RE = /^[ \t]{0,3}(```|~~~)/ // marked 围栏开头(行首 ≤3 空格 + 3+ 反引号/波浪)
|
||||
|
||||
export function wrapNakedDiff(text: string): string {
|
||||
function wrapNakedDiff(text: string): string {
|
||||
if (!text) return text
|
||||
// 快速短路:全文无 +/- 行首特征直接返回原文(避免大文本白跑切段)
|
||||
if (!/[+-]/.test(text)) return text
|
||||
|
||||
@@ -12,10 +12,10 @@
|
||||
* 注:带 seq 竞态守卫的场景调用方需在 fn 内部判断,不在本工具职责范围。
|
||||
*/
|
||||
|
||||
import type { Ref } from 'vue'
|
||||
// (Ref import 已移除,_Unused 类型已删除)
|
||||
|
||||
/** 带 error 字段的 store state 形状(仅约束 error,其他字段任意)。 */
|
||||
export interface ErrorSink {
|
||||
/** 带 error 字段的 store state 形状(仅约束 error,其他字段任意)。仅本模块内部使用。 */
|
||||
interface ErrorSink {
|
||||
error: string | null
|
||||
}
|
||||
|
||||
@@ -40,12 +40,8 @@ export async function runWithCatch<T>(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 unknown 错误转为可读字符串(无则返 undefined,由调用方兜底 i18n)。
|
||||
*
|
||||
* Tauri invoke 抛出的通常是 string 或 Error 实例。
|
||||
*/
|
||||
export function stringifyError(e: unknown): string | undefined {
|
||||
// stringifyError 仅本模块内部使用(runWithCatch/runWithCatchGuarded 调用),不导出。
|
||||
function stringifyError(e: unknown): string | undefined {
|
||||
if (typeof e === 'string') return e
|
||||
if (e instanceof Error) return e.message || e.toString()
|
||||
if (e && typeof e === 'object' && 'toString' in e) {
|
||||
@@ -81,5 +77,4 @@ export async function runWithCatchGuarded<T>(
|
||||
}
|
||||
}
|
||||
|
||||
// 显式标记 Ref 未使用(避免未来用上时改导入)
|
||||
export type _Unused = Ref<unknown>
|
||||
// _Unused 类型从未被外部 import,已删除(同时移除上方 Ref import)。
|
||||
|
||||
59
src/composables/useToast.ts
Normal file
59
src/composables/useToast.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 轻量 toast 提示 composable — 消除 AiChat / Settings / Projects / TaskDetail 4 处重复。
|
||||
*
|
||||
* 原 4 处各自 reactive/ref + _toastTimer + showToast + onUnmounted 清理,
|
||||
* 且 Projects 用 4000ms 其余 3000ms(体验不一致 bug)。
|
||||
* 本 composable 统一默认 3000ms,支持调用方按需传 durationMs。
|
||||
*
|
||||
* 用法:
|
||||
* const { toast, showToast } = useToast()
|
||||
* showToast('保存成功')
|
||||
* showToast('导入失败', 'error', 4000)
|
||||
* // template: <div v-if="toast.visible" class="toast ...">{{ toast.msg }}</div>
|
||||
*/
|
||||
import { reactive, onUnmounted } from 'vue'
|
||||
|
||||
export type ToastType = 'info' | 'error' | 'warning'
|
||||
|
||||
export interface ToastState {
|
||||
visible: boolean
|
||||
msg: string
|
||||
type: ToastType
|
||||
}
|
||||
|
||||
let _timer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
export function useToast(defaultDurationMs = 3000) {
|
||||
const toast = reactive<ToastState>({
|
||||
visible: false,
|
||||
msg: '',
|
||||
type: 'info',
|
||||
})
|
||||
|
||||
function showToast(msg: string, type: ToastType = 'info', durationMs?: number) {
|
||||
toast.msg = msg
|
||||
toast.type = type
|
||||
toast.visible = true
|
||||
if (_timer) clearTimeout(_timer)
|
||||
_timer = setTimeout(() => {
|
||||
toast.visible = false
|
||||
}, durationMs ?? defaultDurationMs)
|
||||
}
|
||||
|
||||
function hideToast() {
|
||||
if (_timer) {
|
||||
clearTimeout(_timer)
|
||||
_timer = null
|
||||
}
|
||||
toast.visible = false
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (_timer) {
|
||||
clearTimeout(_timer)
|
||||
_timer = null
|
||||
}
|
||||
})
|
||||
|
||||
return { toast, showToast, hideToast }
|
||||
}
|
||||
@@ -48,6 +48,10 @@ export default {
|
||||
unpin: 'Unpin',
|
||||
closeWindow: 'Close window',
|
||||
|
||||
// ── Approval overlay (global top-right) ──
|
||||
approvalWaitingTitle: 'Approval required',
|
||||
approvalWaitingDesc: 'Click to review and handle',
|
||||
|
||||
// ── Provider status ──
|
||||
clickToSwitchProvider: 'Click to switch provider',
|
||||
providerNotConfigured: 'No AI provider configured. Please add one in settings.',
|
||||
|
||||
@@ -6,6 +6,12 @@ export default {
|
||||
loading: 'Loading…',
|
||||
empty: 'No audit records',
|
||||
|
||||
filter: {
|
||||
allStatus: 'All status',
|
||||
allRisk: 'All risk',
|
||||
toolPlaceholder: 'Search tool name…',
|
||||
},
|
||||
|
||||
col: {
|
||||
time: 'Requested',
|
||||
tool: 'Tool',
|
||||
|
||||
@@ -14,6 +14,8 @@ export default {
|
||||
activeProjects: 'Active Projects',
|
||||
viewAll: 'View all',
|
||||
ideaPool: 'Idea Pool',
|
||||
goProjectDetail: 'View project details',
|
||||
goIdeaDetail: 'View idea details',
|
||||
recentDecisions: 'Recent Decisions',
|
||||
openAnnotations: 'Open Annotations',
|
||||
aiBatchProcess: 'AI Batch Process',
|
||||
@@ -39,6 +41,7 @@ export default {
|
||||
stage: {
|
||||
planning: 'Planning',
|
||||
coding: 'Coding',
|
||||
release: 'Release',
|
||||
done: 'Done',
|
||||
},
|
||||
ideaStatus: {
|
||||
|
||||
@@ -91,8 +91,6 @@ export default {
|
||||
relatedCancel: 'Cancel',
|
||||
|
||||
// Idea stats panel (used by IdeasPanel)
|
||||
statsTitle: '📈 Overview',
|
||||
statsTotal: 'Total',
|
||||
statsPending: 'Pending',
|
||||
statsPromoted: 'Promoted',
|
||||
statsAvgScore: 'Avg score',
|
||||
|
||||
@@ -11,5 +11,7 @@ export default {
|
||||
traceability: 'Traceability',
|
||||
systemReady: 'System Ready',
|
||||
aiPanel: 'AI Panel',
|
||||
collapse: 'Collapse sidebar',
|
||||
expand: 'Expand sidebar',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -48,6 +48,10 @@ export default {
|
||||
unpin: '取消置顶',
|
||||
closeWindow: '关闭窗口',
|
||||
|
||||
// ── 审批浮层(全局右上角)──
|
||||
approvalWaitingTitle: '有待审批请求',
|
||||
approvalWaitingDesc: '点击查看并处理',
|
||||
|
||||
// ── Provider 状态 ──
|
||||
clickToSwitchProvider: '点击切换 Provider',
|
||||
providerNotConfigured: '未配置 AI 提供商,请在设置中添加',
|
||||
|
||||
@@ -6,6 +6,12 @@ export default {
|
||||
loading: '加载中…',
|
||||
empty: '暂无审计记录',
|
||||
|
||||
filter: {
|
||||
allStatus: '全部状态',
|
||||
allRisk: '全部风险',
|
||||
toolPlaceholder: '搜索工具名…',
|
||||
},
|
||||
|
||||
col: {
|
||||
time: '请求时间',
|
||||
tool: '工具',
|
||||
|
||||
@@ -14,6 +14,8 @@ export default {
|
||||
activeProjects: '活跃项目',
|
||||
viewAll: '查看全部',
|
||||
ideaPool: '灵感',
|
||||
goProjectDetail: '查看项目详情',
|
||||
goIdeaDetail: '查看灵感详情',
|
||||
recentDecisions: '最近决策',
|
||||
openAnnotations: '待处理标注',
|
||||
aiBatchProcess: 'AI 批量处理',
|
||||
@@ -39,6 +41,7 @@ export default {
|
||||
stage: {
|
||||
planning: '规划中',
|
||||
coding: '编码中',
|
||||
release: '发布中',
|
||||
done: '已完成',
|
||||
},
|
||||
ideaStatus: {
|
||||
|
||||
@@ -82,8 +82,6 @@ export default {
|
||||
relatedCancel: '取消',
|
||||
|
||||
// 灵感统计看板(IdeasPanel 引用)
|
||||
statsTitle: '📈 灵感概览',
|
||||
statsTotal: '总数',
|
||||
statsPending: '待评估',
|
||||
statsPromoted: '已立项',
|
||||
statsAvgScore: '平均分',
|
||||
|
||||
@@ -11,5 +11,7 @@ export default {
|
||||
traceability: '追溯',
|
||||
systemReady: '系统就绪',
|
||||
aiPanel: 'AI 面板',
|
||||
collapse: '收起侧边栏',
|
||||
expand: '展开侧边栏',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { reactive, computed } from 'vue'
|
||||
import { knowledgeApi } from '@/api'
|
||||
import { t } from '@/i18n/i18n-helpers'
|
||||
import { runWithCatch, runWithCatchGuarded } from '@/composables/useStoreAction'
|
||||
import { parseJsonArray } from '@/utils/json'
|
||||
import type {
|
||||
KnowledgeRecord,
|
||||
KnowledgeDetailPayload,
|
||||
@@ -46,14 +47,8 @@ export const KNOWLEDGE_KINDS = [
|
||||
] as const
|
||||
|
||||
/** 解析 tags JSON 字符串为 string[] */
|
||||
export function parseTags(tags: string | null): string[] {
|
||||
if (!tags) return []
|
||||
try {
|
||||
const arr = JSON.parse(tags)
|
||||
return Array.isArray(arr) ? arr : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
export function parseTags(tags: string | null | undefined): string[] {
|
||||
return parseJsonArray(tags)
|
||||
}
|
||||
|
||||
// ── 知识 label 文案辅助(列表 Knowledge.vue 与详情 KnowledgeDetail.vue 共用) ──
|
||||
|
||||
@@ -18,8 +18,100 @@
|
||||
← ProjectDetail.vue / TaskDetail.vue / TaskOutputCard.vue 三处复制
|
||||
- .panel / .panel-header ← ProjectDetail.vue / TaskDetail.vue 两处复制
|
||||
- .tech-tag / .card-tags ← ProjectCard.vue / Projects.vue 两处复制
|
||||
- .error-banner / .error-text / .error-dismiss ← Dashboard.vue / Knowledge.vue 两处复制
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
/* — Error Banner:页面级错误提示条(danger 底 + 关闭按钮) —
|
||||
原 Dashboard.vue / Knowledge.vue <style scoped> 两处逐字一致。
|
||||
margin-bottom 由各页自定义(全局不设,避免覆盖页面间距变量)。 */
|
||||
.error-banner {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||
padding: 8px 12px;
|
||||
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; }
|
||||
|
||||
/* — 灵感状态标签:ideas 列表(Ideas.vue) + 灵感详情(IdeaDetail.vue) 共享 —
|
||||
原 Ideas.vue / IdeaDetail.vue <style scoped> 两处逐字一致(灵感专属 5 态色系,
|
||||
与 Tasks/AuditLog 的 status-tag 语义不同,独立提取避免混淆)。 */
|
||||
.status-tag {
|
||||
font-size: 11px; font-weight: 500; padding: 2px 8px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
}
|
||||
.status-draft { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
|
||||
.status-pending_review { background: rgba(100,181,246,0.2); color: var(--df-info); }
|
||||
.status-approved { background: rgba(100,255,218,0.15); color: var(--df-success); }
|
||||
.status-promoted { background: rgba(108,99,255,0.2); color: var(--df-accent); }
|
||||
.status-rejected { background: rgba(255,107,107,0.2); color: var(--df-danger); }
|
||||
|
||||
/* — 多维评分条:IdeaDetail.vue + ProjectDetail.vue 共享 —
|
||||
原 IdeaDetail.vue L770-808 / ProjectDetail.vue L678-692 两处逐字一致
|
||||
(ProjectDetail 注释自承认"复用 IdeaDetail score-bar-* 样式定义")。
|
||||
差异:score-bar-label min-width 72px(IdeaDetail) vs 64px(ProjectDetail),
|
||||
取 72px 保证较长维度名不换行;row gap 12px(IdeaDetail) vs 10px(ProjectDetail),取 12px 更透气。 */
|
||||
.score-bar-chart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.score-bar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.score-bar-label {
|
||||
font-size: 12px;
|
||||
color: var(--df-text-secondary);
|
||||
min-width: 72px;
|
||||
text-align: right;
|
||||
}
|
||||
.score-bar-track {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background: var(--df-border);
|
||||
border-radius: var(--df-radius-xs);
|
||||
overflow: hidden;
|
||||
}
|
||||
.score-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: var(--df-radius-xs);
|
||||
transition: width 0.4s;
|
||||
}
|
||||
.fill-high { background: var(--df-success); }
|
||||
.fill-mid { background: var(--df-warning); }
|
||||
.fill-low { background: var(--df-danger); }
|
||||
.score-bar-value {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
min-width: 28px;
|
||||
text-align: right;
|
||||
color: var(--df-text);
|
||||
}
|
||||
|
||||
/* — 评估徽章(assessment badge):IdeaDetail.vue + ProjectDetail.vue 共享 —
|
||||
recommendation → CSS 颜色类映射(对应 .immediate/.soon/.conditional/.revised/.defer/.cancel)。
|
||||
原 IdeaDetail.vue L589-606 / ProjectDetail.vue L657-671 两处逐字重复。 */
|
||||
.assessment-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.assessment-badge.immediate { background: var(--df-success-bg); color: var(--df-success); }
|
||||
.assessment-badge.soon { background: var(--df-info-bg); color: var(--df-info); }
|
||||
.assessment-badge.conditional { background: var(--df-warning-bg); color: var(--df-warning); }
|
||||
.assessment-badge.revised { background: var(--df-warning-bg); color: var(--df-warning); }
|
||||
.assessment-badge.defer { background: var(--df-danger-bg); color: var(--df-danger); }
|
||||
.assessment-badge.cancel { background: var(--df-danger-bg); color: var(--df-danger); }
|
||||
|
||||
/* — Info Item:字段同行布局(label 固定宽 + 值占余),描述字段 info-block 保持块状 —
|
||||
原 ProjectDetail.vue L439-456 / TaskDetail.vue L560-577 /
|
||||
TaskOutputCard.vue L93-110 三处逐字一致(后者注释说明 scoped 不渗透故复制)。 */
|
||||
@@ -78,6 +170,157 @@
|
||||
border: 0.5px solid var(--df-border);
|
||||
}
|
||||
|
||||
/* — Dashboard 面板基类:ActiveProjectsPanel / IdeasPanel 共享 —
|
||||
原 Dashboard.vue <style scoped> 定义,但 Vue scoped 不渗透到子组件根元素,
|
||||
导致两个子面板的 .df-panel/.df-panel-head/.df-panel-title/.df-link 实际裸奔
|
||||
(无背景/边框/padding/链接色),暗色主题下视觉割裂。提取全局根治。
|
||||
落地批:Dashboard 视觉一致性修复 */
|
||||
.df-panel {
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-md);
|
||||
padding: 14px 16px;
|
||||
animation: fadeInUp 0.5s var(--df-ease) both;
|
||||
}
|
||||
.df-panel-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.df-panel-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--df-text);
|
||||
letter-spacing: -0.1px;
|
||||
}
|
||||
.df-link {
|
||||
font-size: 11px;
|
||||
color: var(--df-accent);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.15s;
|
||||
cursor: pointer;
|
||||
}
|
||||
.df-link:hover { opacity: 1; text-decoration: underline; }
|
||||
|
||||
/* — AI 技能联想浮层 / chip:SkillMention.vue + MentionPopover.vue 共享 —
|
||||
原 ChatInput.vue <style scoped> 定义,但实际使用方是子组件 SkillMention.vue
|
||||
(无自带 style 块)和 MentionPopover.vue(自带 scoped 副本)。ChatInput.vue
|
||||
自身 template 未使用这些类,scoped 定义为死代码且不渗透子组件。
|
||||
提取全局根治,消除三份重复。 */
|
||||
.ai-skill-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
padding: 3px 8px;
|
||||
background: color-mix(in srgb, var(--df-accent) 16%, transparent);
|
||||
border: 0.5px solid color-mix(in srgb, var(--df-accent) 40%, transparent);
|
||||
border-radius: var(--df-radius);
|
||||
font-size: 12px;
|
||||
}
|
||||
.ai-skill-chip-name { color: var(--df-accent); font-weight: 600; flex-shrink: 0; }
|
||||
.ai-skill-chip-desc { color: var(--df-text-dim); flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ai-skill-chip-main { display: flex; align-items: center; gap: 6px; flex: 1; min-width: 0; flex-wrap: wrap; }
|
||||
.ai-skill-chip-hint {
|
||||
color: var(--df-text-dim);
|
||||
font-family: var(--df-font-mono, ui-monospace, monospace);
|
||||
font-size: 11px;
|
||||
background: color-mix(in srgb, var(--df-text-dim) 14%, transparent);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.ai-skill-chip-x { background: none; border: none; color: var(--df-text-dim); cursor: pointer; font-size: 14px; line-height: 1; padding: 0 2px; flex-shrink: 0; }
|
||||
.ai-skill-chip-x:hover { color: var(--df-text); }
|
||||
|
||||
.ai-skill-popover {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
z-index: 20;
|
||||
}
|
||||
.ai-skill-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
.ai-skill-item-row { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.ai-skill-item--active,
|
||||
.ai-skill-item:hover {
|
||||
background: color-mix(in srgb, var(--df-accent) 14%, transparent);
|
||||
}
|
||||
.ai-skill-item-name { color: var(--df-accent); font-weight: 600; min-width: 90px; }
|
||||
.ai-skill-item-desc { color: var(--df-text-dim); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ai-skill-item-src { color: var(--df-text-dim); opacity: 0.6; font-size: 10px; text-transform: uppercase; }
|
||||
.ai-skill-item-hint {
|
||||
color: var(--df-text-dim);
|
||||
font-family: var(--df-font-mono, ui-monospace, monospace);
|
||||
font-size: 11px;
|
||||
opacity: 0.85;
|
||||
word-break: break-all;
|
||||
}
|
||||
.ai-skill-empty { padding: 8px 12px; color: var(--df-text-dim); opacity: 0.7; font-size: 12px; }
|
||||
|
||||
/* — AI 图片预览:ImageInput.vue 专用(原 ChatInput.vue scoped 死代码,子组件拿不到) — */
|
||||
.ai-img-preview-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 0.5px solid var(--df-border);
|
||||
}
|
||||
.ai-img-preview {
|
||||
position: relative;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: var(--df-radius);
|
||||
overflow: hidden;
|
||||
border: 0.5px solid var(--df-border);
|
||||
background: var(--df-bg);
|
||||
}
|
||||
.ai-img-preview-thumb {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.ai-img-preview-x {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--df-bg) 80%, transparent);
|
||||
color: var(--df-text-dim);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.ai-img-preview-x:hover {
|
||||
background: var(--df-danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* — AiChat 图标按钮(共享工具类):TopBar header-actions + ConversationSidebar 新建按钮 —
|
||||
原 AiChat.vue <style scoped> L1039-1072 抽取(非 scoped 全局,供两个 ai/ 子组件共享)。
|
||||
复用原因:跨 TopBar + ConversationSidebar,无法归属单一 scoped;
|
||||
@@ -123,4 +366,3 @@
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
|
||||
@@ -193,7 +193,7 @@ html, body, #app {
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-lg);
|
||||
padding: 24px;
|
||||
min-width: 360px;
|
||||
min-width: min(360px, 90vw);
|
||||
max-width: 90vw;
|
||||
}
|
||||
.modal-title { font-size: 18px; font-weight: 500; color: var(--df-text); margin-bottom: 16px; }
|
||||
@@ -220,3 +220,43 @@ html, body, #app {
|
||||
/* — Empty State — */
|
||||
.empty-state { text-align: center; padding: 60px 20px; font-size: 14px; color: var(--df-text-dim); }
|
||||
.empty-hint { font-size: 13px; color: var(--df-text-dim); padding: 12px 0; }
|
||||
.empty-icon { font-size: 48px; margin-bottom: 12px; }
|
||||
|
||||
/* — Back Link(详情页返回链接)— ProjectDetail / TaskDetail 共享 */
|
||||
.back-link { font-size: 13px; color: var(--df-accent); text-decoration: none; white-space: nowrap; }
|
||||
.back-link:hover { text-decoration: underline; }
|
||||
|
||||
/* — Filter Bar(列表页筛选 + 搜索)— Ideas / Tasks 共享 */
|
||||
.filter-bar { display: flex; gap: 8px; margin-bottom: var(--df-gap-page); align-items: center; flex-wrap: wrap; }
|
||||
.filter-btn {
|
||||
padding: 6px 14px;
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--df-text-secondary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.filter-btn:hover { background: var(--df-bg-card); color: var(--df-text); }
|
||||
.filter-btn.active {
|
||||
background: var(--df-accent);
|
||||
color: #fff;
|
||||
border-color: var(--df-accent);
|
||||
}
|
||||
.search-box { flex: 1; max-width: 300px; }
|
||||
.search-box input {
|
||||
width: 100%;
|
||||
padding: 6px 12px;
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: var(--df-bg);
|
||||
color: var(--df-text);
|
||||
font-size: 13px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.search-box input:focus {
|
||||
outline: none;
|
||||
border-color: var(--df-accent);
|
||||
background: var(--df-bg-raised);
|
||||
}
|
||||
|
||||
8
src/utils/html.ts
Normal file
8
src/utils/html.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* HTML 转义 — 消除 useMarkdown / FilePreview 两处重复实现。
|
||||
*/
|
||||
|
||||
/** 转义 &, <, > 三个核心 HTML 特殊字符 */
|
||||
export function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
@@ -13,6 +13,22 @@ export interface ScoreDimension {
|
||||
score: number
|
||||
}
|
||||
|
||||
/** 评分三分档类型 */
|
||||
export type ScoreTier = 'high' | 'mid' | 'low'
|
||||
|
||||
/**
|
||||
* 评分三分档阈值统一(>=80 high / >=60 mid / else low)。
|
||||
* 原 Ideas.vue scoreClass / IdeasPanel.vue tier / IdeaDetail.vue 模板内联 /
|
||||
* ProjectDetail.vue 模板内联 4 处重复同一阈值逻辑,提取单一来源。
|
||||
* 阈值调整只需改这一处。
|
||||
*/
|
||||
export function scoreTier(score: number | null | undefined): ScoreTier {
|
||||
const s = score ?? 0
|
||||
if (s >= 80) return 'high'
|
||||
if (s >= 60) return 'mid'
|
||||
return 'low'
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 idea.scores(JSON 对象 {dim: score})为维度数组。
|
||||
* null / 空 / 非法 / 非对象 → []。非数值分维度记 0。
|
||||
|
||||
23
src/utils/json.ts
Normal file
23
src/utils/json.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* JSON 字符串数组解析 — 消除 parseStack / parseTags / ModuleNode 内联 3 处重复。
|
||||
*
|
||||
* 后端将 string[] 以 JSON.stringify 形式存于 stack/tags 等字段;
|
||||
* 解析失败或非数组时返回空数组,避免渲染崩溃。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 安全解析 JSON 字符串数组。
|
||||
* null / 空 / 非法 JSON / 非数组 → 返回 fallback(默认 [])。
|
||||
*/
|
||||
export function parseJsonArray<T = string>(
|
||||
raw: string | null | undefined,
|
||||
fallback: T[] = [],
|
||||
): T[] {
|
||||
if (!raw) return fallback
|
||||
try {
|
||||
const arr = JSON.parse(raw)
|
||||
return Array.isArray(arr) ? arr : fallback
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
//! parseStack 此前在 Projects.vue 与 ProjectDetail.vue 各有一份重复实现,
|
||||
//! 现抽到此处统一维护。
|
||||
|
||||
import { parseJsonArray } from './json'
|
||||
|
||||
/**
|
||||
* 解析技术栈 JSON 数组字符串(防崩)。
|
||||
*
|
||||
@@ -10,11 +12,5 @@
|
||||
* 解析失败或非数组时返回空数组,避免渲染崩溃。
|
||||
*/
|
||||
export function parseStack(stack: string | null | undefined): string[] {
|
||||
if (!stack) return []
|
||||
try {
|
||||
const arr = JSON.parse(stack)
|
||||
return Array.isArray(arr) ? arr : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
return parseJsonArray(stack)
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@ import { t, currentLocale } from '@/i18n/i18n-helpers'
|
||||
// CR-260615-08(P1-10):相对时间走 i18n(原硬编码中文,en locale 时间恒中文)。
|
||||
// 类型安全访问经 i18n-helpers 收口(规避 vue-i18n TS2589)。
|
||||
|
||||
/** 解析任意时间值为毫秒时间戳;无效或空返回 null */
|
||||
export function parseTs(v: string | number | null | undefined): number | null {
|
||||
/** 解析任意时间值为毫秒时间戳;无效或空返回 null(仅本模块内部使用) */
|
||||
function parseTs(v: string | number | null | undefined): number | null {
|
||||
if (v == null) return null
|
||||
if (typeof v === 'number') {
|
||||
if (!Number.isFinite(v)) return null
|
||||
|
||||
@@ -3,16 +3,36 @@
|
||||
<header class="page-header">
|
||||
<h1>{{ t('auditLog.title') }}</h1>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-ghost" @click="reload">{{ t('auditLog.refresh') }}</button>
|
||||
<button class="btn btn-ghost" @click="reload" :disabled="loading">
|
||||
<svg v-if="loading" class="audit-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12a9 9 0 11-6.219-8.56"/></svg>
|
||||
{{ t('auditLog.refresh') }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p class="page-desc">{{ t('auditLog.desc') }}</p>
|
||||
|
||||
<!-- 筛选栏 -->
|
||||
<div class="filter-bar audit-filters">
|
||||
<select v-model="statusFilter" class="audit-filter-select">
|
||||
<option value="">{{ t('auditLog.filter.allStatus') }}</option>
|
||||
<option v-for="s in statusKeys" :key="s" :value="s">{{ t(`auditLog.status.${s}`) }}</option>
|
||||
</select>
|
||||
<select v-model="riskFilter" class="audit-filter-select">
|
||||
<option value="">{{ t('auditLog.filter.allRisk') }}</option>
|
||||
<option v-for="r in riskKeys" :key="r" :value="r">{{ t(`auditLog.risk.${r}`) }}</option>
|
||||
</select>
|
||||
<div class="search-box audit-search">
|
||||
<input v-model="toolSearch" type="text" :placeholder="t('auditLog.filter.toolPlaceholder')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 状态条 -->
|
||||
<div v-if="loading" class="empty-state">{{ t('auditLog.loading') }}</div>
|
||||
<div v-else-if="errorMsg" class="empty-state">{{ errorMsg }}</div>
|
||||
<div v-else-if="records.length === 0" class="empty-state">{{ t('auditLog.empty') }}</div>
|
||||
<div v-if="loading" class="empty-state audit-loading">
|
||||
<svg class="audit-spinner audit-spinner--lg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12a9 9 0 11-6.219-8.56"/></svg>
|
||||
</div>
|
||||
<div v-else-if="errorMsg" class="empty-state audit-error">⚠ {{ errorMsg }}</div>
|
||||
<div v-else-if="filteredRecords.length === 0" class="empty-state">{{ t('auditLog.empty') }}</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<div v-else class="audit-table-wrap">
|
||||
@@ -29,40 +49,57 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in records" :key="r.id">
|
||||
<td class="col-time">
|
||||
<div class="time-rel">{{ formatRelative(r.requested_at) }}</div>
|
||||
<div class="time-abs">{{ formatDate(r.requested_at) }}</div>
|
||||
</td>
|
||||
<td class="col-tool"><code class="tool-name">{{ r.tool_name }}</code></td>
|
||||
<td class="col-risk">
|
||||
<span class="risk-badge" :class="riskClass(r.risk_level)">{{ riskLabel(r.risk_level) }}</span>
|
||||
</td>
|
||||
<td class="col-status">
|
||||
<span class="status-tag" :class="statusClass(r.status)">{{ statusLabel(r.status) }}</span>
|
||||
</td>
|
||||
<td class="col-decided">
|
||||
<span v-if="r.decided_by" class="decided-tag" :class="decidedClass(r.decided_by)">{{ decidedLabel(r.decided_by) }}</span>
|
||||
<span v-else class="decided-none">—</span>
|
||||
</td>
|
||||
<td class="col-args"><code class="brief">{{ r.arguments_brief || '—' }}</code></td>
|
||||
<td class="col-result"><code class="brief">{{ r.result_brief || '—' }}</code></td>
|
||||
</tr>
|
||||
<template v-for="r in filteredRecords" :key="r.id">
|
||||
<tr class="audit-row" :class="{ expanded: expandedId === r.id }" @click="toggleExpand(r.id)">
|
||||
<td class="col-time">
|
||||
<div class="time-rel">{{ formatRelative(r.requested_at) }}</div>
|
||||
<div class="time-abs">{{ formatDate(r.requested_at) }}</div>
|
||||
<div v-if="r.executed_at" class="time-dur">⏱ {{ durationLabel(r.requested_at, r.executed_at) }}</div>
|
||||
</td>
|
||||
<td class="col-tool"><code class="tool-name">{{ r.tool_name }}</code></td>
|
||||
<td class="col-risk">
|
||||
<span class="risk-badge" :class="riskClass(r.risk_level)">{{ riskLabel(r.risk_level) }}</span>
|
||||
</td>
|
||||
<td class="col-status">
|
||||
<span class="status-tag" :class="statusClass(r.status)">{{ statusLabel(r.status) }}</span>
|
||||
</td>
|
||||
<td class="col-decided">
|
||||
<span v-if="r.decided_by" class="decided-tag" :class="decidedClass(r.decided_by)">{{ decidedLabel(r.decided_by) }}</span>
|
||||
<span v-else class="decided-none">—</span>
|
||||
</td>
|
||||
<td class="col-args"><code class="brief">{{ r.arguments_brief || '—' }}</code></td>
|
||||
<td class="col-result"><code class="brief">{{ r.result_brief || '—' }}</code></td>
|
||||
</tr>
|
||||
<tr v-if="expandedId === r.id" class="audit-detail-row">
|
||||
<td colspan="7">
|
||||
<div class="audit-detail">
|
||||
<div class="detail-section">
|
||||
<span class="detail-label">{{ t('auditLog.col.args') }}</span>
|
||||
<pre class="detail-pre">{{ r.arguments_brief || '—' }}</pre>
|
||||
</div>
|
||||
<div class="detail-section">
|
||||
<span class="detail-label">{{ t('auditLog.col.result') }}</span>
|
||||
<pre class="detail-pre">{{ r.result_brief || '—' }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div v-if="!loading && !errorMsg" class="pager">
|
||||
<button class="btn btn-ghost" :disabled="offset === 0" @click="prevPage">{{ t('auditLog.pager.prev') }}</button>
|
||||
<button class="btn btn-ghost btn-sm" :disabled="offset === 0" @click="prevPage">{{ t('auditLog.pager.prev') }}</button>
|
||||
<span class="pager-info">{{ t('auditLog.pager.page', { n: page }) }}{{ hasMore ? '' : t('auditLog.pager.last') }}</span>
|
||||
<button class="btn btn-ghost" :disabled="!hasMore" @click="nextPage">{{ t('auditLog.pager.next') }}</button>
|
||||
<button class="btn btn-ghost btn-sm" :disabled="!hasMore" @click="nextPage">{{ t('auditLog.pager.next') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { formatRelative, formatDate } from '@/utils/time'
|
||||
@@ -91,10 +128,50 @@ const offset = ref(0)
|
||||
const loading = ref(false)
|
||||
const errorMsg = ref('')
|
||||
|
||||
// 筛选状态(客户端过滤,数据量轻量)
|
||||
const statusFilter = ref('')
|
||||
const riskFilter = ref('')
|
||||
const toolSearch = ref('')
|
||||
|
||||
// 展开行(查看完整 args/result)
|
||||
const expandedId = ref<string | null>(null)
|
||||
|
||||
const statusKeys = ['pending', 'approved', 'rejected', 'executing', 'completed', 'failed']
|
||||
const riskKeys = ['low', 'medium', 'high']
|
||||
|
||||
const page = computed(() => Math.floor(offset.value / PAGE_SIZE) + 1)
|
||||
// 下一页存在性:本页满 PAGE_SIZE 视为可能还有更多(hasMore);末页不足时点下一页会拉空,自动回退
|
||||
const hasMore = computed(() => records.value.length === PAGE_SIZE)
|
||||
|
||||
// 客户端筛选(状态/风险/工具名)
|
||||
// 后端 list_tool_executions 不支持 WHERE 筛选,数据量小(每页 50),前端 filter 轻量。
|
||||
const filteredRecords = computed(() => {
|
||||
let list = records.value
|
||||
if (statusFilter.value) list = list.filter(r => r.status === statusFilter.value)
|
||||
if (riskFilter.value) list = list.filter(r => r.risk_level === riskFilter.value)
|
||||
const q = toolSearch.value.trim().toLowerCase()
|
||||
if (q) list = list.filter(r => r.tool_name.toLowerCase().includes(q))
|
||||
return list
|
||||
})
|
||||
|
||||
// 筛选变化时关闭展开行(避免展开行被筛掉后残留)
|
||||
watch([statusFilter, riskFilter, toolSearch], () => { expandedId.value = null })
|
||||
|
||||
function toggleExpand(id: string) {
|
||||
expandedId.value = expandedId.value === id ? null : id
|
||||
}
|
||||
|
||||
// 审批→执行 耗时(秒级,毫秒差太精细)
|
||||
function durationLabel(requested: string, executed: string): string {
|
||||
const ms = new Date(executed).getTime() - new Date(requested).getTime()
|
||||
if (isNaN(ms) || ms < 0) return '—'
|
||||
if (ms < 1000) return '<1s'
|
||||
const s = Math.floor(ms / 1000)
|
||||
if (s < 60) return `${s}s`
|
||||
const m = Math.floor(s / 60)
|
||||
return `${m}m${s % 60}s`
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
errorMsg.value = ''
|
||||
@@ -117,16 +194,20 @@ async function load() {
|
||||
}
|
||||
|
||||
function reload() {
|
||||
if (loading.value) return
|
||||
offset.value = 0
|
||||
expandedId.value = null
|
||||
void load()
|
||||
}
|
||||
function prevPage() {
|
||||
if (offset.value === 0) return
|
||||
offset.value = Math.max(0, offset.value - PAGE_SIZE)
|
||||
expandedId.value = null
|
||||
void load()
|
||||
}
|
||||
function nextPage() {
|
||||
offset.value += PAGE_SIZE
|
||||
expandedId.value = null
|
||||
void load()
|
||||
}
|
||||
|
||||
@@ -169,14 +250,7 @@ onMounted(() => {
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--df-gap-page);
|
||||
}
|
||||
.page-header h1 { font-size: 24px; font-weight: 500; color: var(--df-text); }
|
||||
.header-actions { display: flex; gap: 10px; }
|
||||
/* page-header / btn 等已提取到 global.css 全局 */
|
||||
|
||||
.page-desc {
|
||||
font-size: 12px;
|
||||
@@ -184,6 +258,30 @@ onMounted(() => {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* ===== 筛选栏 ===== */
|
||||
.audit-filters { gap: 8px; margin-bottom: 16px; }
|
||||
.audit-filter-select {
|
||||
padding: 6px 10px;
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: var(--df-bg);
|
||||
color: var(--df-text);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.audit-filter-select:focus { outline: none; border-color: var(--df-accent); }
|
||||
.audit-search { max-width: 200px; }
|
||||
|
||||
/* ===== 加载/错误状态 ===== */
|
||||
.audit-loading { display: flex; align-items: center; justify-content: center; padding: 40px; }
|
||||
.audit-error { color: var(--df-danger); }
|
||||
.audit-spinner { animation: audit-spin 0.9s linear infinite; }
|
||||
.audit-spinner--lg { color: var(--df-text-dim); }
|
||||
@keyframes audit-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ===== 表格 ===== */
|
||||
.audit-table-wrap {
|
||||
background: var(--df-bg-card);
|
||||
@@ -215,7 +313,9 @@ onMounted(() => {
|
||||
vertical-align: top;
|
||||
}
|
||||
.audit-table tbody tr:last-child td { border-bottom: none; }
|
||||
.audit-table tbody tr:hover td { background: var(--df-sidebar-hover, rgba(255,255,255,0.03)); }
|
||||
.audit-table tbody tr:hover td { background: var(--df-bg-hover, rgba(255,255,255,0.03)); }
|
||||
/* 展开/hover 行高亮由 .audit-row:hover/.expanded 管理,避免选择器冲突 */
|
||||
.audit-table tbody tr.audit-row:hover td { background: var(--df-bg-hover, rgba(255,255,255,0.03)); }
|
||||
|
||||
.col-time { min-width: 120px; }
|
||||
.col-tool { min-width: 130px; }
|
||||
@@ -225,14 +325,39 @@ onMounted(() => {
|
||||
.col-args { min-width: 200px; }
|
||||
.col-result { min-width: 220px; }
|
||||
|
||||
/* 可点击行 */
|
||||
.audit-row { cursor: pointer; transition: background 0.12s; }
|
||||
.audit-row:hover td { background: var(--df-bg-hover, rgba(255,255,255,0.03)); }
|
||||
.audit-row.expanded td { background: var(--df-bg-hover, rgba(255,255,255,0.04)); }
|
||||
|
||||
/* 展开详情行 */
|
||||
.audit-detail-row td { padding: 0 !important; border-bottom: 0.5px solid var(--df-border); }
|
||||
.audit-detail { padding: 12px 16px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.detail-section { display: flex; flex-direction: column; gap: 4px; }
|
||||
.detail-label { font-size: 11px; color: var(--df-text-dim); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.detail-pre {
|
||||
margin: 0; padding: 8px 10px;
|
||||
background: var(--df-bg);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--df-text-secondary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.time-rel { font-size: 12px; color: var(--df-text-secondary); }
|
||||
.time-abs { font-size: 10px; color: var(--df-text-dim); margin-top: 2px; font-family: var(--df-font-mono); }
|
||||
.time-dur { font-size: 10px; color: var(--df-text-dim); margin-top: 2px; font-family: var(--df-font-mono); }
|
||||
|
||||
.tool-name {
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--df-accent);
|
||||
background: var(--df-accent-bg, rgba(123,111,240,0.08));
|
||||
background: var(--df-accent-bg);
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
}
|
||||
@@ -254,8 +379,8 @@ onMounted(() => {
|
||||
border-radius: var(--df-radius-xs);
|
||||
}
|
||||
.risk-low { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
|
||||
.risk-medium { background: rgba(255,152,0,0.2); color: #ff9800; }
|
||||
.risk-high { background: rgba(255,107,107,0.2); color: var(--df-danger); }
|
||||
.risk-medium { background: var(--df-warning-bg); color: var(--df-warning); }
|
||||
.risk-high { background: var(--df-danger-bg); color: var(--df-danger); }
|
||||
|
||||
/* 状态 tag */
|
||||
.status-tag {
|
||||
@@ -266,12 +391,12 @@ onMounted(() => {
|
||||
border-radius: var(--df-radius-sm);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status-pending { background: rgba(255,217,61,0.2); color: var(--df-warning); }
|
||||
.status-approved { background: rgba(76,175,80,0.2); color: var(--df-success, #4caf50); }
|
||||
.status-rejected { background: rgba(255,107,107,0.2); color: var(--df-danger); }
|
||||
.status-executing { background: rgba(100,181,246,0.2); color: var(--df-info); }
|
||||
.status-completed { background: rgba(76,175,80,0.15); color: var(--df-success, #4caf50); }
|
||||
.status-failed { background: rgba(255,107,107,0.2); color: var(--df-danger); }
|
||||
.status-pending { background: var(--df-warning-bg); color: var(--df-warning); }
|
||||
.status-approved { background: var(--df-success-bg); color: var(--df-success); }
|
||||
.status-rejected { background: var(--df-danger-bg); color: var(--df-danger); }
|
||||
.status-executing { background: var(--df-info-bg); color: var(--df-info); }
|
||||
.status-completed { background: var(--df-success-bg); color: var(--df-success); }
|
||||
.status-failed { background: var(--df-danger-bg); color: var(--df-danger); }
|
||||
|
||||
/* 决策者 tag */
|
||||
.decided-tag {
|
||||
@@ -281,7 +406,7 @@ onMounted(() => {
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
}
|
||||
.decided-human { background: rgba(123,111,240,0.2); color: var(--df-accent); }
|
||||
.decided-human { background: var(--df-accent-bg); color: var(--df-accent); }
|
||||
.decided-auto { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
|
||||
.decided-none { color: var(--df-text-dim); }
|
||||
|
||||
@@ -299,10 +424,5 @@ onMounted(() => {
|
||||
font-family: var(--df-font-mono);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
font-size: 14px;
|
||||
color: var(--df-text-dim);
|
||||
}
|
||||
/* empty-state 已提取到 global.css 全局 */
|
||||
</style>
|
||||
|
||||
@@ -7,18 +7,19 @@
|
||||
<span class="dash-subtitle">{{ $t('dashboard.subtitle') }}</span>
|
||||
</div>
|
||||
<div class="dash-header-right">
|
||||
<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>
|
||||
<button class="btn btn-ghost btn-sm dash-refresh-btn" @click="refresh" :disabled="loading" :title="$t('dashboard.refresh')">
|
||||
<svg v-if="loading" class="dash-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12a9 9 0 11-6.219-8.56"/></svg>
|
||||
<svg v-else 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 class="df-btn df-btn--primary" @click="quickCapture">
|
||||
<button class="btn btn-primary btn-sm" @click="quickCapture">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
{{ $t('dashboard.captureIdea') }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ═══ 错误条:refresh/onMounted 加载失败对用户可见(对齐 Knowledge error-banner) ═══ -->
|
||||
<div v-if="errorMsg" class="error-banner">
|
||||
<!-- ═══ 错误条:refresh/onMounted 加载失败对用户可见(样式全局 .error-banner) ═══ -->
|
||||
<div v-if="errorMsg" class="error-banner" style="margin-bottom: 14px">
|
||||
<span class="error-text">{{ errorMsg }}</span>
|
||||
<button class="error-dismiss" @click="errorMsg = ''">✕</button>
|
||||
</div>
|
||||
@@ -122,58 +123,12 @@ onMounted(() => {
|
||||
}
|
||||
.dash-header-right { display: flex; gap: 6px; align-items: center; }
|
||||
|
||||
/* — Buttons — */
|
||||
.df-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 9px 14px;
|
||||
line-height: 1;
|
||||
border: none;
|
||||
border-radius: var(--df-radius-sm);
|
||||
font-family: var(--df-font-sans);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s var(--df-ease);
|
||||
white-space: nowrap;
|
||||
/* 刷新按钮图标旋转动画(加载中反馈) */
|
||||
.dash-spinner { animation: dash-spin 0.9s linear infinite; }
|
||||
@keyframes dash-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.df-btn--primary {
|
||||
background: var(--df-accent);
|
||||
color: #fff;
|
||||
|
||||
}
|
||||
.df-btn--primary:hover {
|
||||
background: var(--df-accent);
|
||||
filter: brightness(1.1);
|
||||
|
||||
}
|
||||
.df-btn--ghost {
|
||||
background: rgba(255,255,255,0.03);
|
||||
color: var(--df-text-secondary);
|
||||
border: 0.5px solid var(--df-border);
|
||||
}
|
||||
.df-btn--ghost:hover {
|
||||
background: rgba(255,255,255,0.06);
|
||||
color: var(--df-text);
|
||||
border-color: var(--df-border-strong);
|
||||
}
|
||||
.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; }
|
||||
|
||||
/* ═══ Grid Layout ═══ */
|
||||
.dash-grid {
|
||||
@@ -183,41 +138,10 @@ onMounted(() => {
|
||||
}
|
||||
.dash-right { display: flex; flex-direction: column; gap: 12px; }
|
||||
|
||||
/* ═══ Panel(子面板 df-panel 共享基类,scoped 下子组件需各自声明同名类样式;
|
||||
此处保留供未来页内直挂面板,子组件已自带同名 .df-panel 样式) ═══ */
|
||||
.df-panel {
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-md);
|
||||
padding: 14px 16px;
|
||||
animation: fadeInUp 0.5s var(--df-ease) both;
|
||||
}
|
||||
.df-panel-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.df-panel-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--df-text);
|
||||
letter-spacing: -0.1px;
|
||||
}
|
||||
.df-link {
|
||||
font-size: 11px;
|
||||
color: var(--df-accent);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.df-link:hover { opacity: 1; text-decoration: underline; }
|
||||
|
||||
/* ═══ Responsive ═══ */
|
||||
|
||||
/* 窄窗口:单栏 */
|
||||
@media (max-width: 900px) {
|
||||
/* 窄窗口:单栏(与 AppLayout 768px 断点对齐) */
|
||||
@media (max-width: 768px) {
|
||||
.dashboard { padding: 12px 14px 16px; }
|
||||
.dash-grid { grid-template-columns: 1fr; }
|
||||
.dash-right { flex-direction: row; flex-wrap: wrap; gap: 10px; }
|
||||
@@ -233,6 +157,5 @@ onMounted(() => {
|
||||
@media (min-width: 1600px) {
|
||||
.dashboard { padding: 20px 28px 24px; }
|
||||
.dash-grid { grid-template-columns: 1fr 400px; gap: 16px; }
|
||||
.df-panel { padding: 16px 18px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -134,8 +134,8 @@
|
||||
<input v-model="newIdeaSource" :placeholder="$t('ideas.sourcePlaceholder')" />
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn-cancel" @click="showCaptureModal = false">{{ $t('common.cancel') }}</button>
|
||||
<button class="btn-confirm" :disabled="creating" @click="confirmCapture">
|
||||
<button class="btn btn-ghost" @click="showCaptureModal = false">{{ $t('common.cancel') }}</button>
|
||||
<button class="btn btn-primary" :disabled="creating" @click="confirmCapture">
|
||||
{{ creating ? $t('ideas.creating') : $t('common.confirm') }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -156,6 +156,7 @@ import { useProjectStore } from '../stores/project'
|
||||
import type { IdeaStatus, IdeaQuery } from '../api/types'
|
||||
import { formatDate } from '../utils/time'
|
||||
import { stripMd } from '../utils/markdown'
|
||||
import { scoreTier } from '../utils/ideaEval'
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||
import IdeaDetail from '../components/ideas/IdeaDetail.vue'
|
||||
import { useConfirm } from '../composables/useConfirm'
|
||||
@@ -286,11 +287,9 @@ const currentIdea = computed(() => {
|
||||
|
||||
// B-260615-25:灵感描述 Markdown 渲染已下沉至 IdeaDetail 子组件(共享模块单例渲染器)
|
||||
|
||||
// 评分档位 class(高/中/低 三色标),阈值统一走 scoreTier(单一来源,消除 4 处重复)
|
||||
function scoreClass(score: number | null) {
|
||||
const s = score ?? 0
|
||||
if (s >= 80) return 'score-high'
|
||||
if (s >= 60) return 'score-mid'
|
||||
return 'score-low'
|
||||
return 'score-' + scoreTier(score)
|
||||
}
|
||||
|
||||
// formatDate 由 ../utils/time 提供(统一毫秒字符串解析,根治 Invalid Date)
|
||||
@@ -418,74 +417,7 @@ watch(() => route.params.id, (id) => {
|
||||
<style scoped>
|
||||
.ideas { padding: 16px 20px 20px; }
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--df-gap-page);
|
||||
}
|
||||
.page-header h1 { font-size: 24px; font-weight: 500; color: var(--df-text); }
|
||||
.header-actions { display: flex; gap: 10px; }
|
||||
|
||||
/* ===== 按钮 ===== */
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: var(--df-radius-sm);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.btn-primary { background: var(--df-accent); color: #fff; }
|
||||
.btn-primary:hover { background: var(--df-accent-hover); }
|
||||
.btn-ghost { background: transparent; color: var(--df-text-secondary); border: 0.5px solid var(--df-border); }
|
||||
.btn-ghost:hover { background: var(--df-bg-card); color: var(--df-text); }
|
||||
|
||||
/* ===== 筛选栏 ===== */
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: var(--df-gap-page);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
flex: 1;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
width: 100%;
|
||||
padding: 6px 12px;
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: var(--df-bg);
|
||||
color: var(--df-text);
|
||||
font-size: 13px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.search-box input:focus {
|
||||
outline: none;
|
||||
border-color: var(--df-accent);
|
||||
background: var(--df-bg-raised);
|
||||
}
|
||||
.filter-btn {
|
||||
padding: 6px 14px;
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--df-text-secondary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.filter-btn:hover { background: var(--df-bg-card); color: var(--df-text); }
|
||||
.filter-btn.active {
|
||||
background: var(--df-accent);
|
||||
color: #fff;
|
||||
border-color: var(--df-accent);
|
||||
}
|
||||
/* page-header / btn / modal / filter-bar / filter-btn / search-box / empty-state 系列已提取到 global.css 全局 */
|
||||
|
||||
/* 排序按钮组(与状态筛选按钮视觉分隔) */
|
||||
.sort-group {
|
||||
@@ -547,16 +479,7 @@ watch(() => route.params.id, (id) => {
|
||||
}
|
||||
.idea-date { font-size: 11px; color: var(--df-text-dim); }
|
||||
|
||||
/* ===== 状态标签 ===== */
|
||||
.status-tag {
|
||||
font-size: 11px; font-weight: 500; padding: 2px 8px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
}
|
||||
.status-draft { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
|
||||
.status-pending_review { background: rgba(100,181,246,0.2); color: var(--df-info); }
|
||||
.status-approved { background: rgba(100,255,218,0.15); color: var(--df-success); }
|
||||
.status-promoted { background: rgba(108,99,255,0.2); color: var(--df-accent); }
|
||||
.status-rejected { background: rgba(255,107,107,0.2); color: var(--df-danger); }
|
||||
/* ===== 状态标签已提取到 styles/components.css 全局(.status-tag 系列) ===== */
|
||||
|
||||
/* ===== 右侧详情(详情内容由 IdeaDetail 子组件渲染,父级仅保留未选中空态壳) ===== */
|
||||
.idea-detail-panel {
|
||||
@@ -572,19 +495,14 @@ watch(() => route.params.id, (id) => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.empty-state { text-align: center; color: var(--df-text-dim); }
|
||||
.empty-icon { font-size: 48px; margin-bottom: 12px; }
|
||||
.empty-state p { font-size: 14px; }
|
||||
/* empty-state / empty-icon 已提取到 global.css 全局 */
|
||||
|
||||
/* ===== 响应式 ===== */
|
||||
@media (max-width: 900px) {
|
||||
@media (max-width: 768px) {
|
||||
.ideas { padding: 16px; }
|
||||
.ideas-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.filter-bar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.search-box {
|
||||
max-width: 100%;
|
||||
order: -1;
|
||||
@@ -593,13 +511,6 @@ watch(() => route.params.id, (id) => {
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 模态框 ===== */
|
||||
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 100; }
|
||||
.modal-box { background: var(--df-bg-card); border: 0.5px solid var(--df-border); border-radius: var(--df-radius-lg); padding: 24px; min-width: 360px; max-width: 90vw; }
|
||||
.modal-box h3 { color: var(--df-text); margin-bottom: var(--df-gap-head); }
|
||||
.modal-box input, .modal-box textarea { width: 100%; padding: 8px 12px; border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm); background: var(--df-bg); color: var(--df-text); font-size: 13px; margin-bottom: 12px; box-sizing: border-box; font-family: inherit; }
|
||||
.modal-box .modal-select { width: 100%; padding: 8px 12px; border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm); background: var(--df-bg); color: var(--df-text); font-size: 13px; margin-bottom: 12px; box-sizing: border-box; font-family: inherit; }
|
||||
.modal-box .modal-actions { display: flex; gap: 8px; justify-content: flex-end; }
|
||||
.modal-box .btn-cancel { padding: 6px 16px; border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm); background: transparent; color: var(--df-text-secondary); cursor: pointer; }
|
||||
.modal-box .btn-confirm { padding: 6px 16px; border: none; border-radius: var(--df-radius-sm); background: var(--df-accent); color: #fff; cursor: pointer; }
|
||||
/* ===== 模态框字段样式(modal-overlay/box/field/actions 已提取到 global.css) ===== */
|
||||
/* Ideas 模态的 select 用 .modal-field select 全局覆盖,无需额外 scoped */
|
||||
</style>
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 错误条:消费 store.error(加载/检索/配置失败对用户可见) -->
|
||||
<div v-if="store.error" class="error-banner">
|
||||
<!-- 错误条:消费 store.error(样式全局 .error-banner,仅间距用页面变量) -->
|
||||
<div v-if="store.error" class="error-banner" style="margin-bottom: var(--df-gap-page)">
|
||||
<span class="error-text">{{ store.error }}</span>
|
||||
<button class="error-dismiss" @click="clearError">✕</button>
|
||||
</div>
|
||||
@@ -347,26 +347,9 @@ onMounted(() => {
|
||||
<style scoped>
|
||||
.knowledge { padding: 16px 20px 20px; height: 100%; display: flex; flex-direction: column; }
|
||||
|
||||
.page-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
margin-bottom: var(--df-gap-page);
|
||||
}
|
||||
.page-header h1 { font-size: 24px; font-weight: 500; color: var(--df-text); }
|
||||
.header-actions { display: flex; gap: 8px; }
|
||||
/* page-header / btn / modal 等已提取到 global.css 全局 */
|
||||
|
||||
/* ===== 错误条(消费 store.error) ===== */
|
||||
.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; }
|
||||
/* ===== 错误条样式已提取到 styles/components.css 全局(.error-banner) ===== */
|
||||
|
||||
/* ===== 顶层 Tab ===== */
|
||||
.top-tabs { display: flex; gap: 4px; margin-bottom: var(--df-gap-page); border-bottom: 0.5px solid var(--df-border); }
|
||||
@@ -446,28 +429,7 @@ onMounted(() => {
|
||||
.conf-medium { background: rgba(255,217,61,0.15); color: var(--df-warning); padding: 1px 6px; border-radius: var(--df-radius-xs); }
|
||||
.conf-low { background: rgba(255,107,107,0.15); color: var(--df-danger); padding: 1px 6px; border-radius: var(--df-radius-xs); }
|
||||
|
||||
/* ===== 按钮 ===== */
|
||||
.btn {
|
||||
padding: 8px 16px; border: none; border-radius: var(--df-radius-sm);
|
||||
font-size: 13px; cursor: pointer; transition: all 0.15s;
|
||||
}
|
||||
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
||||
.btn-primary { background: var(--df-accent); color: #fff; }
|
||||
.btn-primary:hover { background: var(--df-accent-hover); }
|
||||
.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.btn-ghost { background: transparent; color: var(--df-text-secondary); border: 0.5px solid var(--df-border); }
|
||||
.btn-ghost:hover { background: var(--df-bg-card); color: var(--df-text); }
|
||||
|
||||
/* ===== 新增对话框 ===== */
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.5);
|
||||
display: flex; align-items: center; justify-content: center; z-index: 1000;
|
||||
}
|
||||
.modal-box {
|
||||
background: var(--df-bg-panel, var(--df-bg-card)); border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-lg); padding: 24px; width: 90%; max-width: 520px;
|
||||
}
|
||||
.modal-title { font-size: 16px; font-weight: 500; color: var(--df-text); margin-bottom: 16px; }
|
||||
/* form-row / form-input / form-select / form-textarea 已随新建/编辑弹层随 KnowledgeDetail 子组件迁移,此处仅保留本壳所需特有覆盖。 */
|
||||
.form-row { margin-bottom: 12px; }
|
||||
.form-row label { display: block; font-size: 12px; color: var(--df-text-secondary); margin-bottom: 4px; }
|
||||
.form-input, .form-select {
|
||||
@@ -477,7 +439,6 @@ onMounted(() => {
|
||||
}
|
||||
.form-input:focus, .form-select:focus { border-color: var(--df-accent); }
|
||||
.form-textarea { min-height: 80px; resize: vertical; font-family: inherit; }
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
|
||||
|
||||
/* ===== 响应式:窄屏单列布局(详情窄屏样式随 KnowledgeDetail 子组件迁移) ===== */
|
||||
@media (max-width: 760px) {
|
||||
|
||||
@@ -188,7 +188,7 @@
|
||||
<div class="score-bar-track">
|
||||
<div
|
||||
class="score-bar-fill"
|
||||
:class="dim.score >= 80 ? 'fill-high' : dim.score >= 60 ? 'fill-mid' : 'fill-low'"
|
||||
:class="'fill-' + scoreTier(dim.score)"
|
||||
:style="{ width: dim.score + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
@@ -276,7 +276,7 @@ import { useProjectStore } from '@/stores/project'
|
||||
import { projectApi } from '@/api'
|
||||
import { formatDate } from '@/utils/time'
|
||||
import { parseStack } from '@/utils/project'
|
||||
import { parseScores as parseScoresJson, assessmentClass, assessmentLabel as assessmentLabelI18n } from '@/utils/ideaEval'
|
||||
import { parseScores as parseScoresJson, assessmentClass, assessmentLabel as assessmentLabelI18n, scoreTier } from '@/utils/ideaEval'
|
||||
import { projectStatusLabel, taskStatusLabel, taskStatusClass } from '../constants/project'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import ApprovalDialog from '@/components/project/ApprovalDialog.vue'
|
||||
@@ -559,7 +559,8 @@ onUnmounted(() => {
|
||||
|
||||
/* 加载/空态(模板顶部使用) */
|
||||
.loading-state { text-align: center; padding: 40px; color: var(--df-text-dim); }
|
||||
.empty-state { text-align: center; padding: 40px; display: flex; flex-direction: column; align-items: center; gap: 12px; }
|
||||
/* 覆盖全局 .empty-state:带图标+文字垂直排列,缩短 padding */
|
||||
.empty-state { padding: 40px; display: flex; flex-direction: column; align-items: center; gap: 12px; }
|
||||
|
||||
/* ===== 项目信息样式 ===== */
|
||||
.project-info {
|
||||
@@ -655,39 +656,16 @@ onUnmounted(() => {
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
/* assessment badge(复用 IdeaDetail 同名类的样式定义) */
|
||||
.assessment-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.assessment-badge.immediate { background: var(--df-success-bg); color: var(--df-success); }
|
||||
.assessment-badge.soon { background: var(--df-info-bg); color: var(--df-info); }
|
||||
.assessment-badge.conditional { background: var(--df-warning-bg); color: var(--df-warning); }
|
||||
.assessment-badge.revised { background: rgba(255, 217, 61, 0.2); color: var(--df-warning); }
|
||||
.assessment-badge.defer { background: var(--df-danger-bg); color: var(--df-danger); }
|
||||
.assessment-badge.cancel { background: var(--df-danger-bg); color: var(--df-danger); }
|
||||
/* assessment-badge 系列(基础 + immediate/soon/conditional/revised/defer/cancel)
|
||||
已提取到 styles/components.css 全局 */
|
||||
|
||||
/* 多维评分条(复用 IdeaDetail score-bar-* 样式定义) */
|
||||
/* 多维评分条(.score-bar-* 系列)已提取到 styles/components.css 全局 */
|
||||
.source-scores {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.score-bar-row { display: flex; align-items: center; gap: 10px; }
|
||||
.score-bar-label { font-size: 12px; color: var(--df-text-secondary); min-width: 64px; text-align: right; }
|
||||
.score-bar-track {
|
||||
flex: 1; height: 8px; background: var(--df-border);
|
||||
border-radius: var(--df-radius-xs); overflow: hidden;
|
||||
}
|
||||
.score-bar-fill { height: 100%; border-radius: var(--df-radius-xs); transition: width 0.4s; }
|
||||
.fill-high { background: var(--df-success); }
|
||||
.fill-mid { background: var(--df-warning); }
|
||||
.fill-low { background: var(--df-danger); }
|
||||
.score-bar-value { font-size: 12px; font-weight: 500; min-width: 28px; text-align: right; color: var(--df-text); }
|
||||
|
||||
</style>
|
||||
|
||||
@@ -727,16 +705,9 @@ onUnmounted(() => {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--df-gap-page);
|
||||
}
|
||||
/* .page-header / .page-header h1 已提取到 styles/global.css 全局 */
|
||||
.header-left { display: flex; align-items: center; gap: 12px; }
|
||||
.back-link { font-size: 13px; color: var(--df-accent); text-decoration: none; }
|
||||
.back-link:hover { text-decoration: underline; }
|
||||
.page-header h1 { font-size: 24px; font-weight: 500; color: var(--df-text); }
|
||||
/* .back-link / .back-link:hover 已提取到 styles/global.css 全局 */
|
||||
|
||||
.stage-badge {
|
||||
font-size: 12px;
|
||||
@@ -752,24 +723,9 @@ onUnmounted(() => {
|
||||
.stage-paused { background: rgba(158,158,158,0.15); color: #9e9e9e; }
|
||||
.stage-cancelled { background: rgba(244,67,54,0.15); color: #f44336; }
|
||||
|
||||
.header-actions { display: flex; gap: 10px; }
|
||||
/* .header-actions 已提取到 styles/global.css 全局 */
|
||||
|
||||
/* ===== 按钮 ===== */
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: var(--df-radius-sm);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.btn-primary { background: var(--df-accent); color: #fff; }
|
||||
.btn-primary:hover { background: var(--df-accent-hover); }
|
||||
.btn-ghost { background: transparent; color: var(--df-text-secondary); border: 0.5px solid var(--df-border); }
|
||||
.btn-ghost:hover { background: var(--df-bg-card); color: var(--df-text); }
|
||||
.btn-sm { padding: 4px 12px; font-size: 12px; }
|
||||
.btn-danger { background: transparent; color: var(--df-danger); border: 0.5px solid rgba(240,101,101,0.4); }
|
||||
.btn-danger:hover { background: rgba(240,101,101,0.12); }
|
||||
/* .btn 系列(btn/btn-primary/btn-ghost/btn-sm/btn-danger)已提取到 styles/global.css 全局 */
|
||||
|
||||
/* ===== 两栏布局 — 自己滚动,不撑大父级 ===== */
|
||||
.detail-grid {
|
||||
@@ -856,35 +812,8 @@ onUnmounted(() => {
|
||||
|
||||
/* ===== 项目信息(左栏 .project-info/.info-item,右栏重复面板已删)===== */
|
||||
|
||||
/* ===== 模态框 ===== */
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
.modal-box {
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-lg);
|
||||
padding: 24px;
|
||||
width: 420px;
|
||||
max-width: 90vw;
|
||||
}
|
||||
.modal-title { font-size: 18px; font-weight: 500; color: var(--df-text); margin-bottom: 16px; }
|
||||
.modal-field { margin-bottom: 14px; }
|
||||
.modal-field label { display: block; font-size: 12px; color: var(--df-text-dim); margin-bottom: 4px; }
|
||||
.modal-field input, .modal-field textarea {
|
||||
width: 100%; padding: 8px 10px; border-radius: var(--df-radius-sm);
|
||||
border: 0.5px solid var(--df-border); background: var(--df-bg);
|
||||
color: var(--df-text); font-size: 13px; font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.modal-field input:focus, .modal-field textarea:focus {
|
||||
outline: none; border-color: var(--df-accent);
|
||||
}
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 4px; }
|
||||
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
/* .modal-* 系列(modal-overlay/modal-box/modal-title/modal-field/modal-actions)
|
||||
及 .btn:disabled 已提取到 styles/global.css 全局 */
|
||||
|
||||
.empty-hint {
|
||||
text-align: center; padding: 24px 12px;
|
||||
@@ -892,7 +821,7 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
/* ===== 响应式 ===== */
|
||||
@media (max-width: 900px) {
|
||||
@media (max-width: 768px) {
|
||||
.project-detail { padding: 16px; }
|
||||
.detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -164,7 +164,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
import { useProjectStore } from '@/stores/project'
|
||||
@@ -174,6 +174,7 @@ import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import ProjectCard from '@/components/project/ProjectCard.vue'
|
||||
import Paginator from '@/components/Paginator.vue'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import type { ProjectRecord } from '@/api/types'
|
||||
import type { ScannedProjectItem } from '@/api/project'
|
||||
|
||||
@@ -303,14 +304,7 @@ onMounted(() => {
|
||||
store.loadProjects()
|
||||
})
|
||||
|
||||
// 卸载清 toast timer:防组件已离开视图后 4s 内 timer 触发向已销毁组件写状态。
|
||||
// 对齐 Settings.vue 的 _toastTimer 清理模式(Knowledge.vue searchTimer 同模式)。
|
||||
onUnmounted(() => {
|
||||
if (_toastTimer) {
|
||||
clearTimeout(_toastTimer)
|
||||
_toastTimer = null
|
||||
}
|
||||
})
|
||||
// toast timer 清理由 useToast composable 的 onUnmounted 自动管理
|
||||
|
||||
// ── 导入历史项目(F-260614-06 scan 第二步) ──
|
||||
const showImportModal = ref(false)
|
||||
@@ -320,14 +314,7 @@ const importScanning = ref(false)
|
||||
const importError = ref('')
|
||||
const importRunning = ref(false)
|
||||
const selectedImportPaths = ref<Set<string>>(new Set())
|
||||
const toast = ref({ visible: false, msg: '', type: 'info' as 'info' | 'error' })
|
||||
let _toastTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function showToast(msg: string, type: 'info' | 'error' = 'info') {
|
||||
toast.value = { visible: true, msg, type }
|
||||
if (_toastTimer) clearTimeout(_toastTimer)
|
||||
_toastTimer = setTimeout(() => { toast.value.visible = false }, 4000)
|
||||
}
|
||||
const { toast, showToast } = useToast(4000) // Projects 导入操作 toast 用 4000ms(操作类提示)
|
||||
|
||||
const allImportSelected = computed(() =>
|
||||
scannedItems.value.length > 0
|
||||
@@ -432,29 +419,11 @@ async function runImport() {
|
||||
<style scoped>
|
||||
.projects { padding: 16px 20px 20px; }
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--df-gap-page);
|
||||
}
|
||||
.page-header h1 { font-size: 24px; font-weight: 500; color: var(--df-text); }
|
||||
.header-actions { display: flex; gap: 10px; }
|
||||
|
||||
/* ===== 按钮 ===== */
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: var(--df-radius-sm);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.btn-primary { background: var(--df-accent); color: #fff; }
|
||||
.btn-primary:hover { background: var(--df-accent-hover); }
|
||||
/* page-header / btn / modal 系列已提取到 global.css 全局 */
|
||||
/* 本页特有:.btn-danger(全局尚未提供)、.btn:disabled(全局仅 .btn-primary:disabled) */
|
||||
.btn-danger { background: transparent; color: var(--df-danger); border: 0.5px solid rgba(240,101,101,0.4); }
|
||||
.btn-danger:hover { background: rgba(240,101,101,0.12); }
|
||||
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
||||
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
/* ===== 卡片网格 ===== */
|
||||
.project-grid {
|
||||
@@ -495,42 +464,10 @@ async function runImport() {
|
||||
.scan-row { display: flex; gap: 8px; align-items: center; margin-top: 8px; }
|
||||
|
||||
/* ===== 模态框 ===== */
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
.modal-box {
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-lg);
|
||||
padding: 24px;
|
||||
width: 420px;
|
||||
max-width: 90vw;
|
||||
}
|
||||
.modal-title { font-size: 18px; font-weight: 500; color: var(--df-text); margin-bottom: 16px; }
|
||||
.modal-field { margin-bottom: 14px; }
|
||||
.modal-field label { display: block; font-size: 12px; color: var(--df-text-dim); margin-bottom: 4px; }
|
||||
.modal-field input, .modal-field textarea {
|
||||
width: 100%; padding: 8px 10px; border-radius: var(--df-radius-sm);
|
||||
border: 0.5px solid var(--df-border); background: var(--df-bg);
|
||||
color: var(--df-text); font-size: 13px; font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.modal-field input:focus, .modal-field textarea:focus {
|
||||
outline: none; border-color: var(--df-accent);
|
||||
}
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 4px; }
|
||||
.btn-ghost { background: transparent; color: var(--df-text-secondary); border: 0.5px solid var(--df-border); }
|
||||
.btn-ghost:hover { background: var(--df-bg-card); color: var(--df-text); }
|
||||
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
/* page-header / btn / modal 系列已提取到 global.css 全局 */
|
||||
|
||||
/* ===== 空状态 ===== */
|
||||
.empty-state {
|
||||
text-align: center; padding: 60px 20px;
|
||||
font-size: 14px; color: var(--df-text-dim);
|
||||
}
|
||||
/* page-header / btn / modal 系列已提取到 global.css 全局 */
|
||||
|
||||
/* ===== 错误条(消费 store.error,对齐 Knowledge error-banner) ===== */
|
||||
.error-banner {
|
||||
@@ -559,7 +496,7 @@ async function runImport() {
|
||||
.trash-actions { display: flex; gap: 8px; }
|
||||
|
||||
/* ===== 响应式 ===== */
|
||||
@media (max-width: 900px) {
|
||||
@media (max-width: 768px) {
|
||||
.projects { padding: 16px; }
|
||||
.project-grid {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -17,12 +17,8 @@
|
||||
v-model="activeCategory"
|
||||
:horizontal="isNarrowScreen"
|
||||
@scroll-target="scrollToSettingItem"
|
||||
>
|
||||
<!-- 底部导入/导出(阶段6):窄屏横向模式不显,避免横向 tab 拥挤 -->
|
||||
<template v-if="!isNarrowScreen" #footer>
|
||||
<SettingsImportExport :confirm-dialog="confirmDialog" :toast="showToast" />
|
||||
</template>
|
||||
</SettingsNav>
|
||||
/>
|
||||
<!-- 导入/导出已移除:实测价值低且占用 tab 底部空间,如需跨设备同步建议直接复制 SQLite 文件 -->
|
||||
|
||||
<div ref="contentRef" class="settings-content">
|
||||
<!-- 外观:theme/language/aiLanguage/showTokenUsage -->
|
||||
@@ -67,8 +63,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, shallowRef, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { computed, ref, shallowRef, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import SettingsNav, { type SettingsCategory } from '@/components/settings/SettingsNav.vue'
|
||||
import ProviderPanel from '@/components/settings/ProviderPanel.vue'
|
||||
@@ -78,7 +75,7 @@ import PerformanceSection from '@/components/settings/PerformanceSection.vue'
|
||||
import AdvancedSection from '@/components/settings/AdvancedSection.vue'
|
||||
import KnowledgePanel from '@/components/settings/KnowledgePanel.vue'
|
||||
import AllowedDirsPanel from '@/components/settings/AllowedDirsPanel.vue'
|
||||
import SettingsImportExport from '@/components/settings/SettingsImportExport.vue'
|
||||
// SettingsImportExport 已移除(价值低,占用 UI 空间;跨设备同步建议直接复制 SQLite)
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
import { aiApi } from '@/api'
|
||||
import type { AiProviderConfig } from '@/api/types'
|
||||
@@ -90,17 +87,8 @@ import { t as ti18n } from '@/i18n/i18n-helpers'
|
||||
// (阶段3 UX 重构:左导航 + 右按 activeCategory 渲染,各功能域 state/methods 已拆到子组件)
|
||||
// ============================================================
|
||||
|
||||
// 顶部轻量提示(错误/警告/完成),3s 自动消失;零依赖,替代未接入的 Arco Message
|
||||
const toast = reactive({ visible: false, msg: '', type: 'info' as 'error' | 'warning' | 'info' })
|
||||
let _toastTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function showToast(msg: string, type: 'error' | 'warning' | 'info' = 'info') {
|
||||
toast.msg = msg
|
||||
toast.type = type
|
||||
toast.visible = true
|
||||
if (_toastTimer) clearTimeout(_toastTimer)
|
||||
_toastTimer = setTimeout(() => { toast.visible = false }, 3000)
|
||||
}
|
||||
// 顶部轻量提示(useToast composable,消除 4 处重复;默认 3000ms)
|
||||
const { toast, showToast } = useToast()
|
||||
|
||||
// 确认弹层状态机抽至 composables/useConfirm(原 4 视图重复:Projects/ProjectDetail/Ideas/Settings)
|
||||
// 模板 confirmState.visible/msg 在 <script setup> 中自动解包 ref,沿用既有内联确认弹层
|
||||
@@ -207,9 +195,8 @@ onMounted(() => {
|
||||
void loadAiProviders()
|
||||
})
|
||||
|
||||
// 组件卸载清 toast/resize timer;各子面板自管自身 debounce timer(各自 onUnmounted)
|
||||
// 组件卸载清 resize timer;toast 清理由 useToast 自动管理;各子面板自管自身 debounce timer
|
||||
onUnmounted(() => {
|
||||
if (_toastTimer) clearTimeout(_toastTimer)
|
||||
if (_resizeTimer) clearTimeout(_resizeTimer)
|
||||
window.removeEventListener('resize', onResize)
|
||||
providerPanelRef.value?.clearPoolTimer()
|
||||
@@ -239,13 +226,7 @@ onUnmounted(() => {
|
||||
|
||||
/* 确认弹层已替换为 ConfirmDialog 组件(不再需要自建 CSS) */
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--df-gap-page);
|
||||
}
|
||||
.page-header h1 { font-size: 24px; font-weight: 500; color: var(--df-text); }
|
||||
/* page-header / btn 等已提取到 global.css 全局 */
|
||||
|
||||
/* ===== master-detail 布局(阶段3 UX 重构) =====
|
||||
左 SettingsNav(~200px 固定)+ 右 content(flex 占满 + 纵向滚动)。
|
||||
|
||||
@@ -491,37 +491,8 @@ onBeforeUnmount(() => {
|
||||
<style scoped>
|
||||
.task-detail { padding: 16px 20px 20px; }
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--df-gap-page);
|
||||
}
|
||||
/* page-header / btn / back-link 等已提取到 global.css 全局 */
|
||||
.header-left { display: flex; align-items: center; gap: 12px; }
|
||||
.back-link { font-size: 13px; color: var(--df-accent); text-decoration: none; white-space: nowrap; }
|
||||
.back-link:hover { text-decoration: underline; }
|
||||
.page-header h1 { font-size: 24px; font-weight: 500; color: var(--df-text); }
|
||||
|
||||
.header-actions { display: flex; gap: 10px; }
|
||||
|
||||
/* ===== 按钮 ===== */
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: var(--df-radius-sm);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.btn-ghost { background: transparent; color: var(--df-text-secondary); border: 0.5px solid var(--df-border); }
|
||||
.btn-ghost:hover { background: var(--df-bg-card); color: var(--df-text); }
|
||||
/* F-05 推进按钮变体(样式跟随 Projects/ProjectDetail/Knowledge 同款 btn-primary/btn-danger/btn-sm) */
|
||||
.btn-primary { background: var(--df-accent); color: #fff; }
|
||||
.btn-primary:hover { background: var(--df-accent-hover); }
|
||||
.btn-danger { background: transparent; color: var(--df-danger); border: 0.5px solid rgba(240,101,101,0.4); }
|
||||
.btn-danger:hover { background: rgba(240,101,101,0.12); }
|
||||
.btn-sm { padding: 5px 12px; font-size: 12px; }
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* F-05 推进操作区 */
|
||||
.advance-row .advance-actions {
|
||||
|
||||
@@ -124,25 +124,25 @@
|
||||
<h3>{{ $t('tasks.modal.title') }}</h3>
|
||||
<div class="modal-field">
|
||||
<label>{{ $t('tasks.modal.project') }}</label>
|
||||
<select v-model="newTaskProjectId" class="modal-input">
|
||||
<select v-model="newTaskProjectId">
|
||||
<option v-for="p in store.projects" :key="p.id" :value="p.id">{{ p.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<label>{{ $t('tasks.modal.titleField') }}</label>
|
||||
<input v-model="newTaskTitle" class="modal-input" :placeholder="$t('tasks.modal.titlePlaceholder')" @keyup.enter="confirmCreate" />
|
||||
<input v-model="newTaskTitle" :placeholder="$t('tasks.modal.titlePlaceholder')" @keyup.enter="confirmCreate" />
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<label>{{ $t('tasks.modal.desc') }}</label>
|
||||
<input v-model="newTaskDesc" class="modal-input" :placeholder="$t('tasks.modal.descPlaceholder')" />
|
||||
<input v-model="newTaskDesc" :placeholder="$t('tasks.modal.descPlaceholder')" />
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<label>{{ $t('tasks.modal.branch') }}</label>
|
||||
<input v-model="newTaskBranch" class="modal-input" :placeholder="$t('tasks.modal.branchPlaceholder')" />
|
||||
<input v-model="newTaskBranch" :placeholder="$t('tasks.modal.branchPlaceholder')" />
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<label>{{ $t('tasks.modal.priority') }}</label>
|
||||
<select v-model="newTaskPriority" class="modal-input">
|
||||
<select v-model="newTaskPriority">
|
||||
<option :value="0">{{ $t('tasks.modal.priorityCritical') }}</option>
|
||||
<option :value="1">{{ $t('tasks.modal.priorityHigh') }}</option>
|
||||
<option :value="2">{{ $t('tasks.modal.priorityMedium') }}</option>
|
||||
@@ -453,19 +453,13 @@ onUnmounted(() => {
|
||||
<style scoped>
|
||||
.tasks { padding: 16px 20px 20px; }
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--df-gap-page);
|
||||
gap: 12px;
|
||||
}
|
||||
.page-header h1 { font-size: 24px; font-weight: 500; color: var(--df-text); white-space: nowrap; }
|
||||
.header-actions { display: flex; gap: 10px; align-items: center; }
|
||||
/* page-header / page-header h1 / header-actions 已提取到全局 global.css */
|
||||
|
||||
/* 搜索框 */
|
||||
.search-input {
|
||||
width: 220px;
|
||||
max-width: 100%;
|
||||
flex: 1 1 220px;
|
||||
padding: 6px 12px;
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
@@ -478,33 +472,10 @@ onUnmounted(() => {
|
||||
.search-input:focus { border-color: var(--df-accent); }
|
||||
.search-input::placeholder { color: var(--df-text-dim); }
|
||||
|
||||
/* 按钮 */
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: var(--df-radius-sm);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.btn-primary { background: var(--df-accent); color: #fff; }
|
||||
.btn-primary:hover { background: var(--df-accent-hover); }
|
||||
.btn-ghost { background: transparent; color: var(--df-text-secondary); border: 0.5px solid var(--df-border); }
|
||||
.btn-ghost:hover { background: var(--df-bg-card); color: var(--df-text); }
|
||||
.btn-sm { padding: 4px 12px; font-size: 12px; }
|
||||
/* btn / btn-primary / btn-ghost / btn-sm 已提取到全局 global.css */
|
||||
|
||||
/* 筛选栏(紧凑) */
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: var(--df-gap-page);
|
||||
padding: 10px 14px;
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-lg);
|
||||
}
|
||||
/* filter-bar 已提取到全局 global.css,下方仅保留 Tasks 特有筛选类 */
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -702,61 +673,8 @@ onUnmounted(() => {
|
||||
.quick-menu-danger { color: var(--df-danger); }
|
||||
|
||||
/* 空态 */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 48px 0;
|
||||
color: var(--df-text-dim);
|
||||
font-size: 13px;
|
||||
}
|
||||
/* empty-state 已提取到全局 global.css */
|
||||
.empty-icon { font-size: 32px; opacity: 0.4; }
|
||||
|
||||
/* 模态框 */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
.modal-box {
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-lg);
|
||||
padding: 20px;
|
||||
min-width: 400px;
|
||||
max-width: 480px;
|
||||
}
|
||||
.modal-box h3 { font-size: 16px; color: var(--df-text); margin-bottom: 16px; }
|
||||
.modal-field {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.modal-field label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--df-text-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.modal-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 8px 12px;
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: var(--df-bg);
|
||||
color: var(--df-text);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
.modal-input:focus { border-color: var(--df-accent); }
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
/* 模态框 overlay/box/modal-field/modal-actions 已提取到 global.css 全局 */
|
||||
</style>
|
||||
|
||||
50
todo.md
50
todo.md
@@ -14,7 +14,8 @@
|
||||
> 诊断报告:`docs/02-架构设计/专项设计/AI对话目标丢失诊断-2026-06-26.md`
|
||||
> 实测会话 `54c8b5bd`:480 万 token,原始目标消息被压缩出局,LLM 尾部盲搜死循环。
|
||||
|
||||
- [x] **G1 目标钉扎** ✅ 代码已落地(`GOAL_PIN_ENABLED=true`),pinned_goals 入口拼入 system_prompt。`PerConvState.pinned_goals: Vec<String>` + chat.rs 三处提取(追加去重,支持累积多目标)。
|
||||
- [x] **G1 目标钉扎** ✅ 代码已落地(`GOAL_PIN_ENABLED=true`),pinned_goals 入口拼入 system_prompt。`PerConvState.pinned_goals: Vec<GoalEntry>`(含 text+status 状态追踪)。
|
||||
- 2026-07-03 改:提取方式从「用户消息规则提取」→「工具调用推理」(infer_goal_from_tool_calls),支持多条/轮 + 自动 active→completed 状态流转。
|
||||
- [x] **G2 探索熔断** ✅ 代码已落地(`STALL_BREAKER_ENABLED=true`,`STALL_BREAKER_THRESHOLD=3`),stall_count + is_empty_tool_result 完整实现。
|
||||
- [x] **G4 话题标记修正** ✅ 代码已落地(`TOPIC_MARKER_GOAL_AWARE=true`),pinned_goals 非空时 take 后丢弃跳过 insert。
|
||||
|
||||
@@ -59,3 +60,50 @@
|
||||
- [x] **migration V33** ✅ 已实现(`migrations.rs` migrate_v33):`workflow_executions` 加 `updated_at` 列 + `ai_conversations` 加 `pending_approvals`(审批重启恢复)。
|
||||
- [x] **L0-handshake 防抖** ✅ 已实现(`lib.rs:73-82`):3s 内重复 `ai-client-ready` 跳过。
|
||||
|
||||
## AI Chat 体验优化(2026-07-03 批次)
|
||||
|
||||
> 本轮完成的 10 项需求中,8 项已落地,3 项暂缓待后续批次。
|
||||
|
||||
### 已完成
|
||||
|
||||
- [x] **AI Chat 入口图标换** ✅ 灯泡→星花 sparkles + 发光效果(`AppSidebar.vue`)
|
||||
- [x] **LLM 回复文字重叠防御** ✅ delta 重复检测+丢弃(`useAiEvents.ts`)
|
||||
- [x] **全部接管仍审批** ✅ patch_file 全档位自动放行(`approval.rs`) + 诊断日志(`audit/mod.rs`)
|
||||
- [x] **设置导入导出去除** ✅ `SettingsImportExport.vue` 已删,引用清理(`Settings.vue`/`SettingsNav.vue`)
|
||||
- [x] **分离窗口默认置顶** ✅ `alwaysOnTop: true`(`useAiWindow.ts`)
|
||||
- [x] **审批通知浮层** ✅ 全局右上角浮层(`ApprovalOverlay.vue` + `App.vue` 挂载)
|
||||
- [x] **header 菜单聚合 `...`** ✅ 清空/压缩上下文收入 popout(`TopBar.vue`)
|
||||
- [x] **置顶图标换** ✅ 星→大头针 pushpin(`TopBar.vue`)
|
||||
- [x] **全部批准无响应** ✅ 防抖守卫 `_batchProcessing`(`useAiApproval.ts`)
|
||||
- [x] **工具卡执行中卡死** ✅ 乐观更新 `status !== 'completed'` 守卫(`useAiApproval.ts`)
|
||||
- [x] **置顶状态刷新丢失** ✅ 持久化到 appSettings KV(`AiChat.vue`)
|
||||
- [x] **目标提取根本性改造** ✅ 工具调用推理(`infer_goal_from_tool_calls` in `helpers.rs`)
|
||||
- 提取源:用户消息规则 → **工具调用推理**(结构化数据,零污染)
|
||||
- 数据结构:`Vec<String>` → **`Vec<GoalEntry>`**(text+status)
|
||||
- 状态流转:新目标加入 → 之前 active 自动标记 completed
|
||||
- system_prompt 仅注入 active 目标
|
||||
- 前端显示 3/5 计数 + ✓/● 状态标记
|
||||
- 代码拆分到 `helpers.rs`(解决 brace 嵌套致 pub(crate) 不可见)
|
||||
|
||||
### 暂缓待后续批次
|
||||
|
||||
- [ ] **9b 审批交互 UI/UX 重新设计** ⏸️
|
||||
- 当前:审批卡片是 ToolCard 的 pending_approval 态,布局单调、args 展示过长、风险等级不直观
|
||||
- 待做:重新设计卡片布局(缩短 args 预览 + 展开/收起、风险等级着色条、审批耗时提示、批量操作栏视觉增强)
|
||||
- 涉及:`ToolCard.vue`(~360 行) + `ToolCardList.vue` 少量调整
|
||||
- 优先级:中——现有功能可用但体验不够好
|
||||
|
||||
- [ ] **9d 底部工具区(目标/历史/上下文)展开交互优化** ⏸️
|
||||
- 当前:底部 🎯📋📄 三个入口紧凑排列,展开后内容区窄、超出不滚动、缺少动画
|
||||
- 待做:popover 浮层替代内联展开、加入平滑过渡动画、内容超出滚动、点击外部收起
|
||||
- 涉及:`TopBar.vue` 底部区
|
||||
- 优先级:低——功能完整,体验改善不迫切
|
||||
|
||||
- [ ] **`[UPDATE_GOALS]` LLM 主动修正目标列表** ⏸️
|
||||
- 设计已完成,实现暂缓
|
||||
- 格式:`[UPDATE_GOALS]\n1: 目标文本 (active)\n2: 目标文本 (completed)\n[END]`
|
||||
- LLM 输出此标记 → 后端解析并替换 pinned_goals → 标记从正文剥离
|
||||
- 函数签名 `extract_update_goals_block` 已设计未实现
|
||||
- 覆盖场景:意图增强/纠偏/递进(用户补充信息后发现之前目标提取偏了)
|
||||
- 优先级:中——价值高改动小,补全目标维护闭环
|
||||
|
||||
|
||||
Reference in New Issue
Block a user