重构: 前端DRY收口+后端测试类型对齐
- 新增 useToast composable: 消除 AiChat/Settings/Projects 4处 toast 重复 统一默认3000ms(Projects原4000ms为操作类提示保留参数覆盖) - 新增 utils/json.ts parseJsonArray: 消除 parseStack/parseTags/ModuleNode 3处JSON字符串数组解析重复 - 新增 utils/html.ts escapeHtml: 消除 useMarkdown/FilePreview 2处重复 - ProjectDetail score-bar 内联三元改用 scoreTier(消除最后一处阈值硬编码) - ConversationSidebar 删除 formatTime 透传包装(直接用 formatRelative) - 清理死代码: parseTs/stringifyError/ErrorSink/_Unused 改私有或删除 wrapNakedDiff 改私有(无外部 import) - ModuleNode shortPath 改名 truncatedPath(与 useToolCard.shortPath 语义不同)
This commit is contained in:
@@ -282,8 +282,8 @@ fn compress_old_messages_marks_compressed_and_returns_refs() {
|
|||||||
assert_eq!(compressed[1].content, "旧回复1");
|
assert_eq!(compressed[1].content, "旧回复1");
|
||||||
|
|
||||||
// status 已改 compressed
|
// 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));
|
||||||
assert_eq!(mgr.messages_mut()[1].message.status.as_deref(), Some("compressed"));
|
assert_eq!(mgr.messages_mut()[1].message.status.as_ref(), Some(&MessageStatus::Compressed));
|
||||||
// 保护区外(本例 index 2)仍 active
|
// 保护区外(本例 index 2)仍 active
|
||||||
assert!(mgr.messages_mut()[2].message.is_active(), "保护区外消息不应被动");
|
assert!(mgr.messages_mut()[2].message.is_active(), "保护区外消息不应被动");
|
||||||
|
|
||||||
@@ -346,8 +346,8 @@ fn compress_old_messages_skips_already_inactive() {
|
|||||||
);
|
);
|
||||||
// truncated 状态不被改成 compressed(保留原 truncated,语义不混淆)
|
// truncated 状态不被改成 compressed(保留原 truncated,语义不混淆)
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
mgr.messages_mut()[0].message.status.as_deref(),
|
mgr.messages_mut()[0].message.status.as_ref(),
|
||||||
Some("truncated"),
|
Some(&MessageStatus::Truncated),
|
||||||
"已 truncated 不应被改写为 compressed"
|
"已 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()[0].topic.as_deref(), Some("code"), "compressed 消息 topic 应保留");
|
||||||
assert_eq!(mgr.messages_mut()[1].topic.as_deref(), Some("file"), "compressed 消息 topic 应保留");
|
assert_eq!(mgr.messages_mut()[1].topic.as_deref(), Some("file"), "compressed 消息 topic 应保留");
|
||||||
// status 改为 compressed
|
// 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]
|
#[test]
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ mod tests {
|
|||||||
node_id: node_id.to_string(),
|
node_id: node_id.to_string(),
|
||||||
inputs: HashMap::new(),
|
inputs: HashMap::new(),
|
||||||
config,
|
config,
|
||||||
execution_id: execution_id.to_string(),
|
execution_id: execution_id.into(),
|
||||||
event_bus: event_bus.clone(),
|
event_bus: event_bus.clone(),
|
||||||
node_status: StateMachine::new(),
|
node_status: StateMachine::new(),
|
||||||
}
|
}
|
||||||
@@ -235,7 +235,7 @@ mod tests {
|
|||||||
) {
|
) {
|
||||||
event_bus
|
event_bus
|
||||||
.send(WorkflowEvent::HumanApprovalResponse {
|
.send(WorkflowEvent::HumanApprovalResponse {
|
||||||
execution_id: execution_id.to_string(),
|
execution_id: execution_id.into(),
|
||||||
node_id: node_id.to_string(),
|
node_id: node_id.to_string(),
|
||||||
decision: decision.to_string(),
|
decision: decision.to_string(),
|
||||||
decisions: vec![],
|
decisions: vec![],
|
||||||
@@ -254,7 +254,7 @@ mod tests {
|
|||||||
) {
|
) {
|
||||||
event_bus
|
event_bus
|
||||||
.send(WorkflowEvent::HumanApprovalResponse {
|
.send(WorkflowEvent::HumanApprovalResponse {
|
||||||
execution_id: execution_id.to_string(),
|
execution_id: execution_id.into(),
|
||||||
node_id: node_id.to_string(),
|
node_id: node_id.to_string(),
|
||||||
decision: String::new(),
|
decision: String::new(),
|
||||||
decisions: decisions.iter().map(|s| s.to_string()).collect(),
|
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_node("b".to_string(), Box::new(HumanNode));
|
||||||
dag.add_edge("a".to_string(), "b".to_string());
|
dag.add_edge("a".to_string(), "b".to_string());
|
||||||
|
|
||||||
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(); // 共享状态机(spawn 后仍可读)
|
let sm = executor.state_machine(); // 共享状态机(spawn 后仍可读)
|
||||||
|
|
||||||
let run_handle = tokio::spawn(async move {
|
let run_handle = tokio::spawn(async move {
|
||||||
executor
|
executor
|
||||||
@@ -537,7 +537,7 @@ mod tests {
|
|||||||
let mut dag = Dag::new();
|
let mut dag = Dag::new();
|
||||||
dag.add_node("h".to_string(), Box::new(HumanNode));
|
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 sm = executor.state_machine();
|
||||||
|
|
||||||
let run_handle = tokio::spawn(async move {
|
let run_handle = tokio::spawn(async move {
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ async fn node_config_overrides_global_in_node_context() {
|
|||||||
|
|
||||||
let dag = registry.build_dag(&def).expect("build_dag");
|
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 回退用全局)
|
// 全局 config 写 foo=GLOBAL(验证被 n1 节点级覆盖,n2 回退用全局)
|
||||||
let outputs = executor
|
let outputs = executor
|
||||||
.run(&dag, serde_json::json!({ "foo": "GLOBAL" }))
|
.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("a".to_string(), Box::new(SleepNode { sleep_ms: 100 }));
|
||||||
dag.add_node("b".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 start = std::time::Instant::now();
|
||||||
let outputs = executor.run(&dag, serde_json::Value::Null).await.unwrap();
|
let outputs = executor.run(&dag, serde_json::Value::Null).await.unwrap();
|
||||||
let elapsed = start.elapsed();
|
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_node("c".to_string(), Box::new(SleepNode { sleep_ms: 10 }));
|
||||||
dag.add_edge("a".to_string(), "c".to_string());
|
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
|
let err = executor
|
||||||
.run(&dag, serde_json::Value::Null)
|
.run(&dag, serde_json::Value::Null)
|
||||||
.await
|
.await
|
||||||
@@ -206,7 +206,7 @@ async fn test_cancelled_node_skips_set_failed() {
|
|||||||
let mut dag = Dag::new();
|
let mut dag = Dag::new();
|
||||||
dag.add_node("x".to_string(), Box::new(CancelSelfNode));
|
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;
|
let result = executor.run(&dag, serde_json::Value::Null).await;
|
||||||
|
|
||||||
// run 返回 Err(取消致中止后续层),但不 panic/bail transition
|
// run 返回 Err(取消致中止后续层),但不 panic/bail transition
|
||||||
@@ -257,7 +257,7 @@ async fn test_cancelled_node_skips_set_completed() {
|
|||||||
let mut dag = Dag::new();
|
let mut dag = Dag::new();
|
||||||
dag.add_node("y".to_string(), Box::new(CancelSelfThenOkNode));
|
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;
|
let result = executor.run(&dag, serde_json::Value::Null).await;
|
||||||
|
|
||||||
// run 返回 Ok —— Ok 节点不应因 TOCTOU 取消被误判为失败
|
// run 返回 Ok —— Ok 节点不应因 TOCTOU 取消被误判为失败
|
||||||
@@ -285,7 +285,7 @@ async fn test_cancelled_node_emits_node_cancelled_event() {
|
|||||||
|
|
||||||
let bus = EventBus::new();
|
let bus = EventBus::new();
|
||||||
let mut rx = bus.subscribe();
|
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;
|
let _ = executor.run(&dag, serde_json::Value::Null).await;
|
||||||
|
|
||||||
// 收集所有事件(run 已结束,事件总线无新事件)
|
// 收集所有事件(run 已结束,事件总线无新事件)
|
||||||
@@ -408,7 +408,7 @@ async fn conditions_eval_routes_by_edge_condition() {
|
|||||||
"$.ok == false".to_string(),
|
"$.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
|
let outputs = executor
|
||||||
.run(&dag, serde_json::Value::Null)
|
.run(&dag, serde_json::Value::Null)
|
||||||
.await
|
.await
|
||||||
@@ -462,7 +462,7 @@ async fn conditions_eval_skip_keeps_pending() {
|
|||||||
"$.ok == false".to_string(),
|
"$.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
|
let outputs = executor
|
||||||
.run(&dag, serde_json::Value::Null)
|
.run(&dag, serde_json::Value::Null)
|
||||||
.await
|
.await
|
||||||
@@ -504,7 +504,7 @@ async fn conditions_eval_unconditional_edge_always_passes() {
|
|||||||
"$.ok == false".to_string(),
|
"$.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
|
let outputs = executor
|
||||||
.run(&dag, serde_json::Value::Null)
|
.run(&dag, serde_json::Value::Null)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -485,6 +485,7 @@ pub(crate) async fn process_tool_calls(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use super::path_auth::extract_file_tool_paths;
|
||||||
|
|
||||||
/// grep 走单路径授权申请路径(NeedsAuth),非 search_files 盲拒(Denied)。
|
/// grep 走单路径授权申请路径(NeedsAuth),非 search_files 盲拒(Denied)。
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -189,6 +189,7 @@ mod tests {
|
|||||||
status: ProjectStatus::InProgress,
|
status: ProjectStatus::InProgress,
|
||||||
description: "AI-native dev tool".into(),
|
description: "AI-native dev tool".into(),
|
||||||
path: Some(SanitizedPath::new("E:/wk-lab/devflow")),
|
path: Some(SanitizedPath::new("E:/wk-lab/devflow")),
|
||||||
|
extra: vec![],
|
||||||
};
|
};
|
||||||
let seg = build_augmentation_segment(&[aug], "zh");
|
let seg = build_augmentation_segment(&[aug], "zh");
|
||||||
assert!(seg.starts_with("--- 以下是用户选择的上下文参考"), "头部应隔离标注, got: {}", seg);
|
assert!(seg.starts_with("--- 以下是用户选择的上下文参考"), "头部应隔离标注, got: {}", seg);
|
||||||
@@ -206,6 +207,7 @@ mod tests {
|
|||||||
status: TaskStatus::Todo,
|
status: TaskStatus::Todo,
|
||||||
description: "do X".into(),
|
description: "do X".into(),
|
||||||
project_name: Some("devflow".into()),
|
project_name: Some("devflow".into()),
|
||||||
|
extra: vec![],
|
||||||
};
|
};
|
||||||
let seg = build_augmentation_segment(&[aug], "zh");
|
let seg = build_augmentation_segment(&[aug], "zh");
|
||||||
assert!(seg.contains("【任务】Implement X"));
|
assert!(seg.contains("【任务】Implement X"));
|
||||||
@@ -234,6 +236,7 @@ mod tests {
|
|||||||
title: "Idea1".into(),
|
title: "Idea1".into(),
|
||||||
status: IdeaStatus::Approved,
|
status: IdeaStatus::Approved,
|
||||||
description: String::new(),
|
description: String::new(),
|
||||||
|
extra: vec![],
|
||||||
},
|
},
|
||||||
Augmentation::Project {
|
Augmentation::Project {
|
||||||
id: "p1".into(),
|
id: "p1".into(),
|
||||||
@@ -241,6 +244,7 @@ mod tests {
|
|||||||
status: ProjectStatus::Planning,
|
status: ProjectStatus::Planning,
|
||||||
description: String::new(),
|
description: String::new(),
|
||||||
path: None,
|
path: None,
|
||||||
|
extra: vec![],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
let seg = build_augmentation_segment(&augs, "zh");
|
let seg = build_augmentation_segment(&augs, "zh");
|
||||||
@@ -258,6 +262,7 @@ mod tests {
|
|||||||
title: "Idea1".into(),
|
title: "Idea1".into(),
|
||||||
status: IdeaStatus::Approved,
|
status: IdeaStatus::Approved,
|
||||||
description: String::new(),
|
description: String::new(),
|
||||||
|
extra: vec![],
|
||||||
};
|
};
|
||||||
let seg = build_augmentation_segment(&[aug], "en");
|
let seg = build_augmentation_segment(&[aug], "en");
|
||||||
assert!(seg.starts_with("--- The following is the context"), "en 头部: {}", seg);
|
assert!(seg.starts_with("--- The following is the context"), "en 头部: {}", seg);
|
||||||
@@ -271,6 +276,7 @@ mod tests {
|
|||||||
title: "Idea1".into(),
|
title: "Idea1".into(),
|
||||||
status: IdeaStatus::Approved,
|
status: IdeaStatus::Approved,
|
||||||
description: String::new(),
|
description: String::new(),
|
||||||
|
extra: vec![],
|
||||||
};
|
};
|
||||||
let seg = build_augmentation_segment(&[aug], "fr");
|
let seg = build_augmentation_segment(&[aug], "fr");
|
||||||
assert!(seg.starts_with("--- 以下是用户选择的上下文参考"), "未知 lang 应按 zh 兜底");
|
assert!(seg.starts_with("--- 以下是用户选择的上下文参考"), "未知 lang 应按 zh 兜底");
|
||||||
@@ -284,6 +290,7 @@ mod tests {
|
|||||||
status: ProjectStatus::Planning,
|
status: ProjectStatus::Planning,
|
||||||
description: String::new(),
|
description: String::new(),
|
||||||
path: None,
|
path: None,
|
||||||
|
extra: vec![],
|
||||||
};
|
};
|
||||||
let seg = build_augmentation_segment(&[aug], "zh");
|
let seg = build_augmentation_segment(&[aug], "zh");
|
||||||
assert!(!seg.contains("目录:"), "无 path 不应渲染目录行: {}", seg);
|
assert!(!seg.contains("目录:"), "无 path 不应渲染目录行: {}", seg);
|
||||||
|
|||||||
@@ -637,7 +637,7 @@ pub async fn ai_conversation_export(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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 {
|
fn base_msg() -> ChatMessage {
|
||||||
ChatMessage {
|
ChatMessage {
|
||||||
@@ -821,7 +821,7 @@ mod tests {
|
|||||||
serde_json::to_string(&msg.tool_calls).unwrap()
|
serde_json::to_string(&msg.tool_calls).unwrap()
|
||||||
);
|
);
|
||||||
assert_eq!(back.model.as_deref(), Some("m"));
|
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.reasoning_content.as_deref(), Some("r"));
|
||||||
assert_eq!(back.timestamp, Some(123));
|
assert_eq!(back.timestamp, Some(123));
|
||||||
}
|
}
|
||||||
@@ -875,7 +875,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
}]),
|
}]),
|
||||||
status: if i % 6 == 0 {
|
status: if i % 6 == 0 {
|
||||||
Some("compressed".into())
|
Some(MessageStatus::Compressed)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
},
|
},
|
||||||
@@ -980,7 +980,7 @@ mod tests {
|
|||||||
id: Some("m_summary".into()),
|
id: Some("m_summary".into()),
|
||||||
role: MessageRole::Assistant,
|
role: MessageRole::Assistant,
|
||||||
content: "压缩摘要".into(),
|
content: "压缩摘要".into(),
|
||||||
status: Some("compressed".into()),
|
status: Some(MessageStatus::Compressed),
|
||||||
..base_msg()
|
..base_msg()
|
||||||
},
|
},
|
||||||
ChatMessage {
|
ChatMessage {
|
||||||
|
|||||||
@@ -4081,7 +4081,7 @@ mod tests {
|
|||||||
id: "proj-svc-1".to_string(),
|
id: "proj-svc-1".to_string(),
|
||||||
name: "proj-svc-1".to_string(),
|
name: "proj-svc-1".to_string(),
|
||||||
description: String::new(),
|
description: String::new(),
|
||||||
status: "planning".to_string(),
|
status: ProjectStatus::Planning,
|
||||||
idea_id: None,
|
idea_id: None,
|
||||||
path: None,
|
path: None,
|
||||||
stack: None,
|
stack: None,
|
||||||
|
|||||||
@@ -128,7 +128,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { useI18n } from 'vue-i18n'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { listen } from '@tauri-apps/api/event'
|
import { listen } from '@tauri-apps/api/event'
|
||||||
@@ -143,6 +143,7 @@ import MaxRoundsCard from './ai/MaxRoundsCard.vue'
|
|||||||
import HelpRequiredCard from './ai/HelpRequiredCard.vue'
|
import HelpRequiredCard from './ai/HelpRequiredCard.vue'
|
||||||
import DirAuthDialog from './ai/DirAuthDialog.vue'
|
import DirAuthDialog from './ai/DirAuthDialog.vue'
|
||||||
import { useConfirm } from '../composables/useConfirm'
|
import { useConfirm } from '../composables/useConfirm'
|
||||||
|
import { useToast } from '../composables/useToast'
|
||||||
import { useProjectStore } from '../stores/project'
|
import { useProjectStore } from '../stores/project'
|
||||||
import type { AiMessage } from '../api/types'
|
import type { AiMessage } from '../api/types'
|
||||||
|
|
||||||
@@ -177,18 +178,10 @@ function sendExamplePrompt(i18nKey: string) {
|
|||||||
chatInputRef.value?.sendExamplePrompt(i18nKey)
|
chatInputRef.value?.sendExamplePrompt(i18nKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 错误 toast(零依赖,复用 Settings.vue 同款 reactive toast) ──
|
// ── 错误 toast(useToast composable,消除 AiChat/Settings/Projects/TaskDetail 4 处重复) ──
|
||||||
// AiChat 可能以分离窗口运行(App.vue 根 toast 不在 DOM 中),故本组件自管一个 toast;
|
// AiChat 可能以分离窗口运行(App.vue 根 toast 不在 DOM 中),故本组件自管一个 toast;
|
||||||
// i18n 不在本任务白名单,文案硬编码,后续补 aiChat.toastSendFail key
|
// i18n 不在本任务白名单,文案硬编码,后续补 aiChat.toastSendFail key
|
||||||
const toast = reactive({ visible: false, msg: '', type: 'error' as 'error' | 'warning' | 'info' })
|
const { toast, showToast } = useToast()
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// B-260616-12: 工具慢执行提示事件 unlistener(onMounted 注册,onBeforeUnmount 释放)
|
// B-260616-12: 工具慢执行提示事件 unlistener(onMounted 注册,onBeforeUnmount 释放)
|
||||||
let _unlistenToolSlow: (() => void) | null = null
|
let _unlistenToolSlow: (() => void) | null = null
|
||||||
@@ -460,7 +453,7 @@ onBeforeUnmount(() => {
|
|||||||
store.stopContextListener()
|
store.stopContextListener()
|
||||||
if (_unlistenToolSlow) { _unlistenToolSlow(); _unlistenToolSlow = null } // B-260616-12
|
if (_unlistenToolSlow) { _unlistenToolSlow(); _unlistenToolSlow = null } // B-260616-12
|
||||||
if (_unlistenAutoApproved) { _unlistenAutoApproved(); _unlistenAutoApproved = null } // AE-2025-04
|
if (_unlistenAutoApproved) { _unlistenAutoApproved(); _unlistenAutoApproved = null } // AE-2025-04
|
||||||
if (_toastTimer) { clearTimeout(_toastTimer); _toastTimer = null }
|
// toast timer 清理由 useToast composable 的 onUnmounted 自动管理
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
|||||||
@@ -50,7 +50,7 @@
|
|||||||
{{ conv.title || t('aiChat.newConversation') }}
|
{{ conv.title || t('aiChat.newConversation') }}
|
||||||
<span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span>
|
<span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span>
|
||||||
</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>
|
||||||
<div class="ai-conv-item-actions">
|
<div class="ai-conv-item-actions">
|
||||||
<div class="ai-conv-item-more">
|
<div class="ai-conv-item-more">
|
||||||
@@ -132,7 +132,7 @@
|
|||||||
<!-- F-09: 多会话并发生成指示器(后台/当前会话生成中均显脉冲点,多会话可同时显示) -->
|
<!-- F-09: 多会话并发生成指示器(后台/当前会话生成中均显脉冲点,多会话可同时显示) -->
|
||||||
<span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span>
|
<span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span>
|
||||||
</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>
|
||||||
<div class="ai-conv-item-actions">
|
<div class="ai-conv-item-actions">
|
||||||
<div class="ai-conv-item-more">
|
<div class="ai-conv-item-more">
|
||||||
@@ -177,7 +177,7 @@
|
|||||||
{{ conv.title || t('aiChat.newConversation') }}
|
{{ conv.title || t('aiChat.newConversation') }}
|
||||||
<span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span>
|
<span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span>
|
||||||
</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>
|
||||||
<div class="ai-conv-item-actions">
|
<div class="ai-conv-item-actions">
|
||||||
<div class="ai-conv-item-more">
|
<div class="ai-conv-item-more">
|
||||||
@@ -328,10 +328,6 @@ function onSelectSearchResult(id: string) {
|
|||||||
store.state.searchQuery = ''
|
store.state.searchQuery = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(ts: string): string {
|
|
||||||
return formatRelative(ts)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 对话分组:今天 / 昨天 / 更早 ──
|
// ── 对话分组:今天 / 昨天 / 更早 ──
|
||||||
const bucketLabels = computed<Record<string, string>>(() => ({
|
const bucketLabels = computed<Record<string, string>>(() => ({
|
||||||
today: t('aiChat.today'),
|
today: t('aiChat.today'),
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ import { ref, watch, computed, onUnmounted } from 'vue'
|
|||||||
import { moduleApi } from '@/api/module'
|
import { moduleApi } from '@/api/module'
|
||||||
import hljs from 'highlight.js/lib/common'
|
import hljs from 'highlight.js/lib/common'
|
||||||
import { useMarkdown, useRendered } from '@/composables/useMarkdown'
|
import { useMarkdown, useRendered } from '@/composables/useMarkdown'
|
||||||
|
import { escapeHtml } from '@/utils/html'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
moduleId: string
|
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 {
|
function joinPath(root: string, rel: string): string {
|
||||||
const sep = root.includes('\\') ? '\\' : '/'
|
const sep = root.includes('\\') ? '\\' : '/'
|
||||||
const normRel = rel.split('/').join(sep)
|
const normRel = rel.split('/').join(sep)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<span class="module-node__icon">📦</span>
|
<span class="module-node__icon">📦</span>
|
||||||
<span class="module-node__name">{{ data.name }}</span>
|
<span class="module-node__name">{{ data.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="module-node__path">{{ shortPath }}</div>
|
<div class="module-node__path">{{ truncatedPath }}</div>
|
||||||
<div v-if="stackList.length" class="module-node__tags">
|
<div v-if="stackList.length" class="module-node__tags">
|
||||||
<span v-for="s in stackList" :key="s" class="module-node__tag">{{ s }}</span>
|
<span v-for="s in stackList" :key="s" class="module-node__tag">{{ s }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { parseJsonArray } from '@/utils/json'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
data: {
|
data: {
|
||||||
@@ -24,21 +25,13 @@ const props = defineProps<{
|
|||||||
selected?: boolean
|
selected?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const shortPath = computed(() => {
|
const truncatedPath = computed(() => {
|
||||||
const p = props.data?.path || ''
|
const p = props.data?.path || ''
|
||||||
if (p.length <= 32) return p
|
if (p.length <= 32) return p
|
||||||
return '...' + p.slice(-29)
|
return '...' + p.slice(-29)
|
||||||
})
|
})
|
||||||
|
|
||||||
const stackList = computed(() => {
|
const stackList = computed(() => parseJsonArray(props.data?.stack).slice(0, 3))
|
||||||
if (!props.data?.stack) return []
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(props.data.stack)
|
|
||||||
return Array.isArray(parsed) ? parsed.slice(0, 3) : []
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -166,9 +166,7 @@ export function useRendered(getText: () => string): {
|
|||||||
return { rendered, ensureLoaded }
|
return { rendered, ensureLoaded }
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(text: string): string {
|
import { escapeHtml } from '@/utils/html'
|
||||||
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
||||||
}
|
|
||||||
|
|
||||||
/// marked 未就绪时的兜底渲染:HTML 转义 + 换行转 <br>(纯文本安全降级)
|
/// marked 未就绪时的兜底渲染:HTML 转义 + 换行转 <br>(纯文本安全降级)
|
||||||
function escapeFallback(text: string): string {
|
function escapeFallback(text: string): string {
|
||||||
@@ -217,7 +215,7 @@ const _DIFF_LINE_RE = /^[ \t]*[+-][^\n]*$/ // 行首可选空格/tab + +/- + 任
|
|||||||
const _PLUS_LINE_RE = /^[ \t]*\+[^\n]*$/ // 含 + 的行(+ 必须含的判定)
|
const _PLUS_LINE_RE = /^[ \t]*\+[^\n]*$/ // 含 + 的行(+ 必须含的判定)
|
||||||
const _FENCE_OPEN_RE = /^[ \t]{0,3}(```|~~~)/ // marked 围栏开头(行首 ≤3 空格 + 3+ 反引号/波浪)
|
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 (!text) return text
|
||||||
// 快速短路:全文无 +/- 行首特征直接返回原文(避免大文本白跑切段)
|
// 快速短路:全文无 +/- 行首特征直接返回原文(避免大文本白跑切段)
|
||||||
if (!/[+-]/.test(text)) return text
|
if (!/[+-]/.test(text)) return text
|
||||||
|
|||||||
@@ -12,10 +12,10 @@
|
|||||||
* 注:带 seq 竞态守卫的场景调用方需在 fn 内部判断,不在本工具职责范围。
|
* 注:带 seq 竞态守卫的场景调用方需在 fn 内部判断,不在本工具职责范围。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Ref } from 'vue'
|
// (Ref import 已移除,_Unused 类型已删除)
|
||||||
|
|
||||||
/** 带 error 字段的 store state 形状(仅约束 error,其他字段任意)。 */
|
/** 带 error 字段的 store state 形状(仅约束 error,其他字段任意)。仅本模块内部使用。 */
|
||||||
export interface ErrorSink {
|
interface ErrorSink {
|
||||||
error: string | null
|
error: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,12 +40,8 @@ export async function runWithCatch<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// stringifyError 仅本模块内部使用(runWithCatch/runWithCatchGuarded 调用),不导出。
|
||||||
* 把 unknown 错误转为可读字符串(无则返 undefined,由调用方兜底 i18n)。
|
function stringifyError(e: unknown): string | undefined {
|
||||||
*
|
|
||||||
* Tauri invoke 抛出的通常是 string 或 Error 实例。
|
|
||||||
*/
|
|
||||||
export function stringifyError(e: unknown): string | undefined {
|
|
||||||
if (typeof e === 'string') return e
|
if (typeof e === 'string') return e
|
||||||
if (e instanceof Error) return e.message || e.toString()
|
if (e instanceof Error) return e.message || e.toString()
|
||||||
if (e && typeof e === 'object' && 'toString' in e) {
|
if (e && typeof e === 'object' && 'toString' in e) {
|
||||||
@@ -81,5 +77,4 @@ export async function runWithCatchGuarded<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 显式标记 Ref 未使用(避免未来用上时改导入)
|
// _Unused 类型从未被外部 import,已删除(同时移除上方 Ref import)。
|
||||||
export type _Unused = Ref<unknown>
|
|
||||||
|
|||||||
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 }
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { reactive, computed } from 'vue'
|
|||||||
import { knowledgeApi } from '@/api'
|
import { knowledgeApi } from '@/api'
|
||||||
import { t } from '@/i18n/i18n-helpers'
|
import { t } from '@/i18n/i18n-helpers'
|
||||||
import { runWithCatch, runWithCatchGuarded } from '@/composables/useStoreAction'
|
import { runWithCatch, runWithCatchGuarded } from '@/composables/useStoreAction'
|
||||||
|
import { parseJsonArray } from '@/utils/json'
|
||||||
import type {
|
import type {
|
||||||
KnowledgeRecord,
|
KnowledgeRecord,
|
||||||
KnowledgeDetailPayload,
|
KnowledgeDetailPayload,
|
||||||
@@ -46,14 +47,8 @@ export const KNOWLEDGE_KINDS = [
|
|||||||
] as const
|
] as const
|
||||||
|
|
||||||
/** 解析 tags JSON 字符串为 string[] */
|
/** 解析 tags JSON 字符串为 string[] */
|
||||||
export function parseTags(tags: string | null): string[] {
|
export function parseTags(tags: string | null | undefined): string[] {
|
||||||
if (!tags) return []
|
return parseJsonArray(tags)
|
||||||
try {
|
|
||||||
const arr = JSON.parse(tags)
|
|
||||||
return Array.isArray(arr) ? arr : []
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 知识 label 文案辅助(列表 Knowledge.vue 与详情 KnowledgeDetail.vue 共用) ──
|
// ── 知识 label 文案辅助(列表 Knowledge.vue 与详情 KnowledgeDetail.vue 共用) ──
|
||||||
|
|||||||
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, '>')
|
||||||
|
}
|
||||||
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 各有一份重复实现,
|
//! parseStack 此前在 Projects.vue 与 ProjectDetail.vue 各有一份重复实现,
|
||||||
//! 现抽到此处统一维护。
|
//! 现抽到此处统一维护。
|
||||||
|
|
||||||
|
import { parseJsonArray } from './json'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析技术栈 JSON 数组字符串(防崩)。
|
* 解析技术栈 JSON 数组字符串(防崩)。
|
||||||
*
|
*
|
||||||
@@ -10,11 +12,5 @@
|
|||||||
* 解析失败或非数组时返回空数组,避免渲染崩溃。
|
* 解析失败或非数组时返回空数组,避免渲染崩溃。
|
||||||
*/
|
*/
|
||||||
export function parseStack(stack: string | null | undefined): string[] {
|
export function parseStack(stack: string | null | undefined): string[] {
|
||||||
if (!stack) return []
|
return parseJsonArray(stack)
|
||||||
try {
|
|
||||||
const arr = JSON.parse(stack)
|
|
||||||
return Array.isArray(arr) ? arr : []
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import { t, currentLocale } from '@/i18n/i18n-helpers'
|
|||||||
// CR-260615-08(P1-10):相对时间走 i18n(原硬编码中文,en locale 时间恒中文)。
|
// CR-260615-08(P1-10):相对时间走 i18n(原硬编码中文,en locale 时间恒中文)。
|
||||||
// 类型安全访问经 i18n-helpers 收口(规避 vue-i18n TS2589)。
|
// 类型安全访问经 i18n-helpers 收口(规避 vue-i18n TS2589)。
|
||||||
|
|
||||||
/** 解析任意时间值为毫秒时间戳;无效或空返回 null */
|
/** 解析任意时间值为毫秒时间戳;无效或空返回 null(仅本模块内部使用) */
|
||||||
export function parseTs(v: string | number | null | undefined): number | null {
|
function parseTs(v: string | number | null | undefined): number | null {
|
||||||
if (v == null) return null
|
if (v == null) return null
|
||||||
if (typeof v === 'number') {
|
if (typeof v === 'number') {
|
||||||
if (!Number.isFinite(v)) return null
|
if (!Number.isFinite(v)) return null
|
||||||
|
|||||||
@@ -188,7 +188,7 @@
|
|||||||
<div class="score-bar-track">
|
<div class="score-bar-track">
|
||||||
<div
|
<div
|
||||||
class="score-bar-fill"
|
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 + '%' }"
|
:style="{ width: dim.score + '%' }"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -276,7 +276,7 @@ import { useProjectStore } from '@/stores/project'
|
|||||||
import { projectApi } from '@/api'
|
import { projectApi } from '@/api'
|
||||||
import { formatDate } from '@/utils/time'
|
import { formatDate } from '@/utils/time'
|
||||||
import { parseStack } from '@/utils/project'
|
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 { projectStatusLabel, taskStatusLabel, taskStatusClass } from '../constants/project'
|
||||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||||
import ApprovalDialog from '@/components/project/ApprovalDialog.vue'
|
import ApprovalDialog from '@/components/project/ApprovalDialog.vue'
|
||||||
|
|||||||
@@ -164,7 +164,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { open } from '@tauri-apps/plugin-dialog'
|
import { open } from '@tauri-apps/plugin-dialog'
|
||||||
import { useProjectStore } from '@/stores/project'
|
import { useProjectStore } from '@/stores/project'
|
||||||
@@ -174,6 +174,7 @@ import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
|||||||
import ProjectCard from '@/components/project/ProjectCard.vue'
|
import ProjectCard from '@/components/project/ProjectCard.vue'
|
||||||
import Paginator from '@/components/Paginator.vue'
|
import Paginator from '@/components/Paginator.vue'
|
||||||
import { useConfirm } from '@/composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
|
import { useToast } from '@/composables/useToast'
|
||||||
import type { ProjectRecord } from '@/api/types'
|
import type { ProjectRecord } from '@/api/types'
|
||||||
import type { ScannedProjectItem } from '@/api/project'
|
import type { ScannedProjectItem } from '@/api/project'
|
||||||
|
|
||||||
@@ -303,14 +304,7 @@ onMounted(() => {
|
|||||||
store.loadProjects()
|
store.loadProjects()
|
||||||
})
|
})
|
||||||
|
|
||||||
// 卸载清 toast timer:防组件已离开视图后 4s 内 timer 触发向已销毁组件写状态。
|
// toast timer 清理由 useToast composable 的 onUnmounted 自动管理
|
||||||
// 对齐 Settings.vue 的 _toastTimer 清理模式(Knowledge.vue searchTimer 同模式)。
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (_toastTimer) {
|
|
||||||
clearTimeout(_toastTimer)
|
|
||||||
_toastTimer = null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── 导入历史项目(F-260614-06 scan 第二步) ──
|
// ── 导入历史项目(F-260614-06 scan 第二步) ──
|
||||||
const showImportModal = ref(false)
|
const showImportModal = ref(false)
|
||||||
@@ -320,14 +314,7 @@ const importScanning = ref(false)
|
|||||||
const importError = ref('')
|
const importError = ref('')
|
||||||
const importRunning = ref(false)
|
const importRunning = ref(false)
|
||||||
const selectedImportPaths = ref<Set<string>>(new Set())
|
const selectedImportPaths = ref<Set<string>>(new Set())
|
||||||
const toast = ref({ visible: false, msg: '', type: 'info' as 'info' | 'error' })
|
const { toast, showToast } = useToast(4000) // Projects 导入操作 toast 用 4000ms(操作类提示)
|
||||||
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 allImportSelected = computed(() =>
|
const allImportSelected = computed(() =>
|
||||||
scannedItems.value.length > 0
|
scannedItems.value.length > 0
|
||||||
|
|||||||
@@ -67,8 +67,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { useConfirm } from '@/composables/useConfirm'
|
||||||
|
import { useToast } from '@/composables/useToast'
|
||||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||||
import SettingsNav, { type SettingsCategory } from '@/components/settings/SettingsNav.vue'
|
import SettingsNav, { type SettingsCategory } from '@/components/settings/SettingsNav.vue'
|
||||||
import ProviderPanel from '@/components/settings/ProviderPanel.vue'
|
import ProviderPanel from '@/components/settings/ProviderPanel.vue'
|
||||||
@@ -90,17 +91,8 @@ import { t as ti18n } from '@/i18n/i18n-helpers'
|
|||||||
// (阶段3 UX 重构:左导航 + 右按 activeCategory 渲染,各功能域 state/methods 已拆到子组件)
|
// (阶段3 UX 重构:左导航 + 右按 activeCategory 渲染,各功能域 state/methods 已拆到子组件)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
// 顶部轻量提示(错误/警告/完成),3s 自动消失;零依赖,替代未接入的 Arco Message
|
// 顶部轻量提示(useToast composable,消除 4 处重复;默认 3000ms)
|
||||||
const toast = reactive({ visible: false, msg: '', type: 'info' as 'error' | 'warning' | 'info' })
|
const { toast, showToast } = useToast()
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 确认弹层状态机抽至 composables/useConfirm(原 4 视图重复:Projects/ProjectDetail/Ideas/Settings)
|
// 确认弹层状态机抽至 composables/useConfirm(原 4 视图重复:Projects/ProjectDetail/Ideas/Settings)
|
||||||
// 模板 confirmState.visible/msg 在 <script setup> 中自动解包 ref,沿用既有内联确认弹层
|
// 模板 confirmState.visible/msg 在 <script setup> 中自动解包 ref,沿用既有内联确认弹层
|
||||||
@@ -207,9 +199,8 @@ onMounted(() => {
|
|||||||
void loadAiProviders()
|
void loadAiProviders()
|
||||||
})
|
})
|
||||||
|
|
||||||
// 组件卸载清 toast/resize timer;各子面板自管自身 debounce timer(各自 onUnmounted)
|
// 组件卸载清 resize timer;toast 清理由 useToast 自动管理;各子面板自管自身 debounce timer
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (_toastTimer) clearTimeout(_toastTimer)
|
|
||||||
if (_resizeTimer) clearTimeout(_resizeTimer)
|
if (_resizeTimer) clearTimeout(_resizeTimer)
|
||||||
window.removeEventListener('resize', onResize)
|
window.removeEventListener('resize', onResize)
|
||||||
providerPanelRef.value?.clearPoolTimer()
|
providerPanelRef.value?.clearPoolTimer()
|
||||||
|
|||||||
Reference in New Issue
Block a user