重构: 前端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:
2026-07-03 00:18:21 +08:00
parent 249b3b9ea8
commit 3d8b755229
22 changed files with 163 additions and 124 deletions

View File

@@ -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]

View File

@@ -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 {

View File

@@ -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

View File

@@ -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
/// ///

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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 () => {

View File

@@ -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'),

View File

@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
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)

View File

@@ -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>

View File

@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
/// 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

View File

@@ -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>

View 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 }
}

View File

@@ -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
View File

@@ -0,0 +1,8 @@
/**
* HTML 转义 — 消除 useMarkdown / FilePreview 两处重复实现。
*/
/** 转义 &, <, > 三个核心 HTML 特殊字符 */
export function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}

23
src/utils/json.ts Normal file
View 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
}
}

View File

@@ -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 []
}
} }

View File

@@ -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

View File

@@ -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'

View File

@@ -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

View File

@@ -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()