新增: Phase2 阶段收尾(Sprint 1-20)
重构:删 5 零引用 crate(df-evolve/plugin/stages/task/traceability)+ 清死模块、ai.rs 拆 11 子 module、ai.ts 拆 6 composable、i18n 拆目录 功能:知识库全栈(df-project/scan + CRUD + 时间线 + 前端)、Settings 拆分、appSettings KV 迁移、模型池、LLM 并发 Semaphore 修复:审批持久化根治、ConditionEngine 默认拒绝、NodeRegistry unimplemented 清除、promote 补偿删除、工具结果截断 50KB、路径校验防 symlink 逃逸 文档:B-03 人工审批设计、决策记录三分档、规格契约自检、经验记录、todo 看板、PROGRESS 更新 详见 PROGRESS.md。src-tauri/儿童每日打卡应用/ 与本项目无关,已排除。
This commit is contained in:
93
src/App.vue
93
src/App.vue
@@ -51,6 +51,7 @@
|
||||
>
|
||||
<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>
|
||||
</router-link>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -79,7 +80,8 @@
|
||||
</aside>
|
||||
|
||||
<!-- ═══ Main (页面内容) ═══ -->
|
||||
<main class="df-main" v-show="!aiStore.state.maximized">
|
||||
<!-- 仅在 AI 面板可见且最大化时隐藏主内容;面板关闭时无论 maximized 状态如何都显示 -->
|
||||
<main class="df-main" v-show="!(aiStore.state.maximized && aiStore.state.panelOpen)">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="page" mode="out-in">
|
||||
<component :is="Component" />
|
||||
@@ -95,17 +97,39 @@
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<!-- 全局错误 toast(根级,主窗口与分离窗口共用)-->
|
||||
<transition name="toast">
|
||||
<div v-if="errorMsg" class="df-toast">⚠ {{ errorMsg }}</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useAiStore } from './stores/ai'
|
||||
import { useKnowledgeStore } from './stores/knowledge'
|
||||
import { useProjectStore } from './stores/project'
|
||||
import { useAppSettingsStore } from './stores/appSettings'
|
||||
import i18n from './i18n'
|
||||
import AiChat from './components/AiChat.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const isDetached = computed(() => route.path === '/ai-detached')
|
||||
const aiStore = useAiStore()
|
||||
const appSettings = useAppSettingsStore()
|
||||
|
||||
// ── 全局错误 toast(消费 projectStore.error,所有 action 失败统一反馈)──
|
||||
const projectStore = useProjectStore()
|
||||
const errorMsg = ref('')
|
||||
let errorTimer: ReturnType<typeof setTimeout> | null = null
|
||||
watch(() => projectStore.error, (err) => {
|
||||
if (!err) return
|
||||
errorMsg.value = err
|
||||
projectStore.clearError() // 重置,允许连续同值错误再次触发
|
||||
if (errorTimer) clearTimeout(errorTimer)
|
||||
errorTimer = setTimeout(() => { errorMsg.value = '' }, 3500)
|
||||
})
|
||||
|
||||
// 点击菜单自动退出最大化 → 切为分栏模式
|
||||
watch(() => route.path, (path) => {
|
||||
@@ -115,7 +139,8 @@ watch(() => route.path, (path) => {
|
||||
})
|
||||
|
||||
// ── AI 面板拖拽调宽 ──
|
||||
const panelWidth = ref(parseInt(localStorage.getItem('df-ai-width') || '500', 10))
|
||||
// useSetting 监听 appSettings 缓存:loadAll 异步填充后会自动刷新 ref
|
||||
const panelWidth = appSettings.useSetting<number>('df-ai-width', 500)
|
||||
const resizing = ref(false)
|
||||
|
||||
// maximized=全宽(flex:1填充) | sidebar=固定宽
|
||||
@@ -147,7 +172,7 @@ function startResize(e: MouseEvent) {
|
||||
resizing.value = false
|
||||
document.body.style.cursor = ''
|
||||
document.body.style.userSelect = ''
|
||||
localStorage.setItem('df-ai-width', String(panelWidth.value))
|
||||
void appSettings.set('df-ai-width', panelWidth.value)
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
}
|
||||
@@ -158,7 +183,7 @@ function startResize(e: MouseEvent) {
|
||||
|
||||
// 主题初始化
|
||||
function initTheme() {
|
||||
const saved = localStorage.getItem('df-theme') || 'dark'
|
||||
const saved = appSettings.get<string>('df-theme', 'dark')
|
||||
if (saved === 'light') {
|
||||
document.documentElement.setAttribute('data-theme', 'light')
|
||||
} else if (saved === 'system') {
|
||||
@@ -175,11 +200,58 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
// 1. 先把 SQLite 全表 KV 填进 appSettings 缓存(后续 get/useSetting 才能读到真实值)
|
||||
await appSettings.loadAll()
|
||||
// 2. 一次性迁移:把旧 localStorage 的 11 个 key 导入 SQLite(仅首次跑,之后 localStorage 清净)
|
||||
await migrateLegacyLocalStorage()
|
||||
// 3. 缓存已就绪,初始化主题/locale
|
||||
initTheme()
|
||||
const savedLang = appSettings.get<string | null>('df-language', null)
|
||||
if (savedLang) {
|
||||
i18n.global.locale.value = savedLang as 'zh-CN' | 'en'
|
||||
}
|
||||
window.addEventListener('keydown', handleKeydown)
|
||||
// 加载知识库候选数(供侧栏 knowledge badge 显示)
|
||||
knowledgeStore.loadCandidates()
|
||||
})
|
||||
|
||||
/// 一次性迁移:遍历遗留的 localStorage key,若有值则导入 appSettings(SQLite),
|
||||
/// 然后删除 localStorage 中的旧 key。导入时按各 key 原存格式解析成 JS 值再交给 set
|
||||
/// (set 内部会 JSON.stringify)。之后 localStorage 只剩 df-ai-gen / df-ai-text 流式快照。
|
||||
const LEGACY_KEYS_RAW = new Set(['df-theme', 'df-language', 'df-ai-language']) // 裸字符串
|
||||
const LEGACY_KEYS_JSON = new Set([ // 已 JSON.stringify(裸值或对象)
|
||||
'df-ai-width', 'df-ai-ui', 'df-show-token-usage',
|
||||
'df-llm-global-concurrency', 'df-llm-per-conv-concurrency',
|
||||
'df-ai-active-conv', 'df-connections',
|
||||
])
|
||||
function parseLegacy(key: string, raw: string): unknown {
|
||||
if (LEGACY_KEYS_RAW.has(key)) return raw // 裸字符串,如 'dark' / 'zh-CN'
|
||||
// JSON 类:有的存的是 JSON.stringify('zh-CN') → '"zh-CN"'(虽不在本集合,保险起见 parse)
|
||||
try {
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
async function migrateLegacyLocalStorage(): Promise<void> {
|
||||
const allKeys = [...LEGACY_KEYS_RAW, ...LEGACY_KEYS_JSON]
|
||||
for (const key of allKeys) {
|
||||
const raw = localStorage.getItem(key)
|
||||
if (raw === null || raw === '') continue
|
||||
try {
|
||||
await appSettings.set(key, parseLegacy(key, raw))
|
||||
} catch (e) {
|
||||
console.error(`[migrate] 导入 ${key} 失败:`, e)
|
||||
}
|
||||
localStorage.removeItem(key)
|
||||
}
|
||||
}
|
||||
|
||||
// 知识库候选数量(侧栏 badge)— 解包 computed 取值保持响应式
|
||||
const knowledgeStore = useKnowledgeStore()
|
||||
const candidateCount = computed(() => knowledgeStore.candidateCount)
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeydown)
|
||||
})
|
||||
@@ -437,4 +509,15 @@ const secondaryNav = [
|
||||
.page-leave-active { transition: opacity 0.12s var(--df-ease), transform 0.12s var(--df-ease); }
|
||||
.page-enter-from { opacity: 0; transform: translateY(6px); }
|
||||
.page-leave-to { opacity: 0; transform: translateY(-4px); }
|
||||
|
||||
/* ═══ 全局错误 toast ═══ */
|
||||
.df-toast {
|
||||
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
|
||||
background: var(--df-danger); color: #fff;
|
||||
padding: 10px 18px; border-radius: var(--df-radius-sm);
|
||||
font-size: 13px; z-index: 9999; max-width: 90vw;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
|
||||
}
|
||||
.toast-enter-active, .toast-leave-active { transition: all 0.25s var(--df-ease); }
|
||||
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translate(-50%, 10px); }
|
||||
</style>
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||
import type { AiChatEvent, AiConversationDetail, AiConversationSummary, AiProviderConfig } from './types'
|
||||
import type { AiChatEvent, AiConversationDetail, AiConversationSummary, AiProviderConfig, SkillInfo } from './types'
|
||||
|
||||
export const aiApi = {
|
||||
/** 发送消息(非阻塞,通过 ai-chat-event 流式返回) */
|
||||
sendMessage(message: string, language?: string): Promise<string> {
|
||||
return invoke('ai_chat_send', { message, language: language || 'zh-CN' })
|
||||
/** 发送消息(非阻塞,通过 ai-chat-event 流式返回);skill 传技能名则注入其 SKILL.md */
|
||||
sendMessage(message: string, language?: string, skill?: string): Promise<string> {
|
||||
return invoke('ai_chat_send', { message, language: language || 'zh-CN', skill: skill || null })
|
||||
},
|
||||
|
||||
/** 批准/拒绝工具调用 */
|
||||
@@ -15,6 +15,11 @@ export const aiApi = {
|
||||
return invoke('ai_approve', { toolCallId, approved })
|
||||
},
|
||||
|
||||
/** 查询某对话积压的待审批工具(重启后恢复 toolCard pending_approval 态用) */
|
||||
pendingToolCalls(convId: string): Promise<{ tool_call_id: string; conversation_id: string | null }[]> {
|
||||
return invoke('ai_pending_tool_calls', { convId })
|
||||
},
|
||||
|
||||
/** 清空对话 */
|
||||
clearChat(): Promise<void> {
|
||||
return invoke('ai_chat_clear')
|
||||
@@ -54,6 +59,21 @@ export const aiApi = {
|
||||
return invoke('ai_set_provider', { providerId })
|
||||
},
|
||||
|
||||
/** 删除 AI 提供商 */
|
||||
deleteProvider(providerId: string): Promise<void> {
|
||||
return invoke('ai_delete_provider', { providerId })
|
||||
},
|
||||
|
||||
/** 设置 LLM 调用并发上限(全局 / 单对话,运行时即时生效;软收敛不中断进行中调用) */
|
||||
setConcurrencyConfig(globalLimit: number, perConvLimit: number): Promise<void> {
|
||||
return invoke('ai_set_concurrency_config', { globalLimit, perConvLimit })
|
||||
},
|
||||
|
||||
/** 列出本机 Claude 技能(skills + commands + plugins) */
|
||||
listSkills(): Promise<SkillInfo[]> {
|
||||
return invoke('ai_list_skills')
|
||||
},
|
||||
|
||||
/** 监听 AI 聊天事件 */
|
||||
onEvent(callback: (event: AiChatEvent) => void): Promise<UnlistenFn> {
|
||||
return listen<AiChatEvent>('ai-chat-event', (e) => {
|
||||
@@ -68,9 +88,12 @@ export const aiApi = {
|
||||
return invoke('ai_conversation_create')
|
||||
},
|
||||
|
||||
/** 列出所有对话(摘要) */
|
||||
listConversations(): Promise<AiConversationSummary[]> {
|
||||
return invoke('ai_conversation_list')
|
||||
/** 列出对话(摘要;limit 默认 50,includeArchived 默认 false 隐藏归档) */
|
||||
listConversations(limit?: number, includeArchived?: boolean): Promise<AiConversationSummary[]> {
|
||||
return invoke('ai_conversation_list', {
|
||||
limit: limit ?? null,
|
||||
includeArchived: includeArchived ?? false,
|
||||
})
|
||||
},
|
||||
|
||||
/** 切换到指定对话 */
|
||||
@@ -87,4 +110,9 @@ export const aiApi = {
|
||||
renameConversation(conversationId: string, title: string): Promise<void> {
|
||||
return invoke('ai_conversation_rename', { conversationId, title })
|
||||
},
|
||||
|
||||
/** 归档/取消归档对话 */
|
||||
archiveConversation(conversationId: string, archived: boolean): Promise<void> {
|
||||
return invoke('ai_conversation_archive', { conversationId, archived })
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import type { IdeaRecord, CreateIdeaInput } from './types'
|
||||
import type { IdeaRecord, CreateIdeaInput, PromotionResult } from './types'
|
||||
|
||||
export const ideaApi = {
|
||||
list(): Promise<IdeaRecord[]> {
|
||||
return invoke('list_ideas')
|
||||
/** 列出想法,可选按 status 过滤(draft/pending_review/promoted 等) */
|
||||
list(status?: string): Promise<IdeaRecord[]> {
|
||||
return invoke('list_ideas', { status: status ?? null })
|
||||
},
|
||||
|
||||
create(input: CreateIdeaInput): Promise<IdeaRecord> {
|
||||
@@ -17,4 +18,14 @@ export const ideaApi = {
|
||||
delete(id: string): Promise<boolean> {
|
||||
return invoke('delete_idea', { id })
|
||||
},
|
||||
|
||||
/** 多维评分 + 对抗式评估,结果写回并返回更新后的记录 */
|
||||
evaluate(id: string): Promise<IdeaRecord> {
|
||||
return invoke('evaluate_idea', { id })
|
||||
},
|
||||
|
||||
/** 将想法晋升为项目,返回晋升结果(含 project_id) */
|
||||
promote(id: string): Promise<PromotionResult> {
|
||||
return invoke('promote_idea', { id })
|
||||
},
|
||||
}
|
||||
|
||||
@@ -4,3 +4,5 @@ export { taskApi } from './task'
|
||||
export { ideaApi } from './idea'
|
||||
export { workflowApi } from './workflow'
|
||||
export { aiApi } from './ai'
|
||||
export { knowledgeApi } from './knowledge'
|
||||
export { settingsApi } from './settings'
|
||||
|
||||
82
src/api/knowledge.ts
Normal file
82
src/api/knowledge.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import type {
|
||||
KnowledgeRecord,
|
||||
KnowledgeEventRecord,
|
||||
KnowledgeDetailPayload,
|
||||
CreateKnowledgeInput,
|
||||
KnowledgeSearchInput,
|
||||
UpdateKnowledgeInput,
|
||||
KnowledgeConfig,
|
||||
} from './types'
|
||||
|
||||
export const knowledgeApi = {
|
||||
/** 列出知识(可按 status 筛选,默认排除 archived) */
|
||||
list(status?: string | null): Promise<KnowledgeRecord[]> {
|
||||
return invoke('knowledge_list', { status: status ?? null })
|
||||
},
|
||||
|
||||
/** 单条查询 */
|
||||
get(id: string): Promise<KnowledgeRecord> {
|
||||
return invoke('knowledge_get', { id })
|
||||
},
|
||||
|
||||
/** 检索知识(top-N,默认 3) */
|
||||
search(input: KnowledgeSearchInput): Promise<KnowledgeRecord[]> {
|
||||
return invoke('knowledge_search', { input })
|
||||
},
|
||||
|
||||
/** 创建知识(始终 candidate 状态) */
|
||||
create(input: CreateKnowledgeInput): Promise<KnowledgeRecord> {
|
||||
return invoke('knowledge_create', { input })
|
||||
},
|
||||
|
||||
/** 状态转换(含合法矩阵校验) */
|
||||
updateStatus(id: string, status: string): Promise<boolean> {
|
||||
return invoke('knowledge_update_status', { id, status })
|
||||
},
|
||||
|
||||
/** 复用计数 +1 */
|
||||
recordReuse(id: string): Promise<boolean> {
|
||||
return invoke('knowledge_record_reuse', { id })
|
||||
},
|
||||
|
||||
/** 审核收件箱(候选列表,按置信度排序) */
|
||||
listCandidates(): Promise<KnowledgeRecord[]> {
|
||||
return invoke('knowledge_list_candidates')
|
||||
},
|
||||
|
||||
/** 归档(软删除) */
|
||||
archive(id: string): Promise<boolean> {
|
||||
return invoke('knowledge_archive', { id })
|
||||
},
|
||||
|
||||
/** 读取知识库行为配置 */
|
||||
getConfig(): Promise<KnowledgeConfig> {
|
||||
return invoke('knowledge_get_config')
|
||||
},
|
||||
|
||||
/** 保存知识库行为配置 */
|
||||
saveConfig(config: KnowledgeConfig): Promise<boolean> {
|
||||
return invoke('knowledge_save_config', { config })
|
||||
},
|
||||
|
||||
/** 手动触发提炼 */
|
||||
extractNow(): Promise<boolean> {
|
||||
return invoke('knowledge_extract_now')
|
||||
},
|
||||
|
||||
/** 知识详情(基本信息 + 生命线事件) */
|
||||
getDetail(id: string): Promise<KnowledgeDetailPayload> {
|
||||
return invoke('knowledge_get_detail', { id })
|
||||
},
|
||||
|
||||
/** 编辑知识(部分更新) */
|
||||
update(id: string, input: UpdateKnowledgeInput): Promise<KnowledgeRecord> {
|
||||
return invoke('knowledge_update', { id, input })
|
||||
},
|
||||
|
||||
/** 查询生命线事件(可按 event_type 过滤) */
|
||||
events(knowledgeId: string, eventType?: string, limit?: number): Promise<KnowledgeEventRecord[]> {
|
||||
return invoke('knowledge_events', { knowledgeId, eventType: eventType ?? null, limit: limit ?? null })
|
||||
},
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import type { ProjectRecord, CreateProjectInput } from './types'
|
||||
import type { ProjectRecord, CreateProjectInput, AiScanResult } from './types'
|
||||
|
||||
export const projectApi = {
|
||||
list(): Promise<ProjectRecord[]> {
|
||||
@@ -21,4 +21,41 @@ export const projectApi = {
|
||||
delete(id: string): Promise<boolean> {
|
||||
return invoke('delete_project', { id })
|
||||
},
|
||||
|
||||
listDeleted(): Promise<ProjectRecord[]> {
|
||||
return invoke('list_deleted_projects')
|
||||
},
|
||||
|
||||
restore(id: string): Promise<boolean> {
|
||||
return invoke('restore_project', { id })
|
||||
},
|
||||
|
||||
purge(id: string): Promise<boolean> {
|
||||
return invoke('purge_project', { id })
|
||||
},
|
||||
|
||||
/** 探测目录技术栈(选目录后实时预览) */
|
||||
scanStack(path: string): Promise<string[]> {
|
||||
return invoke('scan_project_stack', { path })
|
||||
},
|
||||
|
||||
/** 检查目录是否已被其他项目绑定(防重复,excludeId 排除自身) */
|
||||
checkBinding(path: string, excludeId?: string): Promise<ProjectRecord | null> {
|
||||
return invoke('check_path_binding', { path, excludeId })
|
||||
},
|
||||
|
||||
/** 重定位项目目录(校验+重探测 stack,返回最新记录) */
|
||||
relocatePath(id: string, newPath: string): Promise<ProjectRecord> {
|
||||
return invoke('relocate_project_path', { id, newPath })
|
||||
},
|
||||
|
||||
/** 检查目录是否存在(详情页"目录是否还在") */
|
||||
checkPathExists(path: string): Promise<boolean> {
|
||||
return invoke('check_path_exists', { path })
|
||||
},
|
||||
|
||||
/** AI 扫描目录,自动分析基础信息(description/stack/project_type) */
|
||||
scanWithAi(path: string): Promise<AiScanResult> {
|
||||
return invoke('scan_project_with_ai', { path })
|
||||
},
|
||||
}
|
||||
|
||||
28
src/api/settings.ts
Normal file
28
src/api/settings.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
//! 应用设置 KV API — IPC invoke 封装(localStorage → SQLite 迁移目标)
|
||||
//!
|
||||
//! 后端 4 个 command 读写 `app_settings` 表,value 在 SQLite 里永远是 JSON 字符串。
|
||||
//! 本层只做透传(不解析 JSON),JSON.parse / JSON.stringify 由 store 层负责。
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
export const settingsApi = {
|
||||
/** 取单个 key 的原始值(JSON 字符串),不存在返回 null */
|
||||
get(key: string): Promise<string | null> {
|
||||
return invoke<string | null>('settings_get', { key })
|
||||
},
|
||||
|
||||
/** 写 key/value(`INSERT OR REPLACE`),成功返回 true */
|
||||
set(key: string, value: string): Promise<boolean> {
|
||||
return invoke<boolean>('settings_set', { key, value })
|
||||
},
|
||||
|
||||
/** 取全部 key/value(启动时一次性拉回恢复偏好) */
|
||||
getAll(): Promise<Record<string, string>> {
|
||||
return invoke<Record<string, string>>('settings_get_all')
|
||||
},
|
||||
|
||||
/** 删除单个 key,成功返回 true */
|
||||
delete(key: string): Promise<boolean> {
|
||||
return invoke<boolean>('settings_delete', { key })
|
||||
},
|
||||
}
|
||||
132
src/api/types.ts
132
src/api/types.ts
@@ -38,6 +38,10 @@ export interface ProjectRecord {
|
||||
description: string
|
||||
status: string // planning | in_progress | paused | completed | cancelled
|
||||
idea_id: string | null
|
||||
/** 绑定的本地代码目录绝对路径(null=未绑定) */
|
||||
path: string | null
|
||||
/** 技术栈 JSON 数组字符串(如 ["rust","vue"],null=未探测) */
|
||||
stack: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
@@ -46,6 +50,30 @@ export interface CreateProjectInput {
|
||||
name: string
|
||||
description?: string
|
||||
idea_id?: string
|
||||
/** 绑定的本地代码目录(可选) */
|
||||
path?: string
|
||||
/** 技术栈 JSON 数组字符串(可选,空则后端自动探测) */
|
||||
stack?: string
|
||||
}
|
||||
|
||||
/** AI 扫描项目结果(预览用,用户确认后填入) */
|
||||
export interface AiScanResult {
|
||||
/** 项目摘要(空=LLM 未得出,需手填) */
|
||||
description: string
|
||||
/** 技术栈(规则 ∪ LLM,去重小写) */
|
||||
stack: string[]
|
||||
/** 项目类型(web/api/cli/library/desktop/mobile/monorepo/other) */
|
||||
project_type: string | null
|
||||
/** LLM 原始返回(降级时含错误信息) */
|
||||
raw: string | null
|
||||
}
|
||||
|
||||
/** 想法晋升结果(后端 promote_idea 返回) */
|
||||
export interface PromotionResult {
|
||||
idea_id: string
|
||||
project_id: string
|
||||
promoted: boolean
|
||||
reason: string
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -141,7 +169,7 @@ export type AiChatEvent = ({
|
||||
} | {
|
||||
type: 'AiApprovalResult'; id: string; approved: boolean
|
||||
} | {
|
||||
type: 'AiCompleted'; total_tokens: number
|
||||
type: 'AiCompleted'; total_tokens: number; prompt_tokens: number; completion_tokens: number
|
||||
} | {
|
||||
type: 'AiError'; error: string
|
||||
} | {
|
||||
@@ -157,6 +185,8 @@ export interface AiMessage {
|
||||
content: string
|
||||
isError?: boolean
|
||||
toolCalls?: AiToolCallInfo[]
|
||||
/** 生成该消息的 model(仅 assistant 消息,历史消息从 DB 读) */
|
||||
model?: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
@@ -167,6 +197,8 @@ export interface AiToolCallInfo {
|
||||
args: unknown
|
||||
status: 'running' | 'pending_approval' | 'completed' | 'rejected'
|
||||
result?: unknown
|
||||
/** 审批风险说明(AiApprovalRequired 时填充) */
|
||||
reason?: string
|
||||
}
|
||||
|
||||
/** 对话列表摘要 */
|
||||
@@ -175,6 +207,10 @@ export interface AiConversationSummary {
|
||||
title: string | null
|
||||
provider_id: string | null
|
||||
model: string | null
|
||||
models?: string[] | null
|
||||
archived: boolean
|
||||
prompt_tokens?: number | null
|
||||
completion_tokens?: number | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
@@ -202,3 +238,97 @@ export interface AiConversationDetail {
|
||||
title: string | null
|
||||
messages: string // JSON string of ChatMessage[]
|
||||
}
|
||||
|
||||
/** 技能信息(本机 Claude skill,供 `/` 联想 + 注入) */
|
||||
export interface SkillInfo {
|
||||
name: string
|
||||
description: string
|
||||
argument_hint?: string
|
||||
/** skill | command | plugin */
|
||||
source: string
|
||||
/** SKILL.md 绝对路径(后端注入时读全文) */
|
||||
path: string
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 知识库
|
||||
// ============================================================
|
||||
|
||||
/** 知识条目(与 Rust KnowledgeRecord 严格对齐) */
|
||||
export interface KnowledgeRecord {
|
||||
id: string
|
||||
kind: string // review_rule | prompt_template | pitfall | architecture_pattern | diagnosis | deployment_note | workflow_optimization
|
||||
title: string
|
||||
content: string
|
||||
tags: string | null // JSON 数组字符串
|
||||
status: string // candidate | pending_review | published | archived
|
||||
confidence: string | null // high | medium | low
|
||||
reuse_count: number
|
||||
verified: boolean
|
||||
source_project: string | null
|
||||
source_ref: string | null
|
||||
/** AI 提炼判断依据("为何值得沉淀"),手动录入为 null */
|
||||
reasoning: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** 创建知识入参 */
|
||||
export interface CreateKnowledgeInput {
|
||||
kind: string
|
||||
title: string
|
||||
content: string
|
||||
tags?: string
|
||||
source_project?: string
|
||||
source_ref?: string
|
||||
confidence?: string
|
||||
}
|
||||
|
||||
/** 检索知识入参 */
|
||||
export interface KnowledgeSearchInput {
|
||||
query: string
|
||||
kind?: string
|
||||
limit?: number
|
||||
}
|
||||
|
||||
/** 知识生命线事件记录(产生/审核/引用/归档审计) */
|
||||
export interface KnowledgeEventRecord {
|
||||
id: string
|
||||
knowledge_id: string
|
||||
/** created | extracted | status_changed | referenced */
|
||||
event_type: string
|
||||
source_ref: string | null
|
||||
/** JSON 字符串,内容因 event_type 而异(见后端 KnowledgeTimeline) */
|
||||
context_json: string | null
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
/** 知识详情聚合负载(基本信息 + 全部生命线事件) */
|
||||
export interface KnowledgeDetailPayload {
|
||||
knowledge: KnowledgeRecord
|
||||
events: KnowledgeEventRecord[]
|
||||
}
|
||||
|
||||
/** 编辑知识入参(部分更新,仅传需改字段) */
|
||||
export interface UpdateKnowledgeInput {
|
||||
title?: string
|
||||
content?: string
|
||||
tags?: string
|
||||
confidence?: string
|
||||
reasoning?: string
|
||||
}
|
||||
|
||||
/** 知识库行为配置(提取 + 注入 + 向量检索) */
|
||||
export interface KnowledgeConfig {
|
||||
auto_extract: boolean
|
||||
trigger_mode: 'on_complete' | 'on_idle' | 'manual_only'
|
||||
min_messages: number
|
||||
idle_timeout_ms: number
|
||||
auto_inject: boolean
|
||||
/** 语义检索(向量)开关,默认 false——关闭时纯 LIKE 零外部依赖 */
|
||||
vector_enabled: boolean
|
||||
/** embedding 用的 provider id(仅 openai_compat 类型) */
|
||||
embedding_provider_id: string | null
|
||||
/** embedding 模型名(如 embedding-3) */
|
||||
embedding_model: string | null
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
96
src/components/ConfirmDialog.vue
Normal file
96
src/components/ConfirmDialog.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<!-- 自建确认弹层:替代 window.confirm(后者在 Tauri webview 带 localhost:port 来源信息,无法去除)。
|
||||
组件自包含:按钮样式内联,不依赖外部 .btn-*,便于在任何视图复用。 -->
|
||||
<Transition name="confirm">
|
||||
<div v-if="visible" class="confirm-mask" @click.self="$emit('result', false)">
|
||||
<div class="confirm-box">
|
||||
<div class="confirm-msg">{{ msg }}</div>
|
||||
<div class="confirm-actions">
|
||||
<button class="confirm-btn confirm-btn--cancel" @click="$emit('result', false)">{{ $t('common.cancel') }}</button>
|
||||
<button class="confirm-btn confirm-btn--danger" @click="$emit('result', true)">{{ dangerLabel || $t('common.delete') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{
|
||||
visible: boolean
|
||||
msg: string
|
||||
/** 危险按钮文案;不传则回退到 common.delete(随语言切换) */
|
||||
dangerLabel?: string
|
||||
}>(), {
|
||||
dangerLabel: '',
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
result: [ok: boolean]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.confirm-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
.confirm-box {
|
||||
background: var(--df-bg-card, #fff);
|
||||
border: 0.5px solid var(--df-border, #ddd);
|
||||
border-radius: var(--df-radius-lg, 8px);
|
||||
padding: 20px 24px;
|
||||
min-width: 280px;
|
||||
max-width: 360px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.confirm-msg {
|
||||
font-size: 13px;
|
||||
color: var(--df-text, #333);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.confirm-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
.confirm-btn {
|
||||
padding: 5px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
font-family: var(--df-font-sans);
|
||||
border-radius: var(--df-radius-sm, 4px);
|
||||
border: 0.5px solid var(--df-border, #ddd);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.confirm-btn--cancel {
|
||||
background: transparent;
|
||||
color: var(--df-text-dim, #888);
|
||||
}
|
||||
.confirm-btn--cancel:hover {
|
||||
background: var(--df-bg-card-hover, #f0f0f0);
|
||||
color: var(--df-text, #333);
|
||||
}
|
||||
.confirm-btn--danger {
|
||||
background: var(--df-danger, #e5484d);
|
||||
color: #fff;
|
||||
border-color: var(--df-danger, #e5484d);
|
||||
}
|
||||
.confirm-btn--danger:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
.confirm-enter-active,
|
||||
.confirm-leave-active {
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.confirm-enter-from,
|
||||
.confirm-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
678
src/components/ToolCard.vue
Normal file
678
src/components/ToolCard.vue
Normal file
@@ -0,0 +1,678 @@
|
||||
<template>
|
||||
<div
|
||||
class="ai-tool-card"
|
||||
:class="['ai-tool-card--' + tc.status, { 'ai-tool-card--collapsed': !isExpanded && !shouldKeepOpen(tc) }]"
|
||||
>
|
||||
<!-- 卡片头部:图标 + 名称 + 状态 -->
|
||||
<div class="ai-tool-header" @click="emit('toggle', tc.id)">
|
||||
<span class="ai-tool-icon-wrap" :class="'ai-tool-icon-wrap--' + toolCategory(tc.name)">
|
||||
<span v-html="toolIcon(tc.name)"></span>
|
||||
</span>
|
||||
<div class="ai-tool-header-text">
|
||||
<span class="ai-tool-name">{{ toolDisplayName(tc) }}</span>
|
||||
<span v-if="tc.status === 'running'" class="ai-tool-sub">{{ $t('aiTool.running') }}</span>
|
||||
<span v-else-if="toolResultSummary(tc)" class="ai-tool-sub">{{ toolResultSummary(tc) }}</span>
|
||||
</div>
|
||||
<span class="ai-tool-status-dot" :class="'ai-tool-status-dot--' + tc.status"></span>
|
||||
<span v-if="!shouldKeepOpen(tc)" class="ai-tool-collapse-hint" :class="{ 'is-open': isExpanded }">▸</span>
|
||||
</div>
|
||||
|
||||
<!-- Body: 收起态隐藏(running/pending 强制展开) -->
|
||||
<div v-show="isExpanded || shouldKeepOpen(tc)" class="ai-tool-body">
|
||||
|
||||
<!-- Running 骨架屏 -->
|
||||
<div v-if="tc.status === 'running'" class="ai-tool-skeleton">
|
||||
<div class="ai-tool-skeleton-line" style="width:60%"></div>
|
||||
<div class="ai-tool-skeleton-line" style="width:85%"></div>
|
||||
<div class="ai-tool-skeleton-line" style="width:40%"></div>
|
||||
</div>
|
||||
|
||||
<!-- 审批:参数内容 + 风险提示 + 按钮 -->
|
||||
<div v-if="tc.status === 'pending_approval'" class="ai-tool-approval">
|
||||
<div v-if="toolArgsEntries(tc.args).length" class="ai-tool-approval-args">
|
||||
<div v-for="arg in toolArgsEntries(tc.args)" :key="arg.key" class="ai-tool-arg">
|
||||
<span class="ai-tool-arg-key">{{ arg.key }}</span>
|
||||
<span class="ai-tool-arg-val">{{ formatArgValue(arg.val) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="tc.reason" class="ai-tool-approval-reason">⚠ {{ tc.reason }}</div>
|
||||
<div class="ai-tool-actions">
|
||||
<button class="ai-tool-btn ai-tool-btn--approve" @click="emit('approve', { id: tc.id, approved: true })">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
{{ $t('aiTool.approve') }}
|
||||
</button>
|
||||
<button class="ai-tool-btn ai-tool-btn--reject" @click="emit('approve', { id: tc.id, approved: false })">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" 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>
|
||||
{{ $t('aiTool.reject') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 已拒绝提示 -->
|
||||
<div v-if="tc.status === 'rejected'" class="ai-tool-rejected">
|
||||
<span class="ai-tool-rejected-icon">✕</span>
|
||||
{{ tc.result || $t('aiTool.rejectedHint') }}
|
||||
</div>
|
||||
|
||||
<!-- read_file 结果:代码预览 -->
|
||||
<div v-if="tc.name === 'read_file' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-file-content">
|
||||
<div class="ai-tool-file-bar">
|
||||
<span class="ai-tool-file-icon" v-html="fileIcon"></span>
|
||||
<span class="ai-tool-file-path">{{ parsed?.path }}</span>
|
||||
<span class="ai-tool-file-meta">{{ parsed?.lines || 0 }} {{ $t('aiTool.lines') }} · {{ formatBytes(parsed?.size) }}</span>
|
||||
<button class="ai-tool-expand-btn" @click="emit('expand-content', tc.id)">
|
||||
{{ isContentExpanded ? $t('aiTool.collapse') : $t('aiTool.expandAll') }}
|
||||
</button>
|
||||
</div>
|
||||
<pre class="ai-tool-file-pre" :class="{ 'ai-tool-file-pre--collapsed': !isContentExpanded }"><code>{{ parsed?.content }}</code></pre>
|
||||
</div>
|
||||
|
||||
<!-- list_directory 结果:文件树 -->
|
||||
<div v-else-if="tc.name === 'list_directory' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-dir-list">
|
||||
<div class="ai-tool-file-bar">
|
||||
<span class="ai-tool-file-icon ai-tool-file-icon--dir" v-html="dirIcon"></span>
|
||||
<span class="ai-tool-file-path">{{ parsed?.path }}</span>
|
||||
<span class="ai-tool-file-meta">{{ parsed?.entries?.length || 0 }} {{ $t('aiTool.items') }}</span>
|
||||
</div>
|
||||
<div class="ai-tool-dir-entries">
|
||||
<div
|
||||
v-for="(entry, i) in parsed?.entries"
|
||||
:key="i"
|
||||
class="ai-tool-dir-entry"
|
||||
:class="'ai-tool-dir-entry--' + entry.type"
|
||||
:style="{ paddingLeft: `${12 + (entry.depth || 0) * 16}px` }"
|
||||
>
|
||||
<span class="ai-tool-dir-icon" :class="entry.type === 'directory' ? 'ai-tool-dir-icon--folder' : 'ai-tool-dir-icon--file'" v-html="entry.type === 'directory' ? dirIcon : fileIcon"></span>
|
||||
<span class="ai-tool-dir-name">{{ entry.name }}</span>
|
||||
<span v-if="entry.type === 'file'" class="ai-tool-dir-size">{{ formatBytes(entry.size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- write_file 结果 -->
|
||||
<div v-else-if="tc.name === 'write_file' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-write-result">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--df-success)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
|
||||
<span class="ai-tool-write-path">{{ parsed?.path }}</span>
|
||||
<span class="ai-tool-write-meta">{{ formatBytes(parsed?.bytes_written) }} {{ $t('aiTool.written') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 通用 CRUD 结果 -->
|
||||
<div v-else-if="tc.result && tc.status === 'completed'" class="ai-tool-result">
|
||||
<code>{{ formatToolResult(tc) }}</code>
|
||||
</div>
|
||||
</div><!-- /.ai-tool-body -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import type { AiToolCallInfo } from '../api/types'
|
||||
|
||||
/** 工具结果 JSON 已知字段(全可选;list_* 运行时返回数组,靠 Array.isArray 区分) */
|
||||
interface ToolResult {
|
||||
path?: string
|
||||
content?: string
|
||||
lines?: number
|
||||
size?: number
|
||||
bytes_written?: number
|
||||
entries?: Array<{ name: string; type: string; size?: number; depth?: number }>
|
||||
name?: string
|
||||
title?: string
|
||||
field?: string
|
||||
deleted?: boolean
|
||||
}
|
||||
|
||||
/** 内联 SVG 图标字符串(经 v-html 注入,常量安全;图标库改造见决策记录) */
|
||||
const dirIcon = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>'
|
||||
const fileIcon = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>'
|
||||
|
||||
/** 默认保持展开:执行中/待审批/已拒绝/write_file——折叠无收益 */
|
||||
function shouldKeepOpen(tc: AiToolCallInfo): boolean {
|
||||
return tc.status === 'running'
|
||||
|| tc.status === 'pending_approval'
|
||||
|| tc.status === 'rejected'
|
||||
|| tc.name === 'write_file'
|
||||
}
|
||||
|
||||
function formatToolName(name: string): string {
|
||||
return name.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
||||
}
|
||||
|
||||
function formatJson(data: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(data, null, 2)
|
||||
} catch {
|
||||
return String(data)
|
||||
}
|
||||
}
|
||||
|
||||
/** 审批参数转键值对数组(非对象/空时返回空数组,防 args 非 object 时 v-for 报错) */
|
||||
function toolArgsEntries(args: unknown): Array<{ key: string; val: unknown }> {
|
||||
if (args && typeof args === 'object' && !Array.isArray(args)) {
|
||||
return Object.entries(args as Record<string, unknown>).map(([key, val]) => ({ key, val }))
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/** 格式化单个审批参数值:字符串直接展示(超长截断),对象/数组 JSON 化后截断 */
|
||||
function formatArgValue(val: unknown): string {
|
||||
if (val == null) return ''
|
||||
if (typeof val === 'string') return val.length > 300 ? val.slice(0, 300) + '…' : val
|
||||
if (typeof val === 'object') {
|
||||
try {
|
||||
const s = JSON.stringify(val)
|
||||
return s.length > 300 ? s.slice(0, 300) + '…' : s
|
||||
} catch {
|
||||
return String(val)
|
||||
}
|
||||
}
|
||||
return String(val)
|
||||
}
|
||||
|
||||
/** 安全解析工具结果 JSON;list_* 运行时是数组,类型归 ToolResult 靠 Array.isArray 区分 */
|
||||
function parseResult(result: unknown): ToolResult | null {
|
||||
if (!result) return null
|
||||
try {
|
||||
return (typeof result === 'string' ? JSON.parse(result) : result) as ToolResult
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function toolCategory(name: string): string {
|
||||
if (name === 'read_file') return 'file-read'
|
||||
if (name === 'write_file') return 'file-write'
|
||||
if (name === 'list_directory') return 'dir'
|
||||
if (name.includes('create')) return 'create'
|
||||
if (name.includes('delete')) return 'delete'
|
||||
if (name.includes('list')) return 'list'
|
||||
return 'default'
|
||||
}
|
||||
|
||||
function toolIcon(name: string): string {
|
||||
if (name === 'read_file') return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>'
|
||||
if (name === 'write_file') return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>'
|
||||
if (name === 'list_directory') return dirIcon
|
||||
if (name.includes('create')) return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>'
|
||||
if (name.includes('delete')) return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>'
|
||||
return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>'
|
||||
}
|
||||
|
||||
function shortPath(p: string): string {
|
||||
const parts = p.replace(/\\/g, '/').split('/')
|
||||
return parts.length <= 2 ? p : '.../' + parts.slice(-2).join('/')
|
||||
}
|
||||
|
||||
/** 格式化字节数 */
|
||||
function formatBytes(bytes: number | undefined): string {
|
||||
if (!bytes) return '0 B'
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
return (bytes / 1048576).toFixed(1) + ' MB'
|
||||
}
|
||||
|
||||
/** 通用工具结果格式化(非文件类工具) */
|
||||
function formatToolResult(tc: AiToolCallInfo): string {
|
||||
const r = parseResult(tc.result)
|
||||
if (!r) {
|
||||
// 非 JSON(纯文本:审批占位/错误/拒绝)→ 原样显示,审批占位时补拟执行参数避免空白
|
||||
const raw = typeof tc.result === 'string' ? tc.result : ''
|
||||
if (raw && tc.args && typeof tc.args === 'object') {
|
||||
const a = tc.args as any
|
||||
const detail = a.title || a.name || a.path || ''
|
||||
if (detail) return `${raw}(${detail})`
|
||||
}
|
||||
return raw
|
||||
}
|
||||
if (tc.name === 'write_file') {
|
||||
return `${r.path} (${formatBytes(r.bytes_written)})`
|
||||
}
|
||||
return formatJson(r)
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
/** 单条工具调用数据 */
|
||||
tc: AiToolCallInfo
|
||||
/** 卡片级展开(来自父级 expandedCards) */
|
||||
isExpanded: boolean
|
||||
/** 内容级展开(read_file 的"展开全部",来自父级 expandedTools) */
|
||||
isContentExpanded: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** header 点击 → 卡片折叠/展开 */
|
||||
toggle: [id: string]
|
||||
/** read_file "展开全部/收起" → 内容级展开 */
|
||||
'expand-content': [id: string]
|
||||
/** 审批按钮 → 父级转发 store.approveToolCall */
|
||||
approve: [{ id: string; approved: boolean }]
|
||||
}>()
|
||||
|
||||
/** 一次解析结果供模板多次读(模板引用 6 次,computed 避免每次 patch 重 parse) */
|
||||
const parsed = computed(() => parseResult(props.tc.result))
|
||||
|
||||
/** 工具显示名称(含路径摘要);无 path 时硬编码 fallback(path 几乎总有,i18n 兜底属过度防御) */
|
||||
function toolDisplayName(tc: AiToolCallInfo): string {
|
||||
const p = (tc.args as any)?.path as string | undefined
|
||||
switch (tc.name) {
|
||||
case 'read_file': return p ? `${t('aiTool.readPrefix')} ${shortPath(p)}` : t('aiTool.readFallback')
|
||||
case 'list_directory': return p ? `${t('aiTool.dirPrefix')} ${shortPath(p)}` : t('aiTool.dirFallback')
|
||||
case 'write_file': return p ? `${t('aiTool.writePrefix')} ${shortPath(p)}` : t('aiTool.writeFallback')
|
||||
default: return formatToolName(tc.name)
|
||||
}
|
||||
}
|
||||
|
||||
/** completed 工具结果的一句话摘要(折叠态 header 也能看到,避免光秃秃) */
|
||||
function toolResultSummary(tc: AiToolCallInfo): string {
|
||||
if (tc.status !== 'completed') return ''
|
||||
const r = parseResult(tc.result)
|
||||
if (!r) return ''
|
||||
switch (tc.name) {
|
||||
case 'list_tasks': return Array.isArray(r) ? t('aiTool.taskCount', { n: r.length }) : ''
|
||||
case 'list_projects': return Array.isArray(r) ? t('aiTool.projectCount', { n: r.length }) : ''
|
||||
case 'list_ideas': return Array.isArray(r) ? t('aiTool.ideaCount', { n: r.length }) : ''
|
||||
case 'create_project': return r.name ? t('aiTool.createdWithName', { name: r.name }) : t('aiTool.created')
|
||||
case 'create_idea':
|
||||
case 'create_task': return r.title ? t('aiTool.createdWithName', { name: r.title }) : t('aiTool.created')
|
||||
case 'update_project': return r.field ? t('aiTool.updatedField', { field: r.field }) : t('aiTool.updated')
|
||||
case 'delete_project': return r.deleted ? t('aiTool.deleted') : t('aiTool.deleteIneffective')
|
||||
case 'run_workflow': return t('aiTool.workflowHint')
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ai-tool-card {
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius);
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
max-width: var(--df-msg-max-width);
|
||||
animation: fadeInUp 0.2s var(--df-ease);
|
||||
transition: border-color 0.2s var(--df-ease);
|
||||
}
|
||||
.ai-tool-card--pending_approval {
|
||||
border-color: rgba(240,199,94,0.3);
|
||||
box-shadow: 0 0 0 1px rgba(240,199,94,0.06);
|
||||
}
|
||||
.ai-tool-card--completed {
|
||||
border-color: rgba(61,219,160,0.2);
|
||||
}
|
||||
.ai-tool-card--rejected {
|
||||
border-color: rgba(240,101,101,0.2);
|
||||
opacity: 0.55;
|
||||
}
|
||||
.ai-tool-rejected {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px 8px 42px;
|
||||
font-size: 11px;
|
||||
color: var(--df-danger);
|
||||
}
|
||||
.ai-tool-rejected-icon {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* -- 卡片折叠态(running/pending 始终展开,completed/rejected 默认折叠为单行 header) -- */
|
||||
.ai-tool-card--collapsed .ai-tool-header {
|
||||
cursor: pointer;
|
||||
}
|
||||
.ai-tool-card--collapsed .ai-tool-header:hover {
|
||||
background: rgba(255,255,255,0.03);
|
||||
}
|
||||
.ai-tool-body {
|
||||
overflow: hidden;
|
||||
}
|
||||
.ai-tool-collapse-hint {
|
||||
font-size: 10px;
|
||||
color: var(--df-text-dim);
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.15s var(--df-ease);
|
||||
}
|
||||
.ai-tool-collapse-hint.is-open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/* -- Header -- */
|
||||
.ai-tool-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.ai-tool-icon-wrap {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--df-radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ai-tool-icon-wrap--file-read { background: rgba(94,175,240,0.12); color: var(--df-info); }
|
||||
.ai-tool-icon-wrap--file-write { background: var(--df-warning-bg); color: var(--df-warning); }
|
||||
.ai-tool-icon-wrap--dir { background: rgba(240,199,94,0.12); color: var(--df-warning); }
|
||||
.ai-tool-icon-wrap--create { background: var(--df-success-bg); color: var(--df-success); }
|
||||
.ai-tool-icon-wrap--delete { background: var(--df-danger-bg); color: var(--df-danger); }
|
||||
.ai-tool-icon-wrap--list { background: var(--df-accent-bg); color: var(--df-accent); }
|
||||
.ai-tool-icon-wrap--default { background: var(--df-accent-bg); color: var(--df-accent); }
|
||||
|
||||
.ai-tool-header-text {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
column-gap: 8px;
|
||||
row-gap: 1px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.ai-tool-name {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--df-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ai-tool-sub {
|
||||
font-size: 10px;
|
||||
color: var(--df-text-dim);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* -- Status dot (脉冲动画) -- */
|
||||
.ai-tool-status-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ai-tool-status-dot--running {
|
||||
background: var(--df-info);
|
||||
animation: pulseGlow 1.5s ease-in-out infinite;
|
||||
box-shadow: 0 0 6px var(--df-info);
|
||||
}
|
||||
.ai-tool-status-dot--pending_approval {
|
||||
background: var(--df-warning);
|
||||
animation: pulseGlow 1.5s ease-in-out infinite;
|
||||
box-shadow: 0 0 6px rgba(240,199,94,0.3);
|
||||
}
|
||||
.ai-tool-status-dot--completed { background: var(--df-success); }
|
||||
.ai-tool-status-dot--rejected { background: var(--df-danger); }
|
||||
|
||||
/* -- Skeleton (running 状态) -- */
|
||||
.ai-tool-skeleton {
|
||||
padding: 4px 10px 10px 42px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
.ai-tool-skeleton-line {
|
||||
height: 8px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
background: linear-gradient(90deg, var(--df-bg) 25%, var(--df-bg-card-hover) 50%, var(--df-bg) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
/* -- 审批参数展示 -- */
|
||||
.ai-tool-approval-args {
|
||||
padding: 4px 10px 6px;
|
||||
margin-bottom: 6px;
|
||||
border-top: 0.5px solid var(--df-border);
|
||||
border-bottom: 0.5px solid var(--df-border);
|
||||
background: var(--df-bg-elevated, rgba(0, 0, 0, 0.03));
|
||||
}
|
||||
.ai-tool-arg {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 2px 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.ai-tool-arg-key {
|
||||
flex-shrink: 0;
|
||||
min-width: 64px;
|
||||
color: var(--df-text-secondary);
|
||||
font-family: var(--df-font-mono);
|
||||
}
|
||||
.ai-tool-arg-val {
|
||||
flex: 1;
|
||||
word-break: break-all;
|
||||
white-space: pre-wrap;
|
||||
font-family: var(--df-font-mono);
|
||||
color: var(--df-text-primary);
|
||||
}
|
||||
.ai-tool-approval-reason {
|
||||
padding: 4px 10px 6px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--df-warning, #d97706);
|
||||
background: var(--df-warning-bg, rgba(217, 119, 6, 0.08));
|
||||
border-radius: var(--df-radius-sm, 4px);
|
||||
}
|
||||
|
||||
/* -- 审批按钮 -- */
|
||||
.ai-tool-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 0 10px 8px;
|
||||
}
|
||||
.ai-tool-btn {
|
||||
padding: 5px 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
border-radius: var(--df-radius-sm);
|
||||
border: 0.5px solid var(--df-border);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
transition: all 0.15s var(--df-ease);
|
||||
font-family: var(--df-font-sans);
|
||||
}
|
||||
.ai-tool-btn--approve {
|
||||
background: var(--df-success-bg);
|
||||
color: var(--df-success);
|
||||
}
|
||||
.ai-tool-btn--approve:hover {
|
||||
background: var(--df-success);
|
||||
color: #fff;
|
||||
border-color: var(--df-success);
|
||||
}
|
||||
.ai-tool-btn--reject {
|
||||
background: var(--df-danger-bg);
|
||||
color: var(--df-danger);
|
||||
}
|
||||
.ai-tool-btn--reject:hover {
|
||||
background: var(--df-danger);
|
||||
color: #fff;
|
||||
border-color: var(--df-danger);
|
||||
}
|
||||
|
||||
/* -- File Content (read_file) -- */
|
||||
.ai-tool-file-content {
|
||||
border-top: 0.5px solid var(--df-border);
|
||||
}
|
||||
.ai-tool-file-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 10px;
|
||||
background: color-mix(in srgb, var(--df-bg) 80%, transparent);
|
||||
border-bottom: 0.5px solid var(--df-border);
|
||||
}
|
||||
.ai-tool-file-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--df-info);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ai-tool-file-icon--dir {
|
||||
color: var(--df-warning);
|
||||
}
|
||||
.ai-tool-file-path {
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--df-accent);
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ai-tool-file-meta {
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 9px;
|
||||
color: var(--df-text-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ai-tool-expand-btn {
|
||||
font-family: var(--df-font-sans);
|
||||
font-size: 10px;
|
||||
color: var(--df-text-dim);
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
padding: 2px 8px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s var(--df-ease);
|
||||
}
|
||||
.ai-tool-expand-btn:hover {
|
||||
color: var(--df-text-secondary);
|
||||
background: rgba(255,255,255,0.08);
|
||||
border-color: var(--df-border-strong);
|
||||
}
|
||||
.ai-tool-file-pre {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
background: color-mix(in srgb, var(--df-bg) 60%, transparent);
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
color: var(--df-text-secondary);
|
||||
overflow-x: auto;
|
||||
tab-size: 4;
|
||||
}
|
||||
.ai-tool-file-pre--collapsed {
|
||||
max-height: 180px;
|
||||
/* hidden→auto: 默认高度内可直接滚轮看完整内容,不必先点"展开全部" */
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
.ai-tool-file-pre--collapsed::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 48px;
|
||||
background: linear-gradient(transparent, color-mix(in srgb, var(--df-bg) 90%, transparent));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* -- Directory List (list_directory) -- */
|
||||
.ai-tool-dir-list {
|
||||
border-top: 0.5px solid var(--df-border);
|
||||
}
|
||||
.ai-tool-dir-entries {
|
||||
padding: 4px 0;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.ai-tool-dir-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 3px 12px;
|
||||
font-size: 11px;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.ai-tool-dir-entry:hover {
|
||||
background: rgba(255,255,255,0.03);
|
||||
}
|
||||
.ai-tool-dir-entry--directory .ai-tool-dir-name {
|
||||
font-weight: 500;
|
||||
color: var(--df-text);
|
||||
}
|
||||
.ai-tool-dir-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ai-tool-dir-icon--folder { color: var(--df-warning); }
|
||||
.ai-tool-dir-icon--file { color: var(--df-text-dim); }
|
||||
.ai-tool-dir-name {
|
||||
font-family: var(--df-font-mono);
|
||||
color: var(--df-text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
.ai-tool-dir-size {
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 9px;
|
||||
color: var(--df-text-dim);
|
||||
flex-shrink: 0;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* -- Write File Result -- */
|
||||
.ai-tool-write-result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-top: 0.5px solid var(--df-border);
|
||||
background: rgba(61,219,160,0.04);
|
||||
}
|
||||
.ai-tool-write-path {
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--df-accent);
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ai-tool-write-meta {
|
||||
font-size: 10px;
|
||||
color: var(--df-success);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* -- Generic Result -- */
|
||||
.ai-tool-result {
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
background: rgba(61,219,160,0.04);
|
||||
border-top: 0.5px solid var(--df-border);
|
||||
border-radius: 0;
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--df-success);
|
||||
max-height: 100px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
62
src/components/ToolCardList.vue
Normal file
62
src/components/ToolCardList.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<div class="ai-tool-calls">
|
||||
<ToolCard
|
||||
v-for="tc in toolCalls"
|
||||
:key="tc.id"
|
||||
:tc="tc"
|
||||
:isExpanded="expandedCards.has(tc.id)"
|
||||
:isContentExpanded="expandedTools.has(tc.id)"
|
||||
@toggle="toggleCardExpand"
|
||||
@expand-content="toggleExpand"
|
||||
@approve="(e) => emit('approve', e)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import ToolCard from './ToolCard.vue'
|
||||
import type { AiToolCallInfo } from '../api/types'
|
||||
|
||||
defineProps<{
|
||||
/** 当前消息的工具调用列表 */
|
||||
toolCalls: AiToolCallInfo[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 审批按钮 → 父组件转发 store.approveToolCall */
|
||||
approve: [{ id: string; approved: boolean }]
|
||||
}>()
|
||||
|
||||
// 卡片级折叠(整卡 body 显隐,区别于 read_file 内容级)
|
||||
const expandedCards = ref(new Set<string>())
|
||||
// 内容级展开(read_file "展开全部")
|
||||
const expandedTools = ref(new Set<string>())
|
||||
|
||||
function toggleCardExpand(id: string) {
|
||||
if (expandedCards.value.has(id)) expandedCards.value.delete(id)
|
||||
else expandedCards.value.add(id)
|
||||
}
|
||||
|
||||
function toggleExpand(id: string) {
|
||||
if (expandedTools.value.has(id)) expandedTools.value.delete(id)
|
||||
else expandedTools.value.add(id)
|
||||
}
|
||||
|
||||
/** 收起不在 activeIds 中的已完成/已拒绝卡片(供父组件 auto-collapse watch 调用) */
|
||||
function collapseInactive(activeIds: Set<string>): void {
|
||||
if (expandedCards.value.size > 0) {
|
||||
expandedCards.value = new Set([...expandedCards.value].filter(id => activeIds.has(id)))
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ collapseInactive })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ai-tool-calls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
178
src/composables/ai/useAiConversations.ts
Normal file
178
src/composables/ai/useAiConversations.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
//! 对话管理 — load/list/new/switch/delete/rename/archive + 侧栏折叠态
|
||||
//!
|
||||
//! 耦合:
|
||||
//! - 多处调 useAiEvents.notifyConversationChanged
|
||||
//! - newConversation/switchConversation 写 appSettings 持久化活跃会话 id
|
||||
|
||||
import { aiApi } from '../../api'
|
||||
import { useAppSettingsStore } from '../../stores/appSettings'
|
||||
import { state } from '../../stores/ai'
|
||||
import { notifyConversationChanged } from './useAiEvents'
|
||||
import { persistUiState } from './useAiPanel'
|
||||
import type { AiToolCallInfo } from '../../api/types'
|
||||
|
||||
const appSettings = useAppSettingsStore()
|
||||
|
||||
/** 拉取会话列表;首次加载时若 appSettings 仍有有效 active-conv 且当前无活跃对话则恢复 */
|
||||
async function loadConversations() {
|
||||
try {
|
||||
state.conversations = await aiApi.listConversations()
|
||||
// 恢复上次活跃对话(仅在首次加载、ID 仍有效且当前无活跃对话时)
|
||||
const pending = appSettings.get<string | null>('df-ai-active-conv', null)
|
||||
if (pending && !state.activeConversationId && state.conversations.some(c => c.id === pending)) {
|
||||
await switchConversation(pending)
|
||||
}
|
||||
} catch {
|
||||
// 静默失败,不影响主流程
|
||||
}
|
||||
}
|
||||
|
||||
/** 新建空对话并切过去 */
|
||||
async function newConversation() {
|
||||
const result = await aiApi.createConversation()
|
||||
state.activeConversationId = result.id
|
||||
void appSettings.set('df-ai-active-conv', result.id)
|
||||
state.messages = []
|
||||
state.currentText = ''
|
||||
state.pendingApprovals = []
|
||||
state.streaming = false
|
||||
await loadConversations()
|
||||
notifyConversationChanged()
|
||||
}
|
||||
|
||||
/** 切换到指定会话:加载历史消息(含 tool_calls 回填 + tool_result 映射 + pending 审批恢复) */
|
||||
export async function switchConversation(id: string) {
|
||||
// 允许生成中切换:后台继续生成,事件按 conversation_id 路由不污染当前视图
|
||||
const detail = await aiApi.switchConversation(id)
|
||||
state.activeConversationId = id
|
||||
void appSettings.set('df-ai-active-conv', id)
|
||||
|
||||
try {
|
||||
const rawMsgs = typeof detail.messages === 'string'
|
||||
? JSON.parse(detail.messages)
|
||||
: detail.messages
|
||||
|
||||
// 构建 tool_call_id → tool_result 映射,用于回填工具执行结果
|
||||
const toolResultMap = new Map<string, string>()
|
||||
for (const m of rawMsgs) {
|
||||
if (m.role === 'tool' && m.tool_call_id) {
|
||||
toolResultMap.set(m.tool_call_id, m.content || '')
|
||||
}
|
||||
}
|
||||
|
||||
state.messages = rawMsgs
|
||||
.filter((m: any) => m.role !== 'tool')
|
||||
.map((m: any, i: number) => ({
|
||||
id: `loaded-${i}`,
|
||||
role: m.role,
|
||||
content: m.content || '',
|
||||
model: m.model,
|
||||
timestamp: Date.now(),
|
||||
toolCalls: m.tool_calls?.map((tc: any) => ({
|
||||
id: tc.id,
|
||||
name: tc.function?.name || '',
|
||||
args: typeof tc.function?.arguments === 'string'
|
||||
? JSON.parse(tc.function.arguments || '{}')
|
||||
: tc.function?.arguments || {},
|
||||
status: 'completed' as const,
|
||||
result: toolResultMap.get(tc.id),
|
||||
})),
|
||||
}))
|
||||
} catch {
|
||||
state.messages = []
|
||||
}
|
||||
|
||||
// 加载历史对话 token 总量(来自 DB summary);切换对话清空实时值
|
||||
const conv = state.conversations.find(c => c.id === id)
|
||||
if (conv && conv.prompt_tokens != null && conv.completion_tokens != null) {
|
||||
state.convTokenTotal = {
|
||||
prompt: conv.prompt_tokens,
|
||||
completion: conv.completion_tokens,
|
||||
total: conv.prompt_tokens + conv.completion_tokens,
|
||||
}
|
||||
} else {
|
||||
state.convTokenTotal = null
|
||||
}
|
||||
state.lastTokenUsage = null
|
||||
|
||||
state.currentText = ''
|
||||
// 恢复该对话积压的待审批:重启后后端从审计表重建了 pending_approvals,
|
||||
// 此处查回并把对应 toolCard.status 置 pending_approval,使审批卡片重新可见
|
||||
try {
|
||||
const pending = await aiApi.pendingToolCalls(id)
|
||||
if (pending.length) {
|
||||
const pendingIds = new Set(pending.map(p => p.tool_call_id))
|
||||
const restored: AiToolCallInfo[] = []
|
||||
for (const m of state.messages) {
|
||||
for (const tc of (m.toolCalls || [])) {
|
||||
if (pendingIds.has(tc.id)) {
|
||||
tc.status = 'pending_approval'
|
||||
restored.push({ id: tc.id, name: tc.name, args: tc.args, status: 'pending_approval' })
|
||||
}
|
||||
}
|
||||
}
|
||||
state.pendingApprovals = restored
|
||||
} else {
|
||||
state.pendingApprovals = []
|
||||
}
|
||||
} catch {
|
||||
state.pendingApprovals = []
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除会话;若删的是当前活跃会话则清空消息+移除活跃 id 持久化 */
|
||||
async function deleteConversation(id: string) {
|
||||
await aiApi.deleteConversation(id)
|
||||
if (state.activeConversationId === id) {
|
||||
state.activeConversationId = null
|
||||
void appSettings.remove('df-ai-active-conv')
|
||||
state.messages = []
|
||||
}
|
||||
await loadConversations()
|
||||
notifyConversationChanged()
|
||||
}
|
||||
|
||||
/** 重命名会话(后端 + 本地侧栏摘要同步) */
|
||||
async function renameConversation(id: string, title: string) {
|
||||
await aiApi.renameConversation(id, title)
|
||||
// 本地同步更新侧栏摘要标题
|
||||
const conv = state.conversations.find(c => c.id === id)
|
||||
if (conv) conv.title = title
|
||||
notifyConversationChanged()
|
||||
}
|
||||
|
||||
/** 归档/取消归档(后端 + 本地侧栏分组同步) */
|
||||
async function archiveConversation(id: string, archived: boolean) {
|
||||
await aiApi.archiveConversation(id, archived)
|
||||
// 本地同步归档态(侧栏立即移入/移出归档分组)
|
||||
const conv = state.conversations.find(c => c.id === id)
|
||||
if (conv) conv.archived = archived
|
||||
notifyConversationChanged()
|
||||
}
|
||||
|
||||
/** 折叠/展开归档分组 */
|
||||
function toggleArchivedFold() {
|
||||
state.archivedCollapsed = !state.archivedCollapsed
|
||||
persistUiState()
|
||||
}
|
||||
|
||||
/** 展开/收起侧栏(会话列表) */
|
||||
function toggleSidebar() {
|
||||
state.sidebarOpen = !state.sidebarOpen
|
||||
persistUiState()
|
||||
}
|
||||
|
||||
export function useAiConversations() {
|
||||
return {
|
||||
loadConversations,
|
||||
newConversation,
|
||||
switchConversation,
|
||||
deleteConversation,
|
||||
renameConversation,
|
||||
archiveConversation,
|
||||
toggleArchivedFold,
|
||||
toggleSidebar,
|
||||
}
|
||||
}
|
||||
|
||||
export { loadConversations }
|
||||
257
src/composables/ai/useAiEvents.ts
Normal file
257
src/composables/ai/useAiEvents.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
//! AI 事件监听与分发 — startListener/stopListener/handleEvent 及其辅助函数
|
||||
//!
|
||||
//! 模块级私有状态(不进 reactive):
|
||||
//! - _unlistenAiEvent / _unlistenConvChanged: 已注册的 unlistener
|
||||
//! - _startPromise: startListener 并发去重(防 onMounted 与 sendMessage 首发竞态重复注册)
|
||||
//! - _msgCounter: 客户端消息自增 id(全局唯一,多个 composable 共用 nextMsgId)
|
||||
//!
|
||||
//! 耦合:
|
||||
//! - handleEvent 调 useAiConversations.loadConversations、useAiSend.drainQueue、
|
||||
//! useAiStream.{resetStreamWatchdog,clearStreamWatchdog}、本模块 flushCurrentText/findToolCall/notifyConversationChanged
|
||||
//! - onStreamTimeout(useAiStream) 调本模块 nextMsgId
|
||||
|
||||
import { listen, emit } from '@tauri-apps/api/event'
|
||||
import { aiApi } from '../../api'
|
||||
import { useAppSettingsStore } from '../../stores/appSettings'
|
||||
import { state } from '../../stores/ai'
|
||||
import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream'
|
||||
import { drainQueue } from './useAiSend'
|
||||
import { loadConversations } from './useAiConversations'
|
||||
import type { AiChatEvent, AiToolCallInfo } from '../../api/types'
|
||||
|
||||
let _unlistenAiEvent: (() => void) | null = null
|
||||
let _unlistenConvChanged: (() => void) | null = null
|
||||
// startListener 并发去重:防 onMounted 与 sendMessage 首发竞态下重复注册 listener
|
||||
// (两回调写同一 state.currentText → 流式文字双倍)
|
||||
let _startPromise: Promise<void> | null = null
|
||||
let _msgCounter = 0
|
||||
|
||||
const appSettings = useAppSettingsStore()
|
||||
|
||||
/** 全局消息 id 自增(供 events/stream/send/window 各 composable 共享同一计数器) */
|
||||
export function nextMsgId(): number {
|
||||
return ++_msgCounter
|
||||
}
|
||||
|
||||
/** 通知会话列表发生变化(供 newConversation/deleteConversation/rename/archive 等触发刷新侧栏) */
|
||||
export function notifyConversationChanged() {
|
||||
emit('ai-conversation-changed', {})
|
||||
}
|
||||
|
||||
/** 后端原始错误转用户友好提示 */
|
||||
export function friendlyError(raw: string): string {
|
||||
if (/404|not\s*found/i.test(raw)) return '调用失败:接口地址或模型不存在,请检查 Provider 配置'
|
||||
if (/401|403|unauthorized|api[_\s-]?key/i.test(raw)) return '调用失败:API Key 无效或无权限'
|
||||
if (/timeout|超时/i.test(raw)) return '响应超时,请重试'
|
||||
if (/network|connection|ECONN|网络|连接/i.test(raw)) return '网络连接失败,请检查网络'
|
||||
return raw
|
||||
}
|
||||
|
||||
/** 把流式累积的 currentText 回填到最后一条 assistant 消息(AiAgentRound/AiCompleted 收尾共用) */
|
||||
export function flushCurrentText() {
|
||||
if (!state.currentText) return
|
||||
const last = state.messages[state.messages.length - 1]
|
||||
if (last && last.role === 'assistant') last.content = state.currentText
|
||||
}
|
||||
|
||||
/** 在全部消息中查找指定 id 的工具调用卡片(用于状态流转) */
|
||||
export function findToolCall(id: string): AiToolCallInfo | undefined {
|
||||
for (const msg of state.messages) {
|
||||
if (msg.toolCalls) {
|
||||
const tc = msg.toolCalls.find(t => t.id === id)
|
||||
if (tc) return tc
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** token 用量展示开关(读 appSettings,与 Settings.vue 共享 key `df-show-token-usage`) */
|
||||
function isShowTokenUsage(): boolean {
|
||||
return appSettings.get<boolean>('df-show-token-usage', false)
|
||||
}
|
||||
|
||||
/** 后端事件分发:按 conversation_id 路由,流式累积文本,工具状态流转,看门狗联动 */
|
||||
export function handleEvent(event: AiChatEvent) {
|
||||
const convId = event.conversation_id
|
||||
// 首次收到事件时同步当前对话 id(后端自动建对话的场景)
|
||||
// 同步写 appSettings(SQLite):刷新页面后 loadConversations 据此恢复上次会话
|
||||
if (convId && !state.activeConversationId) {
|
||||
state.activeConversationId = convId
|
||||
void appSettings.set('df-ai-active-conv', convId)
|
||||
}
|
||||
// 事件不属于当前展示对话(生成中切走了)→ 不污染当前视图,仅完成/错误时刷新侧边栏
|
||||
const isCurrent = !convId || convId === state.activeConversationId
|
||||
if (!isCurrent) {
|
||||
if (event.type === 'AiCompleted' || event.type === 'AiError') {
|
||||
state.generatingConvId = null
|
||||
void loadConversations()
|
||||
}
|
||||
return
|
||||
}
|
||||
// 标记正在生成的对话(完成/错误事件除外)
|
||||
if (convId && event.type !== 'AiCompleted' && event.type !== 'AiError') {
|
||||
state.generatingConvId = convId
|
||||
}
|
||||
// 流式看门狗:活跃事件(delta/工具/新轮/审批结果)重置;审批等待/完成/错误在 case 内 clear
|
||||
if (!['AiApprovalRequired', 'AiCompleted', 'AiError'].includes(event.type)) {
|
||||
resetStreamWatchdog()
|
||||
}
|
||||
switch (event.type) {
|
||||
case 'AiTextDelta':
|
||||
state.currentText += event.delta
|
||||
break
|
||||
|
||||
case 'AiAgentRound': {
|
||||
// Agent 循环新一轮:保存当前文本到上一条 assistant 消息,新建空 assistant 消息
|
||||
flushCurrentText()
|
||||
state.currentText = ''
|
||||
state.messages.push({
|
||||
id: `ai-${nextMsgId()}`,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'AiToolCallStarted': {
|
||||
const info: AiToolCallInfo = {
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
args: event.args,
|
||||
status: 'running',
|
||||
}
|
||||
const lastMsg = state.messages[state.messages.length - 1]
|
||||
if (lastMsg && lastMsg.role === 'assistant') {
|
||||
lastMsg.toolCalls = lastMsg.toolCalls || []
|
||||
lastMsg.toolCalls.push(info)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'AiToolCallCompleted': {
|
||||
const tc = findToolCall(event.id)
|
||||
if (tc) {
|
||||
tc.status = 'completed'
|
||||
tc.result = event.result
|
||||
}
|
||||
state.pendingApprovals = state.pendingApprovals.filter(p => p.id !== event.id)
|
||||
break
|
||||
}
|
||||
|
||||
case 'AiApprovalRequired': {
|
||||
clearStreamWatchdog() // 等用户审批,不计超时
|
||||
const info: AiToolCallInfo = {
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
args: event.args,
|
||||
status: 'pending_approval',
|
||||
}
|
||||
state.pendingApprovals.push(info)
|
||||
const tc = findToolCall(event.id)
|
||||
if (tc) {
|
||||
tc.status = 'pending_approval'
|
||||
tc.reason = event.reason
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'AiApprovalResult': {
|
||||
if (!event.approved) {
|
||||
const tc = findToolCall(event.id)
|
||||
if (tc) {
|
||||
tc.status = 'rejected'
|
||||
// 与后端落库一致:拒绝结果回填卡片,供 UI 展示拒绝原因
|
||||
tc.result = '用户拒绝了此操作'
|
||||
}
|
||||
state.pendingApprovals = state.pendingApprovals.filter(p => p.id !== event.id)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'AiCompleted': {
|
||||
clearStreamWatchdog()
|
||||
flushCurrentText()
|
||||
state.currentText = ''
|
||||
state.streaming = false
|
||||
state.generatingConvId = null
|
||||
// token 用量记录(开关开时):lastTokenUsage 供当前回复展示,convTokenTotal 累加对话总量
|
||||
if (isShowTokenUsage()) {
|
||||
state.lastTokenUsage = {
|
||||
prompt: event.prompt_tokens,
|
||||
completion: event.completion_tokens,
|
||||
total: event.total_tokens,
|
||||
}
|
||||
if (state.convTokenTotal) {
|
||||
state.convTokenTotal.prompt += event.prompt_tokens
|
||||
state.convTokenTotal.completion += event.completion_tokens
|
||||
state.convTokenTotal.total += event.total_tokens
|
||||
} else {
|
||||
state.convTokenTotal = { prompt: event.prompt_tokens, completion: event.completion_tokens, total: event.total_tokens }
|
||||
}
|
||||
}
|
||||
// 清理分离窗口生成态快照
|
||||
localStorage.removeItem('df-ai-gen')
|
||||
localStorage.removeItem('df-ai-text')
|
||||
void loadConversations()
|
||||
notifyConversationChanged()
|
||||
// 队列续发:当前完成后自动发下一条(后端 generating 已复位,不会被"正在生成中"拒绝)
|
||||
drainQueue()
|
||||
break
|
||||
}
|
||||
|
||||
case 'AiError': {
|
||||
clearStreamWatchdog()
|
||||
state.streaming = false
|
||||
state.generatingConvId = null
|
||||
state.currentText = ''
|
||||
localStorage.removeItem('df-ai-gen')
|
||||
localStorage.removeItem('df-ai-text')
|
||||
state.messages.push({
|
||||
id: `err-${nextMsgId()}`,
|
||||
role: 'assistant',
|
||||
content: friendlyError(event.error),
|
||||
isError: true,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 启动事件监听(幂等 + 并发去重,onMounted 与 sendMessage 首发竞态不会重复注册) */
|
||||
export async function startListener() {
|
||||
// 已注册 → 直接复用(幂等;sendMessage 每次调用不重复注册)
|
||||
if (_unlistenAiEvent && _unlistenConvChanged) return
|
||||
// 并发去重:防 onMounted 与 sendMessage 首发竞态重复注册 listener → 文字双倍
|
||||
if (_startPromise) return _startPromise
|
||||
_startPromise = (async () => {
|
||||
_unlistenAiEvent = await aiApi.onEvent(handleEvent)
|
||||
_unlistenConvChanged = await listen('ai-conversation-changed', () => { void loadConversations() })
|
||||
})()
|
||||
try {
|
||||
await _startPromise
|
||||
} finally {
|
||||
_startPromise = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 停止事件监听(卸载时调用,释放后端 listener) */
|
||||
function stopListener() {
|
||||
_unlistenAiEvent?.()
|
||||
_unlistenConvChanged?.()
|
||||
_unlistenAiEvent = null
|
||||
_unlistenConvChanged = null
|
||||
}
|
||||
|
||||
export function useAiEvents() {
|
||||
return {
|
||||
startListener,
|
||||
stopListener,
|
||||
handleEvent,
|
||||
flushCurrentText,
|
||||
findToolCall,
|
||||
friendlyError,
|
||||
notifyConversationChanged,
|
||||
}
|
||||
}
|
||||
107
src/composables/ai/useAiPanel.ts
Normal file
107
src/composables/ai/useAiPanel.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
//! 面板与持久化 — togglePanel/toggleMaximize + UI 持久化(restoreUiState/persistUiState)
|
||||
//! + token 开关 + providers/skills/clearChat
|
||||
//!
|
||||
//! 模块级私有:
|
||||
//! - 启动恢复:模块加载时 restoreUiState()(从 appSettings 读上次 UI 布局)
|
||||
//! - watch appSettings.cache['df-ai-ui']:loadAll() 异步填充后再次应用 UI 布局
|
||||
//!
|
||||
//! 耦合:
|
||||
//! - togglePanel/toggleMaximize/toggleArchivedFold/toggleSidebar 调 persistUiState
|
||||
|
||||
import { watch } from 'vue'
|
||||
import { aiApi } from '../../api'
|
||||
import { useAppSettingsStore } from '../../stores/appSettings'
|
||||
import { state } from '../../stores/ai'
|
||||
|
||||
const appSettings = useAppSettingsStore()
|
||||
|
||||
// 启动恢复:从 appSettings(SQLite)读取上次的 UI 布局(模块级单例 state)
|
||||
// detached/docked 不恢复 — 重启后分离窗口必然不存在,必须回 false
|
||||
export function restoreUiState() {
|
||||
const s = appSettings.get<any>('df-ai-ui', null)
|
||||
if (!s) return
|
||||
if (typeof s.panelOpen === 'boolean') state.panelOpen = s.panelOpen
|
||||
if (typeof s.maximized === 'boolean') state.maximized = s.maximized
|
||||
if (typeof s.sidebarOpen === 'boolean') state.sidebarOpen = s.sidebarOpen
|
||||
if (typeof s.archivedCollapsed === 'boolean') state.archivedCollapsed = s.archivedCollapsed
|
||||
}
|
||||
restoreUiState()
|
||||
|
||||
// loadAll() 异步填充缓存后,再次应用 UI 布局(模块级 state,watch 仅触发一次)
|
||||
watch(
|
||||
() => appSettings.cache['df-ai-ui'],
|
||||
(v) => {
|
||||
if (!v) return
|
||||
const s = v as any
|
||||
if (typeof s.panelOpen === 'boolean') state.panelOpen = s.panelOpen
|
||||
if (typeof s.maximized === 'boolean') state.maximized = s.maximized
|
||||
if (typeof s.sidebarOpen === 'boolean') state.sidebarOpen = s.sidebarOpen
|
||||
if (typeof s.archivedCollapsed === 'boolean') state.archivedCollapsed = s.archivedCollapsed
|
||||
},
|
||||
)
|
||||
|
||||
/** 持久化 UI 布局到 appSettings(debounced 写 SQLite) */
|
||||
export function persistUiState() {
|
||||
void appSettings.set('df-ai-ui', {
|
||||
panelOpen: state.panelOpen,
|
||||
maximized: state.maximized,
|
||||
sidebarOpen: state.sidebarOpen,
|
||||
archivedCollapsed: state.archivedCollapsed,
|
||||
})
|
||||
}
|
||||
|
||||
/** 切换面板展开/收起(收起时同步退出最大化,避免 v-show 卡死 main 交互) */
|
||||
function togglePanel() {
|
||||
state.panelOpen = !state.panelOpen
|
||||
// 关闭面板时同步退出最大化,避免 <main> 被 v-show 隐藏导致其他页面无法交互
|
||||
if (!state.panelOpen) state.maximized = false
|
||||
persistUiState()
|
||||
}
|
||||
|
||||
/** 最大化/还原侧栏宽度 */
|
||||
function toggleMaximize() {
|
||||
state.maximized = !state.maximized
|
||||
persistUiState()
|
||||
}
|
||||
|
||||
// ── Providers / Skills / 清屏 ──
|
||||
|
||||
/** 拉取 provider 列表(用于顶部 bar 展示与切换) */
|
||||
async function loadProviders() {
|
||||
state.providers = await aiApi.listProviders()
|
||||
}
|
||||
|
||||
/** 拉取本机技能列表(用于 `/` 联想,静默失败) */
|
||||
async function loadSkills() {
|
||||
try {
|
||||
state.skills = await aiApi.listSkills()
|
||||
} catch {
|
||||
// 静默失败,不影响主流程
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换活跃 provider(后端 + 本地 state) */
|
||||
async function setProvider(providerId: string) {
|
||||
await aiApi.setProvider(providerId)
|
||||
state.activeProvider = providerId
|
||||
}
|
||||
|
||||
/** 清空当前会话消息(后端 + 本地 state) */
|
||||
async function clearChat() {
|
||||
await aiApi.clearChat()
|
||||
state.messages = []
|
||||
state.currentText = ''
|
||||
state.pendingApprovals = []
|
||||
state.streaming = false
|
||||
}
|
||||
|
||||
export function useAiPanel() {
|
||||
return {
|
||||
togglePanel,
|
||||
toggleMaximize,
|
||||
loadProviders,
|
||||
loadSkills,
|
||||
setProvider,
|
||||
clearChat,
|
||||
}
|
||||
}
|
||||
124
src/composables/ai/useAiSend.ts
Normal file
124
src/composables/ai/useAiSend.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
//! 发送与队列管理 — sendMessage/approveToolCall + 队列(drain/cancel/clear) + stopChat
|
||||
//!
|
||||
//! 模块级私有:
|
||||
//! - 无(队列存在 state.queue 中,看门狗/审批状态机依赖 events 与 stream)
|
||||
//!
|
||||
//! 耦合:
|
||||
//! - sendMessage 调 useAiEvents.startListener(useAiEvents 导出但不在解构集中暴露给组件,
|
||||
//! 故 startListener 同时经 useAiEvents 导出,sendMessage 直接 import 调用)
|
||||
//! - sendMessage 调 useAiStream.resetStreamWatchdog
|
||||
//! - drainQueue 在 sendMessage 完成后由 handleEvent(AiCompleted) 调用 — 故 drainQueue 必须为模块级 export
|
||||
|
||||
import { aiApi } from '../../api'
|
||||
import { useAppSettingsStore } from '../../stores/appSettings'
|
||||
import { state } from '../../stores/ai'
|
||||
import { resetStreamWatchdog } from './useAiStream'
|
||||
import { startListener } from './useAiEvents'
|
||||
import { nextMsgId } from './useAiEvents'
|
||||
|
||||
const appSettings = useAppSettingsStore()
|
||||
|
||||
/// 待发送队列上限(超过抛错提示用户)
|
||||
const QUEUE_LIMIT = 10
|
||||
|
||||
/** 取出队首并发送(AiCompleted 触发,此时 streaming 已 false) */
|
||||
export function drainQueue() {
|
||||
if (state.queue.length === 0) return
|
||||
const next = state.queue.shift()!
|
||||
void sendMessage(next.text, next.skill)
|
||||
}
|
||||
|
||||
/** 发送消息:生成中入队,否则推送 user+air 气泡并触发后端流式 */
|
||||
async function sendMessage(text: string, skill?: string) {
|
||||
if (!text.trim()) return
|
||||
|
||||
// 生成中:进入待发送队列,当前对话完成后(AiCompleted)由 drainQueue 自动续发
|
||||
if (state.streaming) {
|
||||
if (state.queue.length >= QUEUE_LIMIT) {
|
||||
throw new Error(`待发送队列已满(最多 ${QUEUE_LIMIT} 条)`)
|
||||
}
|
||||
state.queue.push({ text: text.trim(), skill: skill || undefined })
|
||||
return
|
||||
}
|
||||
|
||||
state.messages.push({
|
||||
id: `user-${nextMsgId()}`,
|
||||
role: 'user',
|
||||
content: text.trim(),
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
|
||||
const aiMsgId = `ai-${nextMsgId()}`
|
||||
state.messages.push({
|
||||
id: aiMsgId,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
|
||||
state.streaming = true
|
||||
state.currentText = ''
|
||||
resetStreamWatchdog() // 启动流式看门狗,无数据超时兜底
|
||||
|
||||
await startListener()
|
||||
const raw = appSettings.get<string>('df-ai-language', 'auto')
|
||||
const lang = raw === 'auto'
|
||||
? appSettings.get<string>('df-language', 'zh-CN')
|
||||
: raw
|
||||
try {
|
||||
await aiApi.sendMessage(text.trim(), lang, skill)
|
||||
} catch (e) {
|
||||
// IPC 失败(spawn 前/provider 配置错等):回滚 streaming 防光标卡死 + 移除空气泡占位;
|
||||
// 重新抛出由 handleSend 回填输入框,用户可重试
|
||||
state.streaming = false
|
||||
state.messages = state.messages.filter(m => m.id !== aiMsgId)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/** 工具审批:乐观置 running,IPC 失败时改 completed+错误文案(不回滚避免卡按钮) */
|
||||
async function approveToolCall(toolCallId: string, approved: boolean) {
|
||||
// 乐观置运行中,禁用审批按钮防重复点击(后端事件回来后转 completed/rejected)
|
||||
const tc = state.messages
|
||||
.flatMap(m => m.toolCalls || [])
|
||||
.find(t => t.id === toolCallId)
|
||||
if (tc) tc.status = 'running'
|
||||
try {
|
||||
await aiApi.approve(toolCallId, approved)
|
||||
} catch (e) {
|
||||
// 仅 IPC 真失败(审批已处理/网络断)走到这里:后端工具失败已改走 emit completed,不进此分支
|
||||
// 不回滚 pending_approval(会卡死按钮),改设 completed + 错误提示,让用户知晓失败
|
||||
console.error('[AI] 审批操作未送达后端:', e)
|
||||
if (tc) {
|
||||
tc.status = 'completed'
|
||||
tc.result = `审批操作未送达后端:${e instanceof Error ? e.message : String(e)}`
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/** 取消队列中指定位置的消息 */
|
||||
function cancelQueued(index: number) {
|
||||
state.queue.splice(index, 1)
|
||||
}
|
||||
|
||||
/** 清空整个待发送队列 */
|
||||
function clearQueue() {
|
||||
state.queue = []
|
||||
}
|
||||
|
||||
/** 停止当前生成:仅发停止信号,streaming 状态由后端 AiCompleted 事件收尾 */
|
||||
async function stopChat() {
|
||||
await aiApi.stopChat()
|
||||
}
|
||||
|
||||
export function useAiSend() {
|
||||
return {
|
||||
sendMessage,
|
||||
approveToolCall,
|
||||
drainQueue,
|
||||
cancelQueued,
|
||||
clearQueue,
|
||||
stopChat,
|
||||
}
|
||||
}
|
||||
52
src/composables/ai/useAiStream.ts
Normal file
52
src/composables/ai/useAiStream.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
//! 流式看门狗 — 无数据超时兜底
|
||||
//!
|
||||
//! 后端断连/异常不发 AiCompleted/AiError 时,前端自动收尾+提示,
|
||||
//! 避免 streaming 永久 true 卡死对话(用户报"流式文字停在中途"即此类)。
|
||||
//!
|
||||
//! 耦合说明:
|
||||
//! - sendMessage 启动生成时 resetStreamWatchdog() 启动计时
|
||||
//! - handleEvent 每个活跃事件(delta/工具/新轮/审批结果)重置;审批等待/完成/错误 clear
|
||||
//! - 超时回调 onStreamTimeout 直接改 state 并补一条错误消息
|
||||
|
||||
import { state } from '../../stores/ai'
|
||||
import { nextMsgId } from './useAiEvents'
|
||||
|
||||
/// ≥ 后端 STREAM_IDLE_TIMEOUT(120s, ai.rs:848)+余量;
|
||||
/// 前端若短于后端,慢首 token 会被前端先误杀
|
||||
export const STREAM_TIMEOUT_MS = 130000
|
||||
|
||||
let _streamWatchdog: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
/** 看门狗超时回调:收尾 streaming 态并补错误消息 */
|
||||
export function onStreamTimeout() {
|
||||
state.streaming = false
|
||||
state.generatingConvId = null
|
||||
state.currentText = ''
|
||||
clearStreamWatchdog()
|
||||
state.messages.push({
|
||||
id: `timeout-${nextMsgId()}`,
|
||||
role: 'assistant',
|
||||
content: '⚠ 响应中断(长时间无数据流)。可能是网络断连或后端异常,请重试。',
|
||||
isError: true,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
/** 启动/重置看门狗(活跃事件或 sendMessage 时调用) */
|
||||
export function resetStreamWatchdog() {
|
||||
if (_streamWatchdog) clearTimeout(_streamWatchdog)
|
||||
_streamWatchdog = setTimeout(onStreamTimeout, STREAM_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
/** 清除看门狗(完成/错误/审批等待时调用) */
|
||||
export function clearStreamWatchdog() {
|
||||
if (_streamWatchdog) { clearTimeout(_streamWatchdog); _streamWatchdog = null }
|
||||
}
|
||||
|
||||
export function useAiStream() {
|
||||
return {
|
||||
onStreamTimeout,
|
||||
resetStreamWatchdog,
|
||||
clearStreamWatchdog,
|
||||
}
|
||||
}
|
||||
167
src/composables/ai/useAiWindow.ts
Normal file
167
src/composables/ai/useAiWindow.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
//! 窗口分离模式 — detach/reattach/resumeInDetached/closeDetachedWindow/dock/syncToMain/startFollowMain/stopFollowMain
|
||||
//!
|
||||
//! 模块级私有(不进 reactive):
|
||||
//! - _unlistenMove / _unlistenResize: 主窗口 move/resize 跟随的 unlistener
|
||||
//!
|
||||
//! 耦合:
|
||||
//! - detachPanel/resumeInDetached 调 useAiConversations.switchConversation
|
||||
//! - resumeInDetached 调 useAiEvents.nextMsgId 占位 ai 气泡
|
||||
//! - reattachPanel/detachPanel 调 useAiPanel.persistUiState
|
||||
|
||||
import { state } from '../../stores/ai'
|
||||
import { nextMsgId } from './useAiEvents'
|
||||
import { switchConversation } from './useAiConversations'
|
||||
import { persistUiState } from './useAiPanel'
|
||||
|
||||
// ── 主窗口事件跟随 unlistener ──
|
||||
let _unlistenMove: (() => void) | null = null
|
||||
let _unlistenResize: (() => void) | null = null
|
||||
|
||||
/** 分离 AI 面板到独立窗口(若已存在则聚焦);快照当前生成态供分离窗口接管 */
|
||||
async function detachPanel() {
|
||||
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||
const existing = await WebviewWindow.getByLabel('ai-detached')
|
||||
if (existing) {
|
||||
await existing.setFocus()
|
||||
return
|
||||
}
|
||||
// 快照当前生成态,供分离窗口接管(保持正在进行的对话)
|
||||
if (state.streaming && state.generatingConvId) {
|
||||
localStorage.setItem('df-ai-gen', state.generatingConvId)
|
||||
localStorage.setItem('df-ai-text', state.currentText)
|
||||
}
|
||||
const convId = state.activeConversationId
|
||||
const sep = convId ? `?conv=${encodeURIComponent(convId)}` : ''
|
||||
const url = window.location.origin + window.location.pathname + '#/ai-detached' + sep
|
||||
const win = new WebviewWindow('ai-detached', {
|
||||
url,
|
||||
title: 'DevFlow AI',
|
||||
width: 520,
|
||||
height: 700,
|
||||
minWidth: 360,
|
||||
minHeight: 400,
|
||||
center: true,
|
||||
decorations: true,
|
||||
})
|
||||
win.once('tauri://created', () => {
|
||||
state.panelOpen = false
|
||||
persistUiState()
|
||||
})
|
||||
win.once('tauri://error', (e) => {
|
||||
console.error('[AI] 窗口创建失败:', e)
|
||||
state.detached = false
|
||||
})
|
||||
win.once('tauri://destroyed', () => {
|
||||
state.detached = false
|
||||
state.docked = false
|
||||
stopFollowMain()
|
||||
})
|
||||
state.detached = true
|
||||
}
|
||||
|
||||
/** 关闭分离窗口并回到内嵌面板(主窗口侧调用) */
|
||||
async function reattachPanel() {
|
||||
stopFollowMain()
|
||||
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||
const win = await WebviewWindow.getByLabel('ai-detached')
|
||||
if (win) {
|
||||
await win.close()
|
||||
}
|
||||
state.detached = false
|
||||
state.docked = false
|
||||
state.panelOpen = true
|
||||
persistUiState()
|
||||
localStorage.removeItem('df-ai-gen')
|
||||
localStorage.removeItem('df-ai-text')
|
||||
}
|
||||
|
||||
/** 分离窗口挂载时接管主窗口当前对话(含生成中态) */
|
||||
async function resumeInDetached(convId: string | null) {
|
||||
const id = convId || state.activeConversationId
|
||||
if (id) await switchConversation(id)
|
||||
const gen = localStorage.getItem('df-ai-gen')
|
||||
if (gen) {
|
||||
// 接管正在生成的对话:恢复已生成文本并补占位,后续 delta 继续追加
|
||||
state.currentText = localStorage.getItem('df-ai-text') || ''
|
||||
state.messages.push({
|
||||
id: `ai-${nextMsgId()}`,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
state.streaming = true
|
||||
state.generatingConvId = gen
|
||||
}
|
||||
}
|
||||
|
||||
/** 关闭分离窗口自身(分离窗口侧调用) */
|
||||
async function closeDetachedWindow() {
|
||||
state.docked = false
|
||||
stopFollowMain()
|
||||
localStorage.removeItem('df-ai-gen')
|
||||
localStorage.removeItem('df-ai-text')
|
||||
const { getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||
await getCurrentWebviewWindow().close()
|
||||
}
|
||||
|
||||
/** 吸附/取消吸附:AI 窗口跟随主窗口右侧 */
|
||||
async function dockDetached() {
|
||||
// 取消吸附
|
||||
if (state.docked) {
|
||||
state.docked = false
|
||||
stopFollowMain()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await syncToMain()
|
||||
state.docked = true
|
||||
startFollowMain()
|
||||
} catch (e) {
|
||||
console.error('[AI] 吸附失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 同步 AI 窗口位置到主窗口右侧(保留 4px 间隙) */
|
||||
async function syncToMain() {
|
||||
const { WebviewWindow, getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||
const { PhysicalPosition, PhysicalSize } = await import('@tauri-apps/api/dpi')
|
||||
const mainWin = await WebviewWindow.getByLabel('main')
|
||||
const aiWin = getCurrentWebviewWindow()
|
||||
if (!mainWin) return
|
||||
const pos = await mainWin.outerPosition()
|
||||
const size = await mainWin.innerSize()
|
||||
await aiWin.setPosition(new PhysicalPosition(pos.x + size.width + 4, pos.y))
|
||||
await aiWin.setSize(new PhysicalSize(600, size.height))
|
||||
}
|
||||
|
||||
/** 启动主窗口 move/resize 跟随(吸附时调用) */
|
||||
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() })
|
||||
}
|
||||
|
||||
/** 停止主窗口跟随 */
|
||||
function stopFollowMain() {
|
||||
_unlistenMove?.()
|
||||
_unlistenResize?.()
|
||||
_unlistenMove = null
|
||||
_unlistenResize = null
|
||||
}
|
||||
|
||||
export function useAiWindow() {
|
||||
return {
|
||||
detachPanel,
|
||||
reattachPanel,
|
||||
resumeInDetached,
|
||||
closeDetachedWindow,
|
||||
dockDetached,
|
||||
syncToMain,
|
||||
startFollowMain,
|
||||
stopFollowMain,
|
||||
}
|
||||
}
|
||||
101
src/constants/project.ts
Normal file
101
src/constants/project.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
//! 项目 & 任务状态映射常量
|
||||
//!
|
||||
//! 统一各视图(Projects/ProjectDetail/Tasks/Dashboard)的 label / badge / 阶段进度,
|
||||
//! 根治此前三套状态映射互相矛盾、任务文案两处不一致的问题。
|
||||
|
||||
// ── 项目状态 ──
|
||||
|
||||
// 值为 i18n key,实际文案走 $t('projects.status.<key>')(见 src/i18n/{zh-CN,en}/projects.ts)
|
||||
export const PROJECT_STATUS_LABELS: Record<string, string> = {
|
||||
planning: 'projects.status.planning',
|
||||
in_progress: 'projects.status.in_progress',
|
||||
paused: 'projects.status.paused',
|
||||
completed: 'projects.status.completed',
|
||||
cancelled: 'projects.status.cancelled',
|
||||
active: 'projects.status.active',
|
||||
}
|
||||
|
||||
/** 项目状态 → 卡片 badge 样式 class */
|
||||
export const PROJECT_STATUS_BADGE_CLASS: Record<string, string> = {
|
||||
planning: 'stage-design',
|
||||
in_progress: 'stage-coding',
|
||||
paused: 'stage-testing',
|
||||
completed: 'stage-release',
|
||||
cancelled: 'stage-design',
|
||||
}
|
||||
|
||||
/** 项目状态 → 阶段/进度信息(详情页 pipeline + Dashboard 进度条共用) */
|
||||
export interface ProjectStageInfo {
|
||||
/** pipeline 步骤索引 0-4: idea/requirement/coding/testing/release */
|
||||
stepIndex: number
|
||||
/** 进度百分比 */
|
||||
progress: number
|
||||
/** Dashboard dot/chip 颜色档: coding/testing/release */
|
||||
stage: string
|
||||
}
|
||||
|
||||
export const PROJECT_STAGE_INFO: Record<string, ProjectStageInfo> = {
|
||||
planning: { stepIndex: 0, progress: 20, stage: 'coding' },
|
||||
in_progress: { stepIndex: 2, progress: 55, stage: 'coding' },
|
||||
paused: { stepIndex: 2, progress: 40, stage: 'testing' },
|
||||
completed: { stepIndex: 4, progress: 100, stage: 'release' },
|
||||
cancelled: { stepIndex: 0, progress: 0, stage: 'testing' },
|
||||
}
|
||||
|
||||
export function projectStatusLabel(status: string): string {
|
||||
return PROJECT_STATUS_LABELS[status] ?? status
|
||||
}
|
||||
|
||||
export function projectBadgeClass(status: string): string {
|
||||
return PROJECT_STATUS_BADGE_CLASS[status] ?? 'stage-design'
|
||||
}
|
||||
|
||||
export function projectStageInfo(status: string): ProjectStageInfo {
|
||||
return PROJECT_STAGE_INFO[status] ?? PROJECT_STAGE_INFO.planning
|
||||
}
|
||||
|
||||
// ── 任务状态 ──
|
||||
|
||||
// 值为 i18n key,实际文案走 $t('tasks.status.<key>')(见 src/i18n/{zh-CN,en}/tasks.ts)
|
||||
export const TASK_STATUS_LABELS: Record<string, string> = {
|
||||
todo: 'tasks.status.todo',
|
||||
in_progress: 'tasks.status.in_progress',
|
||||
review_ready: 'tasks.status.review_ready',
|
||||
merged: 'tasks.status.merged',
|
||||
abandoned: 'tasks.status.abandoned',
|
||||
}
|
||||
|
||||
export const TASK_STATUS_CLASS: Record<string, string> = {
|
||||
todo: 'status-todo',
|
||||
in_progress: 'status-progress',
|
||||
review_ready: 'status-review',
|
||||
merged: 'status-done',
|
||||
abandoned: 'status-abandoned',
|
||||
}
|
||||
|
||||
export function taskStatusLabel(status: string): string {
|
||||
return TASK_STATUS_LABELS[status] ?? status
|
||||
}
|
||||
|
||||
export function taskStatusClass(status: string): string {
|
||||
return TASK_STATUS_CLASS[status] ?? 'status-todo'
|
||||
}
|
||||
|
||||
// ── 任务优先级 ──
|
||||
|
||||
export const PRIORITY_LABELS: Record<number, string> = { 0: 'P0', 1: 'P1', 2: 'P2', 3: 'P3' }
|
||||
|
||||
export const PRIORITY_CLASSES: Record<number, string> = {
|
||||
0: 'priority-critical',
|
||||
1: 'priority-high',
|
||||
2: 'priority-medium',
|
||||
3: 'priority-low',
|
||||
}
|
||||
|
||||
export function priorityLabel(p: number): string {
|
||||
return PRIORITY_LABELS[p] ?? `P${p}`
|
||||
}
|
||||
|
||||
export function priorityClass(p: number): string {
|
||||
return PRIORITY_CLASSES[p] ?? 'priority-low'
|
||||
}
|
||||
10
src/i18n/en/ai.ts
Normal file
10
src/i18n/en/ai.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export default {
|
||||
ai: {
|
||||
assistant: 'AI Assistant',
|
||||
toolName: {
|
||||
readFile: 'Read File',
|
||||
listDirectory: 'List Directory',
|
||||
writeFile: 'Write File',
|
||||
},
|
||||
},
|
||||
}
|
||||
64
src/i18n/en/aiChat.ts
Normal file
64
src/i18n/en/aiChat.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
export default {
|
||||
aiChat: {
|
||||
// ── Conversation sidebar ──
|
||||
sidebarTitle: 'Chats',
|
||||
newConversation: 'New chat',
|
||||
doubleClickToRename: 'Double-click to rename',
|
||||
archive: 'Archive',
|
||||
unarchive: 'Unarchive',
|
||||
archived: 'Archived',
|
||||
archivedCount: 'Archived ({n})',
|
||||
emptyConversation: 'No conversations',
|
||||
|
||||
// ── Time grouping (sidebar) ──
|
||||
today: 'Today',
|
||||
yesterday: 'Yesterday',
|
||||
earlier: 'Earlier',
|
||||
|
||||
// ── Header buttons ──
|
||||
conversationList: 'Chat list',
|
||||
maximize: 'Maximize',
|
||||
restoreSidebar: 'Restore sidebar',
|
||||
detachWindow: 'Detach window',
|
||||
closePanel: 'Close panel',
|
||||
dockRight: 'Dock to main window right',
|
||||
undock: 'Undock',
|
||||
pinOnTop: 'Pin on top',
|
||||
unpin: 'Unpin',
|
||||
closeWindow: 'Close window',
|
||||
|
||||
// ── Provider status ──
|
||||
clickToSwitchProvider: 'Click to switch provider',
|
||||
providerNotConfigured: 'No AI provider configured. Please add one in settings.',
|
||||
notConfigured: 'Not configured',
|
||||
|
||||
// ── Empty states ──
|
||||
emptyNoProvider: 'No AI provider configured',
|
||||
emptyNoProviderHint: 'Please add a provider in settings to start chatting',
|
||||
emptyTitle: 'Chat with AI assistant',
|
||||
emptyHint: 'Try "create a project" or "list all tasks"',
|
||||
|
||||
// ── Back to bottom ──
|
||||
backToBottom: 'Back to bottom',
|
||||
|
||||
// ── Pending queue ──
|
||||
queueTitle: 'Queued {n}/10',
|
||||
clearQueue: 'Clear',
|
||||
|
||||
// ── Skill ──
|
||||
clearSkill: 'Clear skill',
|
||||
placeholderWithSkill: 'Skill selected. Type arguments and press Enter',
|
||||
placeholderDefault: 'Type a message... (/ for skills)',
|
||||
skillNoMatch: 'No matching skill',
|
||||
skillNotLoaded: 'No local skills loaded',
|
||||
|
||||
// ── Send ──
|
||||
stopGenerating: 'Stop generating',
|
||||
|
||||
// ── Input hint ──
|
||||
inputHint: 'Enter to send · Shift+Enter for newline',
|
||||
|
||||
// ── Confirm dialog ──
|
||||
confirmDeleteConversation: 'Delete chat "{title}"? This cannot be undone.',
|
||||
},
|
||||
}
|
||||
31
src/i18n/en/aiTool.ts
Normal file
31
src/i18n/en/aiTool.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export default {
|
||||
aiTool: {
|
||||
running: 'Running…',
|
||||
approve: 'Approve',
|
||||
reject: 'Reject',
|
||||
rejectedHint: 'User rejected this action',
|
||||
collapse: 'Collapse',
|
||||
expandAll: 'Expand all',
|
||||
written: 'written',
|
||||
items: 'items',
|
||||
lines: 'lines',
|
||||
|
||||
readPrefix: 'Read',
|
||||
readFallback: 'Read File',
|
||||
dirPrefix: 'Dir',
|
||||
dirFallback: 'List Directory',
|
||||
writePrefix: 'Write',
|
||||
writeFallback: 'Write File',
|
||||
|
||||
taskCount: '{n} tasks',
|
||||
projectCount: '{n} projects',
|
||||
ideaCount: '{n} ideas',
|
||||
createdWithName: 'Created: {name}',
|
||||
created: 'Created',
|
||||
updatedField: 'Updated {field}',
|
||||
updated: 'Updated',
|
||||
deleted: 'Deleted',
|
||||
deleteIneffective: 'Delete ineffective',
|
||||
workflowHint: 'Run it on the workflow page',
|
||||
},
|
||||
}
|
||||
17
src/i18n/en/common.ts
Normal file
17
src/i18n/en/common.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export default {
|
||||
common: {
|
||||
justNow: 'just now',
|
||||
minutesAgo: '{n}m ago',
|
||||
hoursAgo: '{n}h ago',
|
||||
dayAgo: '{n}d ago',
|
||||
yesterday: 'Yesterday',
|
||||
ago: '{time} ago',
|
||||
cancel: 'Cancel',
|
||||
confirm: 'OK',
|
||||
delete: 'Delete',
|
||||
save: 'Save',
|
||||
edit: 'Edit',
|
||||
close: 'Close',
|
||||
loading: 'Loading…',
|
||||
},
|
||||
}
|
||||
@@ -1,17 +1,4 @@
|
||||
export default {
|
||||
nav: {
|
||||
overview: 'Overview',
|
||||
ideas: 'Ideas',
|
||||
projects: 'Projects',
|
||||
tasks: 'Tasks',
|
||||
knowledge: 'Knowledge',
|
||||
decisions: 'Decisions',
|
||||
settings: 'Settings',
|
||||
workspace: 'Workspace',
|
||||
traceability: 'Traceability',
|
||||
systemReady: 'System Ready',
|
||||
aiPanel: 'AI Panel',
|
||||
},
|
||||
dashboard: {
|
||||
title: 'Overview',
|
||||
subtitle: 'All systems operational',
|
||||
@@ -59,20 +46,4 @@ export default {
|
||||
rejected: 'Rejected',
|
||||
},
|
||||
},
|
||||
ai: {
|
||||
assistant: 'AI Assistant',
|
||||
toolName: {
|
||||
readFile: 'Read File',
|
||||
listDirectory: 'List Directory',
|
||||
writeFile: 'Write File',
|
||||
},
|
||||
},
|
||||
common: {
|
||||
justNow: 'just now',
|
||||
minutesAgo: '{n}m ago',
|
||||
hoursAgo: '{n}h ago',
|
||||
dayAgo: '{n}d ago',
|
||||
yesterday: 'Yesterday',
|
||||
ago: '{time} ago',
|
||||
},
|
||||
}
|
||||
79
src/i18n/en/ideas.ts
Normal file
79
src/i18n/en/ideas.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
export default {
|
||||
ideas: {
|
||||
// Page header
|
||||
title: '💡 Ideas',
|
||||
refresh: '🔄 Refresh',
|
||||
capture: '✨ Capture Idea',
|
||||
|
||||
// Filter bar
|
||||
searchPlaceholder: 'Search ideas...',
|
||||
filter: {
|
||||
all: 'All',
|
||||
hot: 'Hot',
|
||||
pending: 'Pending',
|
||||
promoted: 'Promoted',
|
||||
},
|
||||
|
||||
// Empty state
|
||||
emptyState: 'Select an idea to view details',
|
||||
|
||||
// Adversarial evaluation
|
||||
adversarialTitle: '⚖️ Adversarial Evaluation',
|
||||
evalModeHeuristic: 'Heuristic',
|
||||
positive: '📈 Pro',
|
||||
negative: '📉 Con',
|
||||
confidence: '{n}% confidence',
|
||||
analystTitle: '🧠 AI Analyst Conclusion',
|
||||
finalScore: 'Overall score: {score}/10',
|
||||
netSentiment: 'Overall sentiment: {tone} ({n})',
|
||||
sentimentPositive: 'Positive',
|
||||
sentimentNegative: 'Cautious',
|
||||
actionTitle: '💡 Action Items',
|
||||
evaluating: '⏳ Evaluating…',
|
||||
startEval: '🔍 Start Adversarial Eval',
|
||||
|
||||
// Assessment badges
|
||||
assessment: {
|
||||
'immediate action': '🚀 Act immediately',
|
||||
soon: '📅 Act soon',
|
||||
'with resources': '📦 Act with resources',
|
||||
'research more': '🔍 Needs more research',
|
||||
monitor: '👁️ Keep monitoring',
|
||||
cancel: '❌ Cancel idea',
|
||||
},
|
||||
|
||||
// Multi-dimensional score
|
||||
multiScoreTitle: '📊 Multi-dim Score',
|
||||
noEval: 'No evaluation yet',
|
||||
|
||||
// Tags
|
||||
tagsTitle: '🏷️ Tags',
|
||||
noTags: 'No tags yet',
|
||||
|
||||
// Status management
|
||||
statusTitle: '📋 Status',
|
||||
status: {
|
||||
draft: '📝 Draft',
|
||||
pending_review: '⏳ Pending',
|
||||
approved: '✅ Approved',
|
||||
promoted: '🚀 Promoted',
|
||||
rejected: '❌ Rejected',
|
||||
},
|
||||
|
||||
// Action buttons
|
||||
promoteToProject: '🚀 Promote to Project',
|
||||
deleteIdea: '🗑️ Delete Idea',
|
||||
|
||||
// Capture modal
|
||||
captureTitle: '✨ Capture New Idea',
|
||||
fieldTitle: 'Title',
|
||||
fieldDesc: 'Description',
|
||||
titlePlaceholder: 'Describe your idea in one sentence...',
|
||||
descPlaceholder: 'More details (optional)...',
|
||||
|
||||
// Native dialog text (confirm / alert)
|
||||
confirmDelete: 'Delete idea "{title}"? This cannot be undone.',
|
||||
promoteFailed: 'Promotion failed',
|
||||
evalFailed: 'Evaluation failed',
|
||||
},
|
||||
}
|
||||
14
src/i18n/en/index.ts
Normal file
14
src/i18n/en/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// 自动聚合本目录所有模块文件 —— 新增模块文件即生效,无需改此文件。
|
||||
// 排除 index 自身。每个模块文件 export default 一个对象,顶层 key 合并进 messages。
|
||||
const modules = import.meta.glob(['./*.ts'], { eager: true, import: 'default' }) as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>
|
||||
|
||||
const messages: Record<string, unknown> = {}
|
||||
for (const [path, mod] of Object.entries(modules)) {
|
||||
if (path.endsWith('index.ts')) continue
|
||||
Object.assign(messages, mod)
|
||||
}
|
||||
|
||||
export default messages
|
||||
78
src/i18n/en/knowledge.ts
Normal file
78
src/i18n/en/knowledge.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
export default {
|
||||
knowledge: {
|
||||
title: '📚 Knowledge Base',
|
||||
add: '+ New Knowledge',
|
||||
tab: {
|
||||
library: 'Library',
|
||||
inbox: 'Inbox',
|
||||
},
|
||||
searchPlaceholder: 'Search title, tags, content...',
|
||||
categoryAll: 'All',
|
||||
emptyInbox: '✅ No pending items',
|
||||
emptyLibrary: '📭 No matching knowledge',
|
||||
edit: '✏️ Edit',
|
||||
publish: 'Publish',
|
||||
reject: 'Reject',
|
||||
archive: 'Archive',
|
||||
reuseCount: '🔄 Reused {n} times',
|
||||
confidenceBadge: 'Confidence: {label}',
|
||||
contentLabel: 'Content',
|
||||
tagsLabel: 'Tags',
|
||||
tagsNone: 'None',
|
||||
tagsPlaceholder: 'comma separated',
|
||||
traceTitle: '🔍 Provenance',
|
||||
traceMethod: 'Origin',
|
||||
traceSource: 'Source conversation',
|
||||
traceTime: 'Created at',
|
||||
originExtracted: '🤖 Extracted by AI',
|
||||
originManual: '✍️ Manually added',
|
||||
originUnknown: '—',
|
||||
reasoningLabel: '🤖 AI Reasoning',
|
||||
refTitle: '🔄 References ({n})',
|
||||
refEmpty: 'Not referenced yet',
|
||||
loadMore: 'Load more ({n} left)',
|
||||
lifecycleTitle: '📅 Lifecycle',
|
||||
detailEmptyHint: 'Select a knowledge on the left to view its full lifecycle',
|
||||
createTitle: 'New Knowledge',
|
||||
kindLabel: 'Type',
|
||||
titleLabel: 'Title',
|
||||
titlePlaceholder: 'Short title',
|
||||
contentPlaceholder: 'Reusable content',
|
||||
tagsFieldLabel: 'Tags (comma separated)',
|
||||
tagsFieldPlaceholder: 'Go, concurrency, context',
|
||||
confidenceLabel: 'Confidence',
|
||||
confidenceNone: 'Not set',
|
||||
create: 'Create',
|
||||
timeline: {
|
||||
extracted: 'AI extracted',
|
||||
created: 'Manually added',
|
||||
published: 'Approved',
|
||||
archived: 'Archived',
|
||||
enterReview: 'Entered review',
|
||||
referenced: 'Referenced',
|
||||
statusChanged: 'Status → {to}',
|
||||
summarySource: 'Source: {title}',
|
||||
summaryConv: 'Conversation: {title}',
|
||||
},
|
||||
status: {
|
||||
candidate: 'Pending',
|
||||
pending_review: 'Reviewing',
|
||||
published: 'Published',
|
||||
archived: 'Archived',
|
||||
},
|
||||
confidence: {
|
||||
high: 'High',
|
||||
medium: 'Medium',
|
||||
low: 'Low',
|
||||
},
|
||||
kind: {
|
||||
review_rule: 'Review Rule',
|
||||
prompt_template: 'Prompt Template',
|
||||
pitfall: 'Pitfall',
|
||||
architecture_pattern: 'Architecture Pattern',
|
||||
diagnosis: 'Diagnosis',
|
||||
deployment_note: 'Deployment Note',
|
||||
workflow_optimization: 'Workflow Optimization',
|
||||
},
|
||||
},
|
||||
}
|
||||
15
src/i18n/en/nav.ts
Normal file
15
src/i18n/en/nav.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export default {
|
||||
nav: {
|
||||
overview: 'Overview',
|
||||
ideas: 'Ideas',
|
||||
projects: 'Projects',
|
||||
tasks: 'Tasks',
|
||||
knowledge: 'Knowledge',
|
||||
decisions: 'Decisions',
|
||||
settings: 'Settings',
|
||||
workspace: 'Workspace',
|
||||
traceability: 'Traceability',
|
||||
systemReady: 'System Ready',
|
||||
aiPanel: 'AI Panel',
|
||||
},
|
||||
}
|
||||
54
src/i18n/en/projectDetail.ts
Normal file
54
src/i18n/en/projectDetail.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
export default {
|
||||
projectDetail: {
|
||||
// Header
|
||||
backToList: '← Projects',
|
||||
sync: '🔄 Sync',
|
||||
delete: '🗑 Delete',
|
||||
newTask: '+ New Task',
|
||||
// Stage pipeline
|
||||
stageIdea: '💡 Idea',
|
||||
stageRequirement: '📋 Requirement',
|
||||
stageCoding: '💻 Coding',
|
||||
stageTesting: '🧪 Testing',
|
||||
stageRelease: '🚀 Release',
|
||||
// New task modal
|
||||
newTaskTitle: 'New Task',
|
||||
taskTitleLabel: 'Task Title',
|
||||
taskTitlePlaceholder: 'Enter task title',
|
||||
descLabel: 'Description',
|
||||
descPlaceholder: 'Brief description',
|
||||
branchLabel: 'Branch Name',
|
||||
confirmCreate: 'Create',
|
||||
// Project info panel
|
||||
infoTitle: '📋 Project Info',
|
||||
sourceIdea: 'Source Idea',
|
||||
viewIdea: 'View idea details',
|
||||
ideaDeleted: 'Original idea deleted',
|
||||
createdAt: 'Created',
|
||||
updatedAt: 'Updated',
|
||||
description: 'Description',
|
||||
projectStatus: 'Status',
|
||||
// Task list panel
|
||||
taskListTitle: '🔀 Tasks',
|
||||
taskCount: '{n} tasks',
|
||||
emptyTasks: 'No tasks yet',
|
||||
// Workflow log panel
|
||||
workflowLogTitle: '📋 Workflow Log',
|
||||
runDemoWorkflow: '▶ Run Demo Workflow',
|
||||
emptyWorkflowLog: 'Click "Run Demo Workflow" to view live logs',
|
||||
// Approval dialog
|
||||
approvalTitle: 'Manual Approval Required',
|
||||
approvalHint: 'Choose an action:',
|
||||
approvalLater: 'Later',
|
||||
// Delete confirm
|
||||
confirmDelete: 'Delete project "{name}"? It will be moved to trash and can be restored.',
|
||||
// Project directory
|
||||
codeDirLabel: 'Code Directory',
|
||||
noDirBound: 'Not bound',
|
||||
relocateDir: 'Relocate',
|
||||
bindDir: 'Bind Directory',
|
||||
dirStatusLabel: 'Directory Status',
|
||||
// Tech stack
|
||||
techStackLabel: 'Tech Stack',
|
||||
},
|
||||
}
|
||||
42
src/i18n/en/projects.ts
Normal file
42
src/i18n/en/projects.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
export default {
|
||||
projects: {
|
||||
title: '📂 Projects',
|
||||
// Header actions
|
||||
create: '+ New Project',
|
||||
trash: '🗑 Trash',
|
||||
// New project modal
|
||||
createTitle: 'New Project',
|
||||
nameLabel: 'Project Name',
|
||||
namePlaceholder: 'Enter project name',
|
||||
descLabel: 'Project Description',
|
||||
descPlaceholder: 'Briefly describe the project goal',
|
||||
confirmCreate: 'Create',
|
||||
// Trash modal
|
||||
trashTitle: '🗑 Trash',
|
||||
trashEmpty: 'Trash is empty',
|
||||
movedIn: 'moved in',
|
||||
restore: '↩ Restore',
|
||||
purge: 'Delete Permanently',
|
||||
// Project card
|
||||
deleteTitle: 'Delete',
|
||||
// Empty state
|
||||
empty: 'No projects yet, click the top-right button to create your first project',
|
||||
// Confirm prompts
|
||||
confirmDelete: 'Delete project "{name}"? It will be moved to trash and can be restored.',
|
||||
confirmPurge: 'Permanently delete project "{name}"? Tasks/branches/releases will be physically deleted and cannot be recovered!',
|
||||
// New fields
|
||||
codeDirLabel: 'Code Directory',
|
||||
codeDirPlaceholder: 'Click the button on the right to select project directory',
|
||||
selectDir: 'Select Directory',
|
||||
clearDir: 'Clear',
|
||||
// Status labels (PROJECT_STATUS_LABELS values in constants/project.ts use these keys)
|
||||
status: {
|
||||
planning: '📐 Planning',
|
||||
in_progress: '💻 In Progress',
|
||||
paused: '⏸ Paused',
|
||||
completed: '✅ Completed',
|
||||
cancelled: '❌ Cancelled',
|
||||
active: '🚀 In Progress',
|
||||
},
|
||||
},
|
||||
}
|
||||
118
src/i18n/en/settings.ts
Normal file
118
src/i18n/en/settings.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
export default {
|
||||
settings: {
|
||||
// ===== Page title =====
|
||||
title: '⚙️ Settings',
|
||||
|
||||
// ===== AI Provider panel =====
|
||||
panelAiProvider: '🤖 AI Model Config',
|
||||
addProvider: '+ Add Provider',
|
||||
editProviderTitle: 'Edit Provider',
|
||||
addProviderTitle: 'Add Provider',
|
||||
emptyProvider: 'No AI provider. Click above to add.',
|
||||
badgeDefault: 'Default',
|
||||
actionSetDefault: 'Set as default',
|
||||
detailBaseUrl: 'Base URL',
|
||||
detailModel: 'Model',
|
||||
detailApiKey: 'API Key',
|
||||
|
||||
// Provider form
|
||||
labelName: 'Name',
|
||||
labelProviderType: 'Protocol',
|
||||
labelBaseUrl: 'Base URL',
|
||||
labelApiKey: 'API Key',
|
||||
labelDefaultModel: 'Default',
|
||||
phProviderName: 'e.g. DeepSeek, GLM-4',
|
||||
phBaseUrl: 'https://api.deepseek.com',
|
||||
phApiKey: 'sk-...',
|
||||
phDefaultModel: 'deepseek-chat',
|
||||
providerTypeOpenaiCompat: 'OpenAI-compatible (DeepSeek / GLM v4 / OpenAI)',
|
||||
providerTypeAnthropic: 'Anthropic-compatible (GLM subscription / Claude)',
|
||||
saving: 'Saving...',
|
||||
|
||||
// ===== Connection panel =====
|
||||
panelConnection: '🔗 Connections',
|
||||
addConnection: '+ Add connection',
|
||||
editConnectionTitle: 'Edit connection',
|
||||
addConnectionTitle: 'Add connection',
|
||||
emptyConnection: 'No connection yet. Click the button above to add one.',
|
||||
labelType: 'Type',
|
||||
labelHost: 'Host',
|
||||
labelPort: 'Port',
|
||||
labelUser: 'Username',
|
||||
phConnName: 'flux_dev',
|
||||
phHost: '127.0.0.1',
|
||||
phPort: '3306',
|
||||
phUser: 'root',
|
||||
|
||||
// ===== General settings panel =====
|
||||
panelGeneral: '🎨 General',
|
||||
labelTheme: 'Theme',
|
||||
descTheme: 'Dark / light',
|
||||
themeDark: '🌙 Dark',
|
||||
themeLight: '☀️ Light',
|
||||
themeSystem: '💻 System',
|
||||
|
||||
labelLanguage: 'Language',
|
||||
descLanguage: 'Interface language',
|
||||
|
||||
labelAiLanguage: 'AI language',
|
||||
descAiLanguage: 'Default AI reply language',
|
||||
aiLanguageAuto: '🔄 Follow interface language',
|
||||
aiLanguageZh: '🇨🇳 简体中文',
|
||||
aiLanguageEn: '🇺🇸 English',
|
||||
|
||||
labelAutoExecute: 'Auto-execute',
|
||||
descAutoExecute: 'Let AI auto-execute low-risk actions',
|
||||
|
||||
labelLogLevel: 'Log level',
|
||||
descLogLevel: 'Verbosity of workflow logs',
|
||||
|
||||
labelShowTokenUsage: 'Show token usage',
|
||||
descShowTokenUsage: 'Show token consumption of each reply in conversations',
|
||||
|
||||
labelGlobalConcurrency: 'Global max concurrency',
|
||||
descGlobalConcurrency: 'Shared cap on simultaneous LLM requests across all conversations',
|
||||
|
||||
labelPerConvConcurrency: 'Per-conversation concurrency',
|
||||
descPerConvConcurrency: 'Concurrent LLM calls within one conversation (main loop / title / distill shared)',
|
||||
|
||||
// ===== Knowledge base panel =====
|
||||
panelKnowledge: '📚 Knowledge base',
|
||||
labelAutoExtract: 'Auto-distill knowledge',
|
||||
descAutoExtract: 'Automatically distill reusable knowledge from conversations after they finish (AI proposes candidates, manual review required)',
|
||||
labelTriggerMode: 'Distill trigger',
|
||||
descTriggerMode: 'When auto-distillation is triggered',
|
||||
triggerOnComplete: 'On conversation complete',
|
||||
triggerOnIdle: 'On conversation idle',
|
||||
triggerManualOnly: 'Manual only',
|
||||
labelMinMessages: 'Min message count',
|
||||
descMinMessages: 'Distillation triggers only after this many messages (filters out small talk noise)',
|
||||
labelIdleTimeout: 'Idle timeout (s)',
|
||||
descIdleTimeout: 'Seconds to wait in idle-trigger mode',
|
||||
labelAutoInject: 'Auto-inject related knowledge',
|
||||
descAutoInject: 'Retrieve related knowledge and inject it into the conversation context during chat',
|
||||
labelVectorEnabled: 'Semantic retrieval (vector)',
|
||||
descVectorEnabled: 'Use Embedding vectors for semantic matching — close meanings surface even with different wording; when off, pure keyword matching with zero external calls',
|
||||
labelEmbeddingProvider: 'Embedding provider',
|
||||
descEmbeddingProvider: 'AI provider used for vectorization (OpenAI-compatible only; switching requires re-embedding all knowledge)',
|
||||
labelEmbeddingModel: 'Embedding model',
|
||||
descEmbeddingModel: 'e.g. embedding-3 (Zhipu) / text-embedding-v4 (Alibaba) / text-embedding-3-small (OpenAI)',
|
||||
embeddingProviderNone: 'Not selected',
|
||||
phEmbeddingModel: 'embedding-3',
|
||||
|
||||
// ===== Toast =====
|
||||
toastLoadProviderFail: 'Failed to load providers',
|
||||
toastSaveIncomplete: 'Please fill in all fields (Name / Base URL / API Key / Model)',
|
||||
toastSaved: 'Saved',
|
||||
toastSaveFail: 'Save failed: {msg}',
|
||||
toastDeleted: 'Deleted',
|
||||
toastDeleteFail: 'Delete failed: {msg}',
|
||||
toastSetDefaultOk: 'Set as default',
|
||||
toastSetDefaultFail: 'Failed to set default: {msg}',
|
||||
toastConnIncomplete: 'Please fill in Name and Host',
|
||||
|
||||
// ===== Confirm dialogs =====
|
||||
confirmDeleteProvider: 'Delete provider "{name}"? This cannot be undone.',
|
||||
confirmDeleteConn: 'Delete connection "{name}"?',
|
||||
},
|
||||
}
|
||||
56
src/i18n/en/tasks.ts
Normal file
56
src/i18n/en/tasks.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
export default {
|
||||
tasks: {
|
||||
// Header
|
||||
title: '🔀 Task Queue',
|
||||
refresh: '🔄 Refresh',
|
||||
create: '+ New Task',
|
||||
|
||||
// Filter bar
|
||||
filter: {
|
||||
project: 'Project: ',
|
||||
status: 'Status: ',
|
||||
all: 'All',
|
||||
},
|
||||
// Status filters (with icons)
|
||||
statusFilter: {
|
||||
all: 'All',
|
||||
todo: 'Todo',
|
||||
in_progress: 'In Progress',
|
||||
review_ready: 'Review Ready',
|
||||
merged: 'Merged',
|
||||
abandoned: 'Abandoned',
|
||||
},
|
||||
|
||||
// Groups
|
||||
group: {
|
||||
taskCount: '{n} tasks',
|
||||
unknownProject: 'Unknown Project',
|
||||
},
|
||||
|
||||
// New task modal
|
||||
modal: {
|
||||
title: 'New Task',
|
||||
project: 'Project',
|
||||
titleField: 'Title',
|
||||
desc: 'Description',
|
||||
branch: 'Branch',
|
||||
priority: 'Priority',
|
||||
titlePlaceholder: 'Enter task title...',
|
||||
descPlaceholder: 'Brief description (optional)',
|
||||
branchPlaceholder: 'feature/xxx (optional)',
|
||||
// Priority options
|
||||
priorityCritical: 'P0 Critical',
|
||||
priorityHigh: 'P1 High',
|
||||
priorityMedium: 'P2 Medium',
|
||||
priorityLow: 'P3 Low',
|
||||
},
|
||||
// Status labels (TASK_STATUS_LABELS values in constants/project.ts use these keys)
|
||||
status: {
|
||||
todo: '📋 Todo',
|
||||
in_progress: '🔄 In Progress',
|
||||
review_ready: '👀 Review Ready',
|
||||
merged: '✅ Merged',
|
||||
abandoned: '🗑️ Abandoned',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1,16 +1,26 @@
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import zhCN from './zh-CN'
|
||||
import en from './en'
|
||||
import { useAppSettingsStore } from '../stores/appSettings'
|
||||
|
||||
// 界面语言:模块加载时 appSettings 缓存尚未填充(loadAll 在 App.vue onMounted 异步执行),
|
||||
// 故这里 get() 拿到默认 'zh-CN';真实用户偏好由 App.vue 在 loadAll 完成后回填到
|
||||
// i18n.global.locale.value,初帧渲染短暂 zh-CN 不影响功能。
|
||||
const getInitialLocale = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return useAppSettingsStore().get<string>('df-language', 'zh-CN')
|
||||
}
|
||||
return 'zh-CN'
|
||||
}
|
||||
|
||||
// 界面语言:跟随 localStorage 中用户选择,默认简体中文
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
globalInjection: true, // 模板中可直接用 $t / $tc
|
||||
locale: localStorage.getItem('df-language') || 'zh-CN',
|
||||
locale: getInitialLocale(),
|
||||
fallbackLocale: 'en',
|
||||
messages: {
|
||||
'zh-CN': zhCN,
|
||||
en,
|
||||
'zh-CN': zhCN as Record<string, any>,
|
||||
en: en as Record<string, any>,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
10
src/i18n/zh-CN/ai.ts
Normal file
10
src/i18n/zh-CN/ai.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export default {
|
||||
ai: {
|
||||
assistant: 'AI 助手',
|
||||
toolName: {
|
||||
readFile: '读取文件',
|
||||
listDirectory: '查看目录',
|
||||
writeFile: '写入文件',
|
||||
},
|
||||
},
|
||||
}
|
||||
64
src/i18n/zh-CN/aiChat.ts
Normal file
64
src/i18n/zh-CN/aiChat.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
export default {
|
||||
aiChat: {
|
||||
// ── 对话侧栏 ──
|
||||
sidebarTitle: '对话',
|
||||
newConversation: '新对话',
|
||||
doubleClickToRename: '双击重命名',
|
||||
archive: '归档',
|
||||
unarchive: '取消归档',
|
||||
archived: '已归档',
|
||||
archivedCount: '已归档 ({n})',
|
||||
emptyConversation: '暂无对话',
|
||||
|
||||
// ── 时间分组(侧栏) ──
|
||||
today: '今天',
|
||||
yesterday: '昨天',
|
||||
earlier: '更早',
|
||||
|
||||
// ── Header 按钮 ──
|
||||
conversationList: '对话列表',
|
||||
maximize: '最大化',
|
||||
restoreSidebar: '还原侧栏',
|
||||
detachWindow: '分离窗口',
|
||||
closePanel: '关闭面板',
|
||||
dockRight: '吸附到主窗口右侧',
|
||||
undock: '取消吸附',
|
||||
pinOnTop: '置顶',
|
||||
unpin: '取消置顶',
|
||||
closeWindow: '关闭窗口',
|
||||
|
||||
// ── Provider 状态 ──
|
||||
clickToSwitchProvider: '点击切换 Provider',
|
||||
providerNotConfigured: '未配置 AI 提供商,请在设置中添加',
|
||||
notConfigured: '未配置',
|
||||
|
||||
// ── 空状态 ──
|
||||
emptyNoProvider: '未配置 AI 提供商',
|
||||
emptyNoProviderHint: '请在设置中添加 Provider 后开始对话',
|
||||
emptyTitle: '与 AI 助手对话',
|
||||
emptyHint: '可以说"帮我创建一个项目"或"列出所有任务"',
|
||||
|
||||
// ── 回到底部 ──
|
||||
backToBottom: '回到底部',
|
||||
|
||||
// ── 待发送队列 ──
|
||||
queueTitle: '待发送 {n}/10',
|
||||
clearQueue: '清空',
|
||||
|
||||
// ── 技能 ──
|
||||
clearSkill: '取消技能',
|
||||
placeholderWithSkill: '已选技能,输入参数后回车',
|
||||
placeholderDefault: '输入消息...(/ 触发技能)',
|
||||
skillNoMatch: '无匹配技能',
|
||||
skillNotLoaded: '未读取到本机技能',
|
||||
|
||||
// ── 发送 ──
|
||||
stopGenerating: '停止生成',
|
||||
|
||||
// ── 输入提示 ──
|
||||
inputHint: 'Enter 发送 · Shift+Enter 换行',
|
||||
|
||||
// ── 确认弹层 ──
|
||||
confirmDeleteConversation: '确定删除对话「{title}」?该操作不可恢复。',
|
||||
},
|
||||
}
|
||||
33
src/i18n/zh-CN/aiTool.ts
Normal file
33
src/i18n/zh-CN/aiTool.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export default {
|
||||
aiTool: {
|
||||
running: '执行中…',
|
||||
approve: '批准执行',
|
||||
reject: '拒绝',
|
||||
rejectedHint: '用户拒绝了此操作',
|
||||
collapse: '收起',
|
||||
expandAll: '展开全部',
|
||||
written: '已写入',
|
||||
items: '项',
|
||||
lines: '行',
|
||||
|
||||
// 工具显示名(含路径摘要前缀,fallback 兜底)
|
||||
readPrefix: '读取',
|
||||
readFallback: '读取文件',
|
||||
dirPrefix: '目录',
|
||||
dirFallback: '列出目录',
|
||||
writePrefix: '写入',
|
||||
writeFallback: '写入文件',
|
||||
|
||||
// completed 结果摘要(header 折叠态可见)
|
||||
taskCount: '{n} 项任务',
|
||||
projectCount: '{n} 个项目',
|
||||
ideaCount: '{n} 条想法',
|
||||
createdWithName: '已创建:{name}',
|
||||
created: '已创建',
|
||||
updatedField: '已更新 {field}',
|
||||
updated: '已更新',
|
||||
deleted: '已删除',
|
||||
deleteIneffective: '删除未生效',
|
||||
workflowHint: '请到工作流页面运行',
|
||||
},
|
||||
}
|
||||
17
src/i18n/zh-CN/common.ts
Normal file
17
src/i18n/zh-CN/common.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export default {
|
||||
common: {
|
||||
justNow: '刚刚',
|
||||
minutesAgo: '{n} 分钟前',
|
||||
hoursAgo: '{n} 小时前',
|
||||
dayAgo: '{n} 天前',
|
||||
yesterday: '昨天',
|
||||
ago: '{time}前',
|
||||
cancel: '取消',
|
||||
confirm: '确定',
|
||||
delete: '删除',
|
||||
save: '保存',
|
||||
edit: '编辑',
|
||||
close: '关闭',
|
||||
loading: '加载中…',
|
||||
},
|
||||
}
|
||||
@@ -1,17 +1,4 @@
|
||||
export default {
|
||||
nav: {
|
||||
overview: '总览',
|
||||
ideas: '灵感',
|
||||
projects: '项目',
|
||||
tasks: '任务',
|
||||
knowledge: '知识库',
|
||||
decisions: '决策',
|
||||
settings: '设置',
|
||||
workspace: '工作区',
|
||||
traceability: '追溯',
|
||||
systemReady: '系统就绪',
|
||||
aiPanel: 'AI 面板',
|
||||
},
|
||||
dashboard: {
|
||||
title: '总览',
|
||||
subtitle: '所有系统运行正常',
|
||||
@@ -59,20 +46,4 @@ export default {
|
||||
rejected: '已驳回',
|
||||
},
|
||||
},
|
||||
ai: {
|
||||
assistant: 'AI 助手',
|
||||
toolName: {
|
||||
readFile: '读取文件',
|
||||
listDirectory: '查看目录',
|
||||
writeFile: '写入文件',
|
||||
},
|
||||
},
|
||||
common: {
|
||||
justNow: '刚刚',
|
||||
minutesAgo: '{n} 分钟前',
|
||||
hoursAgo: '{n} 小时前',
|
||||
dayAgo: '{n} 天前',
|
||||
yesterday: '昨天',
|
||||
ago: '{time}前',
|
||||
},
|
||||
}
|
||||
79
src/i18n/zh-CN/ideas.ts
Normal file
79
src/i18n/zh-CN/ideas.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
export default {
|
||||
ideas: {
|
||||
// 页头
|
||||
title: '💡 灵感',
|
||||
refresh: '🔄 刷新',
|
||||
capture: '✨ 捕捉灵感',
|
||||
|
||||
// 筛选栏
|
||||
searchPlaceholder: '搜索灵感...',
|
||||
filter: {
|
||||
all: '全部',
|
||||
hot: '热门',
|
||||
pending: '待评估',
|
||||
promoted: '已立项',
|
||||
},
|
||||
|
||||
// 空状态
|
||||
emptyState: '选择一个想法查看详情',
|
||||
|
||||
// 对抗式评估
|
||||
adversarialTitle: '⚖️ 对抗式评估',
|
||||
evalModeHeuristic: '启发式',
|
||||
positive: '📈 正方观点',
|
||||
negative: '📉 反方观点',
|
||||
confidence: '{n}% 置信度',
|
||||
analystTitle: '🧠 AI 分析师结论',
|
||||
finalScore: '综合评分: {score}/10',
|
||||
netSentiment: '整体倾向: {tone} ({n})',
|
||||
sentimentPositive: '积极',
|
||||
sentimentNegative: '谨慎',
|
||||
actionTitle: '💡 行动建议',
|
||||
evaluating: '⏳ 评估中…',
|
||||
startEval: '🔍 开始对抗评估',
|
||||
|
||||
// 评估结论徽章
|
||||
assessment: {
|
||||
'immediate action': '🚀 立即行动',
|
||||
soon: '📅 尽快行动',
|
||||
'with resources': '📦 配置资源后行动',
|
||||
'research more': '🔍 需要更多研究',
|
||||
monitor: '👁️ 持续监控',
|
||||
cancel: '❌ 取消想法',
|
||||
},
|
||||
|
||||
// 多维评分
|
||||
multiScoreTitle: '📊 多维评分',
|
||||
noEval: '暂无评估',
|
||||
|
||||
// 标签
|
||||
tagsTitle: '🏷️ 标签',
|
||||
noTags: '暂无标签',
|
||||
|
||||
// 状态管理
|
||||
statusTitle: '📋 状态管理',
|
||||
status: {
|
||||
draft: '📝 草稿',
|
||||
pending_review: '⏳ 待评估',
|
||||
approved: '✅ 已批准',
|
||||
promoted: '🚀 已立项',
|
||||
rejected: '❌ 已拒绝',
|
||||
},
|
||||
|
||||
// 操作按钮
|
||||
promoteToProject: '🚀 立项为项目',
|
||||
deleteIdea: '🗑️ 删除想法',
|
||||
|
||||
// 捕捉模态框
|
||||
captureTitle: '✨ 捕捉新想法',
|
||||
fieldTitle: '标题',
|
||||
fieldDesc: '描述',
|
||||
titlePlaceholder: '一句话描述你的想法...',
|
||||
descPlaceholder: '详细说明(可选)...',
|
||||
|
||||
// 原生对话框文案(confirm / alert)
|
||||
confirmDelete: '确定删除想法「{title}」?此操作不可撤销。',
|
||||
promoteFailed: '立项失败',
|
||||
evalFailed: '评估失败',
|
||||
},
|
||||
}
|
||||
14
src/i18n/zh-CN/index.ts
Normal file
14
src/i18n/zh-CN/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// 自动聚合本目录所有模块文件 —— 新增模块文件即生效,无需改此文件。
|
||||
// 排除 index 自身。每个模块文件 export default 一个对象,顶层 key 合并进 messages。
|
||||
const modules = import.meta.glob(['./*.ts'], { eager: true, import: 'default' }) as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>
|
||||
|
||||
const messages: Record<string, unknown> = {}
|
||||
for (const [path, mod] of Object.entries(modules)) {
|
||||
if (path.endsWith('index.ts')) continue
|
||||
Object.assign(messages, mod)
|
||||
}
|
||||
|
||||
export default messages
|
||||
94
src/i18n/zh-CN/knowledge.ts
Normal file
94
src/i18n/zh-CN/knowledge.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
export default {
|
||||
knowledge: {
|
||||
title: '📚 知识库',
|
||||
add: '+ 新增知识',
|
||||
// 顶层 Tab
|
||||
tab: {
|
||||
library: '知识库',
|
||||
inbox: '审核收件箱',
|
||||
},
|
||||
// 搜索
|
||||
searchPlaceholder: '搜索标题、标签、内容...',
|
||||
// 分类
|
||||
categoryAll: '全部',
|
||||
// 列表空状态
|
||||
emptyInbox: '✅ 暂无待审核条目',
|
||||
emptyLibrary: '📭 没有匹配的知识',
|
||||
// 详情头部操作
|
||||
edit: '✏️ 编辑',
|
||||
publish: '发布',
|
||||
reject: '拒绝',
|
||||
archive: '归档',
|
||||
// 详情徽章
|
||||
reuseCount: '🔄 复用 {n} 次',
|
||||
confidenceBadge: '置信度: {label}',
|
||||
// 详情字段
|
||||
contentLabel: '内容',
|
||||
tagsLabel: '标签',
|
||||
tagsNone: '无',
|
||||
tagsPlaceholder: '逗号分隔',
|
||||
// 溯源
|
||||
traceTitle: '🔍 溯源',
|
||||
traceMethod: '产生方式',
|
||||
traceSource: '来源对话',
|
||||
traceTime: '产生时间',
|
||||
originExtracted: '🤖 AI 从对话中提炼',
|
||||
originManual: '✍️ 手动录入',
|
||||
originUnknown: '—',
|
||||
reasoningLabel: '🤖 AI 判断依据',
|
||||
// 引用
|
||||
refTitle: '🔄 引用记录({n} 次)',
|
||||
refEmpty: '尚未被引用',
|
||||
loadMore: '加载更多(剩 {n} 条)',
|
||||
// 生命周期
|
||||
lifecycleTitle: '📅 生命周期',
|
||||
// 详情空状态
|
||||
detailEmptyHint: '选择左侧知识查看完整生命线',
|
||||
// 新增对话框
|
||||
createTitle: '新增知识',
|
||||
kindLabel: '类型',
|
||||
titleLabel: '标题',
|
||||
titlePlaceholder: '简短标题',
|
||||
contentPlaceholder: '完整可复用内容',
|
||||
tagsFieldLabel: '标签(逗号分隔)',
|
||||
tagsFieldPlaceholder: 'Go, 并发, context',
|
||||
confidenceLabel: '置信度',
|
||||
confidenceNone: '不设置',
|
||||
create: '创建',
|
||||
// 时间线节点
|
||||
timeline: {
|
||||
extracted: 'AI 提炼产生',
|
||||
created: '手动录入',
|
||||
published: '审核通过',
|
||||
archived: '归档',
|
||||
enterReview: '进入审核',
|
||||
referenced: '被引用',
|
||||
statusChanged: '状态 → {to}',
|
||||
summarySource: '来源: {title}',
|
||||
summaryConv: '对话: {title}',
|
||||
},
|
||||
// 状态标签
|
||||
status: {
|
||||
candidate: '待审核',
|
||||
pending_review: '审核中',
|
||||
published: '已发布',
|
||||
archived: '已归档',
|
||||
},
|
||||
// 置信度标签
|
||||
confidence: {
|
||||
high: '高',
|
||||
medium: '中',
|
||||
low: '低',
|
||||
},
|
||||
// 知识类型(与 store KNOWLEDGE_KINDS key 对齐)
|
||||
kind: {
|
||||
review_rule: '审查规则',
|
||||
prompt_template: 'Prompt模板',
|
||||
pitfall: '踩坑经验',
|
||||
architecture_pattern: '架构模式',
|
||||
diagnosis: '诊断知识',
|
||||
deployment_note: '部署经验',
|
||||
workflow_optimization: '工作流优化',
|
||||
},
|
||||
},
|
||||
}
|
||||
15
src/i18n/zh-CN/nav.ts
Normal file
15
src/i18n/zh-CN/nav.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export default {
|
||||
nav: {
|
||||
overview: '总览',
|
||||
ideas: '灵感',
|
||||
projects: '项目',
|
||||
tasks: '任务',
|
||||
knowledge: '知识库',
|
||||
decisions: '决策',
|
||||
settings: '设置',
|
||||
workspace: '工作区',
|
||||
traceability: '追溯',
|
||||
systemReady: '系统就绪',
|
||||
aiPanel: 'AI 面板',
|
||||
},
|
||||
}
|
||||
54
src/i18n/zh-CN/projectDetail.ts
Normal file
54
src/i18n/zh-CN/projectDetail.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
export default {
|
||||
projectDetail: {
|
||||
// 头部
|
||||
backToList: '← 项目列表',
|
||||
sync: '🔄 同步',
|
||||
delete: '🗑 删除',
|
||||
newTask: '+ 新任务',
|
||||
// 阶段 pipeline
|
||||
stageIdea: '💡 想法',
|
||||
stageRequirement: '📋 需求',
|
||||
stageCoding: '💻 编码',
|
||||
stageTesting: '🧪 测试',
|
||||
stageRelease: '🚀 发布',
|
||||
// 新建任务模态框
|
||||
newTaskTitle: '新建任务',
|
||||
taskTitleLabel: '任务标题',
|
||||
taskTitlePlaceholder: '输入任务标题',
|
||||
descLabel: '描述',
|
||||
descPlaceholder: '简要描述',
|
||||
branchLabel: '分支名称',
|
||||
confirmCreate: '确认创建',
|
||||
// 项目信息面板
|
||||
infoTitle: '📋 项目信息',
|
||||
sourceIdea: '来源想法',
|
||||
viewIdea: '查看想法详情',
|
||||
ideaDeleted: '原始想法已删除',
|
||||
createdAt: '创建时间',
|
||||
updatedAt: '更新时间',
|
||||
description: '描述',
|
||||
projectStatus: '项目状态',
|
||||
// 任务列表面板
|
||||
taskListTitle: '🔀 任务列表',
|
||||
taskCount: '{n} 个任务',
|
||||
emptyTasks: '暂无任务',
|
||||
// 工作流日志面板
|
||||
workflowLogTitle: '📋 工作流日志',
|
||||
runDemoWorkflow: '▶ 运行测试工作流',
|
||||
emptyWorkflowLog: '点击"运行测试工作流"查看实时日志',
|
||||
// 审批对话框
|
||||
approvalTitle: '需要人工审批',
|
||||
approvalHint: '请选择操作:',
|
||||
approvalLater: '稍后处理',
|
||||
// 删除确认
|
||||
confirmDelete: '确定删除项目「{name}」?将移入回收站,可恢复。',
|
||||
// 项目目录
|
||||
codeDirLabel: '代码目录',
|
||||
noDirBound: '未绑定',
|
||||
relocateDir: '重定位',
|
||||
bindDir: '绑定目录',
|
||||
dirStatusLabel: '目录状态',
|
||||
// 技术栈
|
||||
techStackLabel: '技术栈',
|
||||
},
|
||||
}
|
||||
41
src/i18n/zh-CN/projects.ts
Normal file
41
src/i18n/zh-CN/projects.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
export default {
|
||||
projects: {
|
||||
title: '📂 项目列表',
|
||||
// 头部操作
|
||||
create: '+ 新建项目',
|
||||
trash: '🗑 回收站',
|
||||
// 新建项目模态框
|
||||
createTitle: '新建项目',
|
||||
nameLabel: '项目名称',
|
||||
namePlaceholder: '输入项目名称',
|
||||
descLabel: '项目描述',
|
||||
descPlaceholder: '简要描述项目目标',
|
||||
codeDirLabel: '代码目录',
|
||||
codeDirPlaceholder: '点击右侧选择项目所在目录',
|
||||
selectDir: '选择目录',
|
||||
clearDir: '清除',
|
||||
confirmCreate: '确认创建',
|
||||
// 回收站模态框
|
||||
trashTitle: '🗑 回收站',
|
||||
trashEmpty: '回收站为空',
|
||||
movedIn: '移入',
|
||||
restore: '↩ 恢复',
|
||||
purge: '彻底删除',
|
||||
// 项目卡片
|
||||
deleteTitle: '删除',
|
||||
// 空状态
|
||||
empty: '暂无项目,点击右上角创建第一个项目',
|
||||
// 确认文案
|
||||
confirmDelete: '确定删除项目「{name}」?将移入回收站,可恢复。',
|
||||
confirmPurge: '彻底删除项目「{name}」?连带任务/分支/发布一并物理删除,不可恢复!',
|
||||
// 状态文案(constants/project.ts 的 PROJECT_STATUS_LABELS 值走此 key)
|
||||
status: {
|
||||
planning: '📐 规划中',
|
||||
in_progress: '💻 进行中',
|
||||
paused: '⏸ 已暂停',
|
||||
completed: '✅ 已完成',
|
||||
cancelled: '❌ 已取消',
|
||||
active: '🚀 进行中',
|
||||
},
|
||||
},
|
||||
}
|
||||
118
src/i18n/zh-CN/settings.ts
Normal file
118
src/i18n/zh-CN/settings.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
export default {
|
||||
settings: {
|
||||
// ===== 页面标题 =====
|
||||
title: '⚙️ 设置',
|
||||
|
||||
// ===== AI 模型配置面板 =====
|
||||
panelAiProvider: '🤖 AI 模型配置',
|
||||
addProvider: '+ 添加 Provider',
|
||||
editProviderTitle: '编辑 Provider',
|
||||
addProviderTitle: '添加 Provider',
|
||||
emptyProvider: '暂无 AI 提供商,点击上方按钮添加',
|
||||
badgeDefault: '默认',
|
||||
actionSetDefault: '设为默认',
|
||||
detailBaseUrl: 'Base URL',
|
||||
detailModel: '模型',
|
||||
detailApiKey: 'API Key',
|
||||
|
||||
// Provider 表单
|
||||
labelName: '名称',
|
||||
labelProviderType: '协议类型',
|
||||
labelBaseUrl: 'Base URL',
|
||||
labelApiKey: 'API Key',
|
||||
labelDefaultModel: '默认模型',
|
||||
phProviderName: '如 DeepSeek、GLM-4',
|
||||
phBaseUrl: 'https://api.deepseek.com',
|
||||
phApiKey: 'sk-...',
|
||||
phDefaultModel: 'deepseek-chat',
|
||||
providerTypeOpenaiCompat: 'OpenAI 兼容(DeepSeek / GLM v4 / OpenAI)',
|
||||
providerTypeAnthropic: 'Anthropic 兼容(GLM 订阅 / Claude)',
|
||||
saving: '保存中...',
|
||||
|
||||
// ===== 连接管理面板 =====
|
||||
panelConnection: '🔗 连接管理',
|
||||
addConnection: '+ 添加连接',
|
||||
editConnectionTitle: '编辑连接',
|
||||
addConnectionTitle: '添加连接',
|
||||
emptyConnection: '暂无连接,点击上方按钮添加',
|
||||
labelType: '类型',
|
||||
labelHost: 'Host',
|
||||
labelPort: 'Port',
|
||||
labelUser: '用户名',
|
||||
phConnName: 'flux_dev',
|
||||
phHost: '127.0.0.1',
|
||||
phPort: '3306',
|
||||
phUser: 'root',
|
||||
|
||||
// ===== 通用设置面板 =====
|
||||
panelGeneral: '🎨 通用设置',
|
||||
labelTheme: '主题',
|
||||
descTheme: '深色 / 浅色模式',
|
||||
themeDark: '🌙 深色模式',
|
||||
themeLight: '☀️ 浅色模式',
|
||||
themeSystem: '💻 跟随系统',
|
||||
|
||||
labelLanguage: '语言',
|
||||
descLanguage: '界面显示语言',
|
||||
|
||||
labelAiLanguage: 'AI 对话语言',
|
||||
descAiLanguage: 'AI 回复使用的默认语言',
|
||||
aiLanguageAuto: '🔄 跟随界面语言',
|
||||
aiLanguageZh: '🇨🇳 简体中文',
|
||||
aiLanguageEn: '🇺🇸 English',
|
||||
|
||||
labelAutoExecute: 'AI 自动执行',
|
||||
descAutoExecute: 'AI 可自动执行低风险操作',
|
||||
|
||||
labelLogLevel: '日志级别',
|
||||
descLogLevel: '工作流日志详细程度',
|
||||
|
||||
labelShowTokenUsage: '显示 Token 用量',
|
||||
descShowTokenUsage: '在对话中展示每次回复的 token 消耗',
|
||||
|
||||
labelGlobalConcurrency: '全局最大并发',
|
||||
descGlobalConcurrency: '所有对话共享的 LLM 同时请求数上限',
|
||||
|
||||
labelPerConvConcurrency: '单对话并发上限',
|
||||
descPerConvConcurrency: '单个对话内同时进行的 LLM 调用数(主循环 / 标题 / 提炼共享)',
|
||||
|
||||
// ===== 知识库面板 =====
|
||||
panelKnowledge: '📚 知识库',
|
||||
labelAutoExtract: '自动提炼知识',
|
||||
descAutoExtract: '对话完成后自动从内容中提炼可复用知识(AI 提炼产候选,需人工审核)',
|
||||
labelTriggerMode: '提炼触发方式',
|
||||
descTriggerMode: '自动提炼的触发时机',
|
||||
triggerOnComplete: '对话完成后',
|
||||
triggerOnIdle: '对话闲置后',
|
||||
triggerManualOnly: '仅手动',
|
||||
labelMinMessages: '最少消息数',
|
||||
descMinMessages: '对话达到此消息数才触发提炼(防闲聊噪音)',
|
||||
labelIdleTimeout: '闲置超时(秒)',
|
||||
descIdleTimeout: '闲置触发模式下等待的秒数',
|
||||
labelAutoInject: '自动注入相关知识',
|
||||
descAutoInject: '聊天时自动检索相关知识注入对话上下文',
|
||||
labelVectorEnabled: '语义检索(向量)',
|
||||
descVectorEnabled: '用 Embedding 向量做语义匹配,意思相近用词不同也能搜到;关闭时纯关键词匹配,零外部调用',
|
||||
labelEmbeddingProvider: 'Embedding Provider',
|
||||
descEmbeddingProvider: '向量化用的 AI 提供商(仅 OpenAI 兼容类型;选定后更换需重新嵌入全部知识)',
|
||||
labelEmbeddingModel: 'Embedding 模型',
|
||||
descEmbeddingModel: '如 embedding-3(智谱) / text-embedding-v4(阿里) / text-embedding-3-small(OpenAI)',
|
||||
embeddingProviderNone: '未选择',
|
||||
phEmbeddingModel: 'embedding-3',
|
||||
|
||||
// ===== Toast 提示 =====
|
||||
toastLoadProviderFail: '加载提供商失败',
|
||||
toastSaveIncomplete: '请填写完整(名称 / Base URL / API Key / 模型)',
|
||||
toastSaved: '已保存',
|
||||
toastSaveFail: '保存失败:{msg}',
|
||||
toastDeleted: '已删除',
|
||||
toastDeleteFail: '删除失败:{msg}',
|
||||
toastSetDefaultOk: '已设为默认',
|
||||
toastSetDefaultFail: '设置默认失败:{msg}',
|
||||
toastConnIncomplete: '请填写名称和 Host',
|
||||
|
||||
// ===== 确认弹层 =====
|
||||
confirmDeleteProvider: '确定删除提供商「{name}」?该操作不可恢复。',
|
||||
confirmDeleteConn: '确定删除连接「{name}」?',
|
||||
},
|
||||
}
|
||||
56
src/i18n/zh-CN/tasks.ts
Normal file
56
src/i18n/zh-CN/tasks.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
export default {
|
||||
tasks: {
|
||||
// 页头
|
||||
title: '🔀 任务队列',
|
||||
refresh: '🔄 刷新',
|
||||
create: '+ 新建任务',
|
||||
|
||||
// 筛选栏
|
||||
filter: {
|
||||
project: '项目:',
|
||||
status: '状态:',
|
||||
all: '全部',
|
||||
},
|
||||
// 状态筛选(含图标)
|
||||
statusFilter: {
|
||||
all: '全部',
|
||||
todo: '待开始',
|
||||
in_progress: '进行中',
|
||||
review_ready: '待审查',
|
||||
merged: '已合并',
|
||||
abandoned: '已放弃',
|
||||
},
|
||||
|
||||
// 分组
|
||||
group: {
|
||||
taskCount: '{n} 个任务',
|
||||
unknownProject: '未知项目',
|
||||
},
|
||||
|
||||
// 新建任务模态
|
||||
modal: {
|
||||
title: '新建任务',
|
||||
project: '项目',
|
||||
titleField: '标题',
|
||||
desc: '描述',
|
||||
branch: '分支',
|
||||
priority: '优先级',
|
||||
titlePlaceholder: '输入任务标题...',
|
||||
descPlaceholder: '简要描述(可选)',
|
||||
branchPlaceholder: 'feature/xxx(可选)',
|
||||
// 优先级选项
|
||||
priorityCritical: 'P0 紧急',
|
||||
priorityHigh: 'P1 高',
|
||||
priorityMedium: 'P2 中',
|
||||
priorityLow: 'P3 低',
|
||||
},
|
||||
// 状态文案(constants/project.ts 的 TASK_STATUS_LABELS 值走此 key)
|
||||
status: {
|
||||
todo: '📋 待开始',
|
||||
in_progress: '🔄 进行中',
|
||||
review_ready: '👀 待审查',
|
||||
merged: '✅ 已合并',
|
||||
abandoned: '🗑️ 已放弃',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -6,6 +6,6 @@ import "./styles/global.css";
|
||||
|
||||
createApp(App).use(router).use(i18n).mount("#app");
|
||||
|
||||
// 启动计时:从 index.html 解析到 Vue 挂载完成
|
||||
// 启动计时:从 index.html 解析到 Vue 挂载完成(debug 级,空白屏复现时开 verbose 看)
|
||||
const t0 = (window as any).__APP_T0 ?? 0;
|
||||
console.log(`[启动] Vue mount 完成: ${(performance.now() - t0).toFixed(0)}ms`);
|
||||
console.debug(`[启动] Vue mount 完成: ${(performance.now() - t0).toFixed(0)}ms`);
|
||||
|
||||
534
src/stores/ai.ts
534
src/stores/ai.ts
@@ -1,15 +1,35 @@
|
||||
//! AI 聊天 Store — 管理对话状态、流式渲染、工具审批、对话管理
|
||||
//! AI 聊天 Store — 模块级单例 state + useAiStore() 统一入口
|
||||
//!
|
||||
//! 架构(路线A:state 单例 + 逻辑外移到 composables):
|
||||
//! - 本文件只保留模块级单例 reactive state(19 字段)+ useAiStore() 统一入口
|
||||
//! - 所有方法搬到 src/composables/ai/*,通过 ...useAiXxx(state) 展开到 useAiStore() 返回值
|
||||
//! - 组件用法不变:`const { state, sendMessage, ... } = useAiStore()` 零改动
|
||||
//! - composables 之间通过模块级 import 直接互调(events↔send↔stream 耦合)
|
||||
//!
|
||||
//! composables 职责:
|
||||
//! - useAiEvents startListener/stopListener/handleEvent/flushCurrentText/findToolCall/friendlyError/notifyConversationChanged
|
||||
//! - useAiStream onStreamTimeout/resetStreamWatchdog/clearStreamWatchdog + STREAM_TIMEOUT_MS
|
||||
//! - useAiSend sendMessage/approveToolCall/drainQueue/cancelQueued/clearQueue/stopChat
|
||||
//! - useAiConversations loadConversations/newConversation/switchConversation/deleteConversation/renameConversation/archiveConversation/toggleArchivedFold/toggleSidebar
|
||||
//! - useAiWindow detachPanel/reattachPanel/resumeInDetached/closeDetachedWindow/dockDetached/syncToMain/startFollowMain/stopFollowMain
|
||||
//! - useAiPanel togglePanel/toggleMaximize/loadProviders/loadSkills/setProvider/clearChat + restoreUiState/persistUiState
|
||||
//!
|
||||
//! 注意:
|
||||
//! - 各 composable 模块加载时会执行其顶层副作用(useAiPanel.restoreUiState + watch appSettings),
|
||||
//! 故 useAiStore() 之外无需手动 init;以下 import 即触发恢复逻辑
|
||||
//! - state 为模块级单例,全应用共享同一引用
|
||||
|
||||
import { reactive } from 'vue'
|
||||
import { listen, emit } from '@tauri-apps/api/event'
|
||||
import { aiApi } from '../api'
|
||||
import type { AiChatEvent, AiConversationSummary, AiMessage, AiProviderConfig, AiToolCallInfo } from '../api/types'
|
||||
import type { AiChatEvent, AiConversationSummary, AiMessage, AiProviderConfig, AiToolCallInfo, SkillInfo } from '../api/types'
|
||||
import { useAiEvents } from '../composables/ai/useAiEvents'
|
||||
import { useAiStream } from '../composables/ai/useAiStream'
|
||||
import { useAiSend } from '../composables/ai/useAiSend'
|
||||
import { useAiConversations } from '../composables/ai/useAiConversations'
|
||||
import { useAiWindow } from '../composables/ai/useAiWindow'
|
||||
import { useAiPanel } from '../composables/ai/useAiPanel'
|
||||
|
||||
let _unlistenAiEvent: (() => void) | null = null
|
||||
let _unlistenConvChanged: (() => void) | null = null
|
||||
let _msgCounter = 0
|
||||
|
||||
const state = reactive({
|
||||
/// 模块级单例 state — 全应用共享(composables 通过 `import { state } from '../../stores/ai'` 取用)
|
||||
export const state = reactive({
|
||||
messages: [] as AiMessage[],
|
||||
streaming: false,
|
||||
currentText: '',
|
||||
@@ -29,481 +49,35 @@ const state = reactive({
|
||||
detached: false,
|
||||
// 吸附跟随中
|
||||
docked: false,
|
||||
// 本机技能(`/` 联想)
|
||||
skills: [] as SkillInfo[],
|
||||
// 待发送队列(生成中发的消息排队,AiCompleted 后自动续发)
|
||||
queue: [] as { text: string; skill?: string }[],
|
||||
// 归档分组折叠态(默认折叠)
|
||||
archivedCollapsed: true,
|
||||
// token 用量展示(受 df-show-token-usage 开关控制;lastTokenUsage=最近一条回复,convTokenTotal=当前对话累计)
|
||||
lastTokenUsage: null as { prompt: number; completion: number; total: number } | null,
|
||||
convTokenTotal: null as { prompt: number; completion: number; total: number } | null,
|
||||
})
|
||||
|
||||
// 旁注:此处不再保留 type-only 导出(AiChatEvent 等),因组件直接从 api/types import。
|
||||
// 若有外部模块仍从本文件 import 这些类型,下方 re-export 兜底:
|
||||
export type { AiChatEvent, AiConversationSummary, AiMessage, AiProviderConfig, AiToolCallInfo, SkillInfo }
|
||||
|
||||
/**
|
||||
* AI Store 统一入口 — 返回单例 state + 全部方法。
|
||||
*
|
||||
* 组件零改动:解构 shape 与拆分前完全一致。每次调用都重新展开 composable 方法
|
||||
* (composable 内部全部是模块级单例实现,展开的只是引用,无重复实例化开销)。
|
||||
*/
|
||||
export function useAiStore() {
|
||||
async function startListener() {
|
||||
if (_unlistenAiEvent) return
|
||||
_unlistenAiEvent = await aiApi.onEvent(handleEvent)
|
||||
_unlistenConvChanged = await listen('ai-conversation-changed', () => {
|
||||
loadConversations()
|
||||
})
|
||||
}
|
||||
|
||||
function stopListener() {
|
||||
_unlistenAiEvent?.()
|
||||
_unlistenConvChanged?.()
|
||||
_unlistenAiEvent = null
|
||||
_unlistenConvChanged = null
|
||||
}
|
||||
|
||||
function notifyConversationChanged() {
|
||||
emit('ai-conversation-changed', {})
|
||||
}
|
||||
|
||||
/** 后端原始错误转用户友好提示 */
|
||||
function friendlyError(raw: string): string {
|
||||
if (/404|not\s*found/i.test(raw)) return '调用失败:接口地址或模型不存在,请检查 Provider 配置'
|
||||
if (/401|403|unauthorized|api[_\s-]?key/i.test(raw)) return '调用失败:API Key 无效或无权限'
|
||||
if (/timeout|超时/i.test(raw)) return '响应超时,请重试'
|
||||
if (/network|connection|ECONN|网络|连接/i.test(raw)) return '网络连接失败,请检查网络'
|
||||
return raw
|
||||
}
|
||||
|
||||
function handleEvent(event: AiChatEvent) {
|
||||
const convId = event.conversation_id
|
||||
// 首次收到事件时同步当前对话 id(后端自动建对话的场景)
|
||||
if (convId && !state.activeConversationId) state.activeConversationId = convId
|
||||
// 事件不属于当前展示对话(生成中切走了)→ 不污染当前视图,仅完成/错误时刷新侧边栏
|
||||
const isCurrent = !convId || convId === state.activeConversationId
|
||||
if (!isCurrent) {
|
||||
if (event.type === 'AiCompleted' || event.type === 'AiError') {
|
||||
state.generatingConvId = null
|
||||
loadConversations()
|
||||
}
|
||||
return
|
||||
}
|
||||
// 标记正在生成的对话(完成/错误事件除外)
|
||||
if (convId && event.type !== 'AiCompleted' && event.type !== 'AiError') {
|
||||
state.generatingConvId = convId
|
||||
}
|
||||
switch (event.type) {
|
||||
case 'AiTextDelta':
|
||||
state.currentText += event.delta
|
||||
break
|
||||
|
||||
case 'AiAgentRound': {
|
||||
// Agent 循环新一轮:保存当前文本到上一条 assistant 消息,新建空 assistant 消息
|
||||
if (state.currentText) {
|
||||
const lastMsg = state.messages[state.messages.length - 1]
|
||||
if (lastMsg && lastMsg.role === 'assistant') {
|
||||
lastMsg.content = state.currentText
|
||||
}
|
||||
}
|
||||
state.currentText = ''
|
||||
state.messages.push({
|
||||
id: `ai-${++_msgCounter}`,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'AiToolCallStarted': {
|
||||
const info: AiToolCallInfo = {
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
args: event.args,
|
||||
status: 'running',
|
||||
}
|
||||
const lastMsg = state.messages[state.messages.length - 1]
|
||||
if (lastMsg && lastMsg.role === 'assistant') {
|
||||
lastMsg.toolCalls = lastMsg.toolCalls || []
|
||||
lastMsg.toolCalls.push(info)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'AiToolCallCompleted': {
|
||||
const tc = findToolCall(event.id)
|
||||
if (tc) {
|
||||
tc.status = 'completed'
|
||||
tc.result = event.result
|
||||
}
|
||||
state.pendingApprovals = state.pendingApprovals.filter(p => p.id !== event.id)
|
||||
break
|
||||
}
|
||||
|
||||
case 'AiApprovalRequired': {
|
||||
const info: AiToolCallInfo = {
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
args: event.args,
|
||||
status: 'pending_approval',
|
||||
}
|
||||
state.pendingApprovals.push(info)
|
||||
const tc = findToolCall(event.id)
|
||||
if (tc) tc.status = 'pending_approval'
|
||||
break
|
||||
}
|
||||
|
||||
case 'AiApprovalResult': {
|
||||
if (!event.approved) {
|
||||
const tc = findToolCall(event.id)
|
||||
if (tc) tc.status = 'rejected'
|
||||
state.pendingApprovals = state.pendingApprovals.filter(p => p.id !== event.id)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'AiCompleted': {
|
||||
if (state.currentText) {
|
||||
const lastMsg = state.messages[state.messages.length - 1]
|
||||
if (lastMsg && lastMsg.role === 'assistant') {
|
||||
lastMsg.content = state.currentText
|
||||
}
|
||||
}
|
||||
state.currentText = ''
|
||||
state.streaming = false
|
||||
state.generatingConvId = null
|
||||
// 清理分离窗口生成态快照
|
||||
localStorage.removeItem('df-ai-gen')
|
||||
localStorage.removeItem('df-ai-text')
|
||||
loadConversations()
|
||||
notifyConversationChanged()
|
||||
break
|
||||
}
|
||||
|
||||
case 'AiError': {
|
||||
state.streaming = false
|
||||
state.generatingConvId = null
|
||||
state.currentText = ''
|
||||
localStorage.removeItem('df-ai-gen')
|
||||
localStorage.removeItem('df-ai-text')
|
||||
state.messages.push({
|
||||
id: `err-${++_msgCounter}`,
|
||||
role: 'assistant',
|
||||
content: friendlyError(event.error),
|
||||
isError: true,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findToolCall(id: string): AiToolCallInfo | undefined {
|
||||
for (const msg of state.messages) {
|
||||
if (msg.toolCalls) {
|
||||
const tc = msg.toolCalls.find(t => t.id === id)
|
||||
if (tc) return tc
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
async function sendMessage(text: string) {
|
||||
if (!text.trim() || state.streaming) return
|
||||
|
||||
state.messages.push({
|
||||
id: `user-${++_msgCounter}`,
|
||||
role: 'user',
|
||||
content: text.trim(),
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
|
||||
state.messages.push({
|
||||
id: `ai-${++_msgCounter}`,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
|
||||
state.streaming = true
|
||||
state.currentText = ''
|
||||
|
||||
await startListener()
|
||||
const raw = localStorage.getItem('df-ai-language') || 'auto'
|
||||
const lang = raw === 'auto'
|
||||
? (localStorage.getItem('df-language') || 'zh-CN')
|
||||
: raw
|
||||
await aiApi.sendMessage(text.trim(), lang)
|
||||
}
|
||||
|
||||
async function approveToolCall(toolCallId: string, approved: boolean) {
|
||||
await aiApi.approve(toolCallId, approved)
|
||||
}
|
||||
|
||||
async function loadProviders() {
|
||||
state.providers = await aiApi.listProviders()
|
||||
}
|
||||
|
||||
async function setProvider(providerId: string) {
|
||||
await aiApi.setProvider(providerId)
|
||||
state.activeProvider = providerId
|
||||
}
|
||||
|
||||
async function clearChat() {
|
||||
await aiApi.clearChat()
|
||||
state.messages = []
|
||||
state.currentText = ''
|
||||
state.pendingApprovals = []
|
||||
state.streaming = false
|
||||
}
|
||||
|
||||
/** 停止当前生成:仅发停止信号,streaming 状态由后端 AiCompleted 事件收尾 */
|
||||
async function stopChat() {
|
||||
await aiApi.stopChat()
|
||||
}
|
||||
|
||||
function togglePanel() {
|
||||
state.panelOpen = !state.panelOpen
|
||||
}
|
||||
|
||||
function toggleMaximize() {
|
||||
state.maximized = !state.maximized
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════
|
||||
// 对话管理
|
||||
// ══════════════════════════════════════
|
||||
|
||||
async function loadConversations() {
|
||||
try {
|
||||
state.conversations = await aiApi.listConversations()
|
||||
} catch {
|
||||
// 静默失败,不影响主流程
|
||||
}
|
||||
}
|
||||
|
||||
async function newConversation() {
|
||||
const result = await aiApi.createConversation()
|
||||
state.activeConversationId = result.id
|
||||
state.messages = []
|
||||
state.currentText = ''
|
||||
state.pendingApprovals = []
|
||||
state.streaming = false
|
||||
await loadConversations()
|
||||
notifyConversationChanged()
|
||||
}
|
||||
|
||||
async function switchConversation(id: string) {
|
||||
// 允许生成中切换:后台继续生成,事件按 conversation_id 路由不污染当前视图
|
||||
const detail = await aiApi.switchConversation(id)
|
||||
state.activeConversationId = id
|
||||
|
||||
try {
|
||||
const rawMsgs = typeof detail.messages === 'string'
|
||||
? JSON.parse(detail.messages)
|
||||
: detail.messages
|
||||
|
||||
// 构建 tool_call_id → tool_result 映射,用于回填工具执行结果
|
||||
const toolResultMap = new Map<string, string>()
|
||||
for (const m of rawMsgs) {
|
||||
if (m.role === 'tool' && m.tool_call_id) {
|
||||
toolResultMap.set(m.tool_call_id, m.content || '')
|
||||
}
|
||||
}
|
||||
|
||||
state.messages = rawMsgs
|
||||
.filter((m: any) => m.role !== 'tool')
|
||||
.map((m: any, i: number) => ({
|
||||
id: `loaded-${i}`,
|
||||
role: m.role,
|
||||
content: m.content || '',
|
||||
timestamp: Date.now(),
|
||||
toolCalls: m.tool_calls?.map((tc: any) => ({
|
||||
id: tc.id,
|
||||
name: tc.function?.name || '',
|
||||
args: typeof tc.function?.arguments === 'string'
|
||||
? JSON.parse(tc.function.arguments || '{}')
|
||||
: tc.function?.arguments || {},
|
||||
status: 'completed' as const,
|
||||
result: toolResultMap.get(tc.id),
|
||||
})),
|
||||
}))
|
||||
} catch {
|
||||
state.messages = []
|
||||
}
|
||||
|
||||
state.currentText = ''
|
||||
state.pendingApprovals = []
|
||||
}
|
||||
|
||||
async function deleteConversation(id: string) {
|
||||
await aiApi.deleteConversation(id)
|
||||
if (state.activeConversationId === id) {
|
||||
state.activeConversationId = null
|
||||
state.messages = []
|
||||
}
|
||||
await loadConversations()
|
||||
notifyConversationChanged()
|
||||
}
|
||||
|
||||
async function renameConversation(id: string, title: string) {
|
||||
await aiApi.renameConversation(id, title)
|
||||
// 本地同步更新侧栏摘要标题
|
||||
const conv = state.conversations.find(c => c.id === id)
|
||||
if (conv) conv.title = title
|
||||
notifyConversationChanged()
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
state.sidebarOpen = !state.sidebarOpen
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════
|
||||
// 窗口模式
|
||||
// ══════════════════════════════════════
|
||||
|
||||
async function detachPanel() {
|
||||
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||
const existing = await WebviewWindow.getByLabel('ai-detached')
|
||||
if (existing) {
|
||||
await existing.setFocus()
|
||||
return
|
||||
}
|
||||
// 快照当前生成态,供分离窗口接管(保持正在进行的对话)
|
||||
if (state.streaming && state.generatingConvId) {
|
||||
localStorage.setItem('df-ai-gen', state.generatingConvId)
|
||||
localStorage.setItem('df-ai-text', state.currentText)
|
||||
}
|
||||
const convId = state.activeConversationId
|
||||
const sep = convId ? `?conv=${encodeURIComponent(convId)}` : ''
|
||||
const url = window.location.origin + window.location.pathname + '#/ai-detached' + sep
|
||||
const win = new WebviewWindow('ai-detached', {
|
||||
url,
|
||||
title: 'DevFlow AI',
|
||||
width: 520,
|
||||
height: 700,
|
||||
minWidth: 360,
|
||||
minHeight: 400,
|
||||
center: true,
|
||||
decorations: true,
|
||||
})
|
||||
win.once('tauri://created', () => {
|
||||
state.panelOpen = false
|
||||
})
|
||||
win.once('tauri://error', (e) => {
|
||||
console.error('[AI] 窗口创建失败:', e)
|
||||
state.detached = false
|
||||
})
|
||||
win.once('tauri://destroyed', () => {
|
||||
state.detached = false
|
||||
state.docked = false
|
||||
stopFollowMain()
|
||||
})
|
||||
state.detached = true
|
||||
}
|
||||
|
||||
async function reattachPanel() {
|
||||
stopFollowMain()
|
||||
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||
const win = await WebviewWindow.getByLabel('ai-detached')
|
||||
if (win) {
|
||||
await win.close()
|
||||
}
|
||||
state.detached = false
|
||||
state.docked = false
|
||||
state.panelOpen = true
|
||||
localStorage.removeItem('df-ai-gen')
|
||||
localStorage.removeItem('df-ai-text')
|
||||
}
|
||||
|
||||
/** 分离窗口挂载时接管主窗口当前对话(含生成中态) */
|
||||
async function resumeInDetached(convId: string | null) {
|
||||
const id = convId || state.activeConversationId
|
||||
if (id) await switchConversation(id)
|
||||
const gen = localStorage.getItem('df-ai-gen')
|
||||
if (gen) {
|
||||
// 接管正在生成的对话:恢复已生成文本并补占位,后续 delta 继续追加
|
||||
state.currentText = localStorage.getItem('df-ai-text') || ''
|
||||
state.messages.push({
|
||||
id: `ai-${++_msgCounter}`,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
state.streaming = true
|
||||
state.generatingConvId = gen
|
||||
}
|
||||
}
|
||||
|
||||
async function closeDetachedWindow() {
|
||||
state.docked = false
|
||||
stopFollowMain()
|
||||
localStorage.removeItem('df-ai-gen')
|
||||
localStorage.removeItem('df-ai-text')
|
||||
const { getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||
await getCurrentWebviewWindow().close()
|
||||
}
|
||||
|
||||
/** 吸附/取消吸附:AI 窗口跟随主窗口右侧 */
|
||||
async function dockDetached() {
|
||||
// 取消吸附
|
||||
if (state.docked) {
|
||||
state.docked = false
|
||||
stopFollowMain()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await syncToMain()
|
||||
state.docked = true
|
||||
startFollowMain()
|
||||
} catch (e) {
|
||||
console.error('[AI] 吸附失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 同步 AI 窗口位置到主窗口右侧(保留 4px 间隙) */
|
||||
async function syncToMain() {
|
||||
const { WebviewWindow, getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||
const { PhysicalPosition, PhysicalSize } = await import('@tauri-apps/api/dpi')
|
||||
const mainWin = await WebviewWindow.getByLabel('main')
|
||||
const aiWin = getCurrentWebviewWindow()
|
||||
if (!mainWin) return
|
||||
const pos = await mainWin.outerPosition()
|
||||
const size = await mainWin.innerSize()
|
||||
await aiWin.setPosition(new PhysicalPosition(pos.x + size.width + 4, pos.y))
|
||||
await aiWin.setSize(new PhysicalSize(600, size.height))
|
||||
}
|
||||
|
||||
// ── 主窗口事件跟随 ──
|
||||
let _unlistenMove: (() => void) | null = null
|
||||
let _unlistenResize: (() => void) | null = null
|
||||
|
||||
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(() => { syncToMain() })
|
||||
_unlistenResize = await mainWin.onResized(() => { syncToMain() })
|
||||
}
|
||||
|
||||
function stopFollowMain() {
|
||||
_unlistenMove?.()
|
||||
_unlistenResize?.()
|
||||
_unlistenMove = null
|
||||
_unlistenResize = null
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
sendMessage,
|
||||
approveToolCall,
|
||||
loadProviders,
|
||||
setProvider,
|
||||
clearChat,
|
||||
stopChat,
|
||||
togglePanel,
|
||||
toggleMaximize,
|
||||
startListener,
|
||||
stopListener,
|
||||
// 对话管理
|
||||
loadConversations,
|
||||
newConversation,
|
||||
switchConversation,
|
||||
deleteConversation,
|
||||
renameConversation,
|
||||
toggleSidebar,
|
||||
// 窗口模式
|
||||
detachPanel,
|
||||
reattachPanel,
|
||||
closeDetachedWindow,
|
||||
dockDetached,
|
||||
resumeInDetached,
|
||||
...useAiEvents(),
|
||||
...useAiStream(),
|
||||
...useAiSend(),
|
||||
...useAiConversations(),
|
||||
...useAiWindow(),
|
||||
...useAiPanel(),
|
||||
}
|
||||
}
|
||||
|
||||
138
src/stores/appSettings.ts
Normal file
138
src/stores/appSettings.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
//! 应用设置 KV Store — localStorage → SQLite 迁移的统一持久化层
|
||||
//!
|
||||
//! 后端 `app_settings` 表的 value 永远是 JSON 字符串。本层职责:
|
||||
//! - 缓存已解析(反序列化)值,按 key 索引,组件同步读
|
||||
//! - 写操作立即更新缓存(乐观),并按 key debounce ~300ms 合并连续快速写
|
||||
//! - 组合式 `useSetting` 让组件像用 ref 一样用 v-model 绑定某个 key
|
||||
//!
|
||||
//! 注意:此为「KV 偏好持久化」层,与已有的 `stores/settings.ts`(mock 的 AI 提供商/
|
||||
//! 连接/通用配置列表)是两件事 —— 那个文件承载静态展示数据,本文件承载运行时偏好,
|
||||
//! 故单独成文,互不干扰。
|
||||
|
||||
import { reactive, ref, watch, type Ref } from 'vue'
|
||||
import { settingsApi } from '../api/settings'
|
||||
|
||||
/// 按 key 合并连续写的 debounce 时长(毫秒)
|
||||
const SET_DEBOUNCE_MS = 300
|
||||
|
||||
/// 已解析值的响应式缓存 —— 用 reactive 对象而非 Map,使 `useSetting` 的 watch 能
|
||||
/// 在 `loadAll` / `set` / `remove` 写入时被触发,从而同步刷新组件视图
|
||||
const cache = reactive<Record<string, unknown>>({})
|
||||
|
||||
/// 按 key 的 pending 写定时器:连续 set 在窗口内只发最后一次到 SQLite
|
||||
const pendingTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
/// 载荷队列:debounce 触发时取最新值(而非定时器创建时的快照)
|
||||
const pendingValues = new Map<string, unknown>()
|
||||
|
||||
/** 启动时一次性拉回全部 key/value 并解析填充缓存(App.vue 调用) */
|
||||
async function loadAll(): Promise<void> {
|
||||
const all = await settingsApi.getAll()
|
||||
for (const [k, raw] of Object.entries(all)) {
|
||||
cache[k] = parse(raw)
|
||||
}
|
||||
}
|
||||
|
||||
/** 同步取已解析值,未命中或缓存未加载时返回 defaultValue */
|
||||
function get<T>(key: string, defaultValue: T): T {
|
||||
const v = cache[key]
|
||||
return v === undefined ? defaultValue : (v as T)
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入某 key:立即更新缓存(乐观,视图无延迟),再 debounce 后落库。
|
||||
* 同 key 连续快速写(如拖拽/输入)在窗口内合并,只把最后一次值 JSON.stringify 发后端。
|
||||
*/
|
||||
async function set(key: string, value: unknown): Promise<void> {
|
||||
cache[key] = value
|
||||
pendingValues.set(key, value)
|
||||
|
||||
const existing = pendingTimers.get(key)
|
||||
if (existing) clearTimeout(existing)
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
pendingTimers.delete(key)
|
||||
const latest = pendingValues.get(key)
|
||||
pendingValues.delete(key)
|
||||
// 落库失败仅记日志:缓存已是最新值,降级为内存态,避免回滚造成 UI 与库不一致
|
||||
settingsApi
|
||||
.set(key, JSON.stringify(latest))
|
||||
.catch((e) => console.error(`[appSettings] 写入 ${key} 失败:`, e))
|
||||
}, SET_DEBOUNCE_MS)
|
||||
pendingTimers.set(key, timer)
|
||||
}
|
||||
|
||||
/** 删除某 key:清缓存 + 取消 pending 写 + 调后端删除 */
|
||||
async function remove(key: string): Promise<void> {
|
||||
delete cache[key]
|
||||
pendingValues.delete(key)
|
||||
const t = pendingTimers.get(key)
|
||||
if (t) {
|
||||
clearTimeout(t)
|
||||
pendingTimers.delete(key)
|
||||
}
|
||||
await settingsApi.delete(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* 组合式:把某 key 绑定到一个 ref,组件可直接 v-model。
|
||||
* 读走缓存(同步、响应式),写触发 debounced set。
|
||||
*
|
||||
* 实现说明:用普通 `ref` 持有当前值,`watch(cache[key])` 单向同步缓存 → ref,
|
||||
* 这样 `loadAll` 异步填充缓存后能正确刷新组件视图(customRef 的 trigger 不会被
|
||||
* reactive cache 的异步 mutation 自动唤起,故改用 watch 显式同步)。
|
||||
*
|
||||
* @example
|
||||
* const theme = useSetting('df-theme', 'dark')
|
||||
* // 模板里 <a-switch v-model="theme" /> —— 改动自动落库
|
||||
*/
|
||||
function useSetting<T>(key: string, defaultValue: T): Ref<T> {
|
||||
const r = ref(get<T>(key, defaultValue)) as Ref<T>
|
||||
|
||||
// 缓存 → ref:loadAll/set/remove 改 cache[key] 时同步到 ref
|
||||
watch(
|
||||
() => cache[key],
|
||||
(v) => {
|
||||
// 写回值与当前 ref 不同时才更新,避免组件 set 后被 watch 回写造成抖动
|
||||
const next = v === undefined ? defaultValue : (v as T)
|
||||
if (!Object.is(next, r.value)) r.value = next
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
// ref → 缓存(及落库):组件改 .value 时触发 debounced set
|
||||
watch(
|
||||
r,
|
||||
(v) => {
|
||||
// 仅当与缓存当前值不同时才写,避免 watch 回写触发循环
|
||||
if (!Object.is(v, cache[key])) void set(key, v)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析后端原始值:JSON.parse 失败时回退为原字符串(兼容历史非 JSON 数据)。
|
||||
* null/空串直接返回,避免 `JSON.parse('')` 抛错。
|
||||
*/
|
||||
function parse(raw: string): unknown {
|
||||
if (raw === null || raw === '') return raw
|
||||
try {
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
||||
export function useAppSettingsStore() {
|
||||
return {
|
||||
cache,
|
||||
loadAll,
|
||||
get,
|
||||
set,
|
||||
remove,
|
||||
useSetting,
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
export { useProjectStore } from './project'
|
||||
|
||||
export { useKnowledgeStore } from './knowledge'
|
||||
export type { KnowledgeItem } from './knowledge'
|
||||
|
||||
|
||||
export { useSettingsStore } from './settings'
|
||||
export type { AIProvider, Connection, GeneralSettings } from './settings'
|
||||
|
||||
export { useAppSettingsStore } from './appSettings'
|
||||
|
||||
@@ -1,79 +1,156 @@
|
||||
import { reactive, computed } from 'vue'
|
||||
import { knowledgeApi } from '../api'
|
||||
import type {
|
||||
KnowledgeRecord,
|
||||
KnowledgeDetailPayload,
|
||||
KnowledgeEventRecord,
|
||||
CreateKnowledgeInput,
|
||||
UpdateKnowledgeInput,
|
||||
KnowledgeConfig,
|
||||
} from '../api/types'
|
||||
|
||||
export interface KnowledgeItem {
|
||||
id: number
|
||||
title: string
|
||||
description: string
|
||||
category: string
|
||||
tags: string[]
|
||||
reuseCount: number
|
||||
score: number
|
||||
updatedAt: string
|
||||
}
|
||||
// ── 全局响应式状态(单例) ──
|
||||
|
||||
const state = reactive({
|
||||
items: [
|
||||
// 审查规则
|
||||
{ id: 1, title: 'SQL 注入防护检查', description: '所有 SQL 拼接必须使用参数化查询,禁止字符串拼接用户输入', category: 'review', tags: ['安全', 'SQL', 'Go'], reuseCount: 23, score: 95, updatedAt: '3 天前' },
|
||||
{ id: 2, title: '错误处理规范', description: '禁止吞掉 error,必须向上传播或记录日志', category: 'review', tags: ['Go', '规范', '错误处理'], reuseCount: 18, score: 88, updatedAt: '1 周前' },
|
||||
{ id: 3, title: 'API 响应格式一致性', description: '统一使用 { code, message, data } 结构,HTTP 状态码语义正确', category: 'review', tags: ['API', '规范'], reuseCount: 15, score: 82, updatedAt: '5 天前' },
|
||||
// Prompt 模板
|
||||
{ id: 4, title: 'SQL 查询生成 Prompt', description: '根据自然语言生成 SQL,包含表结构上下文和示例输出格式', category: 'prompt', tags: ['SQL', 'LLM', '模板'], reuseCount: 142, score: 91, updatedAt: '昨天' },
|
||||
{ id: 5, title: '代码审查 Prompt', description: 'AI 代码审查专用 Prompt,涵盖安全、性能、可维护性维度', category: 'prompt', tags: ['代码审查', 'LLM'], reuseCount: 87, score: 85, updatedAt: '3 天前' },
|
||||
{ id: 6, title: 'API 文档生成 Prompt', description: '从 Handler 代码自动生成 API 文档的 Prompt 模板', category: 'prompt', tags: ['文档', 'LLM', '自动生成'], reuseCount: 34, score: 78, updatedAt: '1 周前' },
|
||||
// 踩坑经验
|
||||
{ id: 7, title: 'Go context 传递陷阱', description: 'goroutine 中必须传递 context 而非创建新的,否则超时控制失效', category: 'pitfall', tags: ['Go', '并发', 'context'], reuseCount: 12, score: 92, updatedAt: '2 天前' },
|
||||
{ id: 8, title: 'Vue 3 作用域坑', description: 'v-for 内 ref 绑定不会自动响应式,需使用数组形式', category: 'pitfall', tags: ['Vue', '前端', '响应式'], reuseCount: 8, score: 75, updatedAt: '1 周前' },
|
||||
{ id: 9, title: 'Docker 网络模式选择', description: 'host 模式在 Mac/Win 上无效,必须用端口映射', category: 'pitfall', tags: ['Docker', '网络'], reuseCount: 6, score: 70, updatedAt: '2 周前' },
|
||||
// 诊断知识
|
||||
{ id: 10, title: 'MySQL 慢查询诊断流程', description: 'EXPLAIN → 索引检查 → 慢查询日志分析 → 优化建议', category: 'diagnosis', tags: ['MySQL', '性能', '诊断'], reuseCount: 31, score: 89, updatedAt: '4 天前' },
|
||||
{ id: 11, title: 'Go 内存泄漏排查', description: 'pprof heap 分析 → goroutine 泄漏检查 → GC 调优', category: 'diagnosis', tags: ['Go', '内存', 'pprof'], reuseCount: 9, score: 83, updatedAt: '1 周前' },
|
||||
{ id: 12, title: '前端白屏诊断', description: 'Console 错误 → 网络请求 → 路由配置 → 构建产物检查', category: 'diagnosis', tags: ['前端', '调试', 'Vue'], reuseCount: 14, score: 80, updatedAt: '5 天前' },
|
||||
// 部署经验
|
||||
{ id: 13, title: 'Go 二进制热更新', description: 'kill + mv + nohup 启动,无需 systemd 的轻量部署方案', category: 'deploy', tags: ['Go', '部署', 'Linux'], reuseCount: 19, score: 86, updatedAt: '昨天' },
|
||||
{ id: 14, title: 'SCP 上传最佳实践', description: '使用 ssh-proxy upload 代替 scp,支持配置化管理', category: 'deploy', tags: ['SCP', '部署', '工具'], reuseCount: 11, score: 72, updatedAt: '3 天前' },
|
||||
{ id: 15, title: 'Nginx 反向代理配置', description: 'u-ask 的 Nginx 配置模板,含 WebSocket 支持和 gzip', category: 'deploy', tags: ['Nginx', '配置', '反向代理'], reuseCount: 7, score: 77, updatedAt: '1 周前' },
|
||||
] as KnowledgeItem[],
|
||||
|
||||
categories: [
|
||||
{ key: 'all', label: '全部', icon: '📦' },
|
||||
{ key: 'review', label: '审查规则', icon: '🔍' },
|
||||
{ key: 'prompt', label: 'Prompt模板', icon: '💬' },
|
||||
{ key: 'pitfall', label: '踩坑经验', icon: '⚠️' },
|
||||
{ key: 'diagnosis', label: '诊断知识', icon: '🩺' },
|
||||
{ key: 'deploy', label: '部署经验', icon: '🚀' },
|
||||
],
|
||||
items: [] as KnowledgeRecord[],
|
||||
candidates: [] as KnowledgeRecord[],
|
||||
config: null as KnowledgeConfig | null,
|
||||
loading: false,
|
||||
error: null as string | null,
|
||||
})
|
||||
|
||||
export function useKnowledgeStore() {
|
||||
const items = computed(() => state.items)
|
||||
// 7 种知识类型(snake_case,与后端 KnowledgeKind 对齐)
|
||||
// 仅存 key + icon;展示文案(label)走 i18n: $t('knowledge.kind.<key>')
|
||||
export const KNOWLEDGE_KINDS = [
|
||||
{ key: 'review_rule', icon: '🔍' },
|
||||
{ key: 'prompt_template', icon: '💬' },
|
||||
{ key: 'pitfall', icon: '⚠️' },
|
||||
{ key: 'architecture_pattern', icon: '🏛️' },
|
||||
{ key: 'diagnosis', icon: '🩺' },
|
||||
{ key: 'deployment_note', icon: '🚀' },
|
||||
{ key: 'workflow_optimization', icon: '⚡' },
|
||||
] as const
|
||||
|
||||
const getByCategory = (category: string) =>
|
||||
computed(() =>
|
||||
category === 'all'
|
||||
? state.items
|
||||
: state.items.filter(i => i.category === category)
|
||||
)
|
||||
|
||||
const search = (query: string) =>
|
||||
computed(() => {
|
||||
if (!query.trim()) return state.items
|
||||
const q = query.toLowerCase()
|
||||
return state.items.filter(i =>
|
||||
i.title.toLowerCase().includes(q) ||
|
||||
i.tags.some(t => t.toLowerCase().includes(q)) ||
|
||||
i.description.toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
|
||||
const getCategoryCount = (key: string) =>
|
||||
key === 'all' ? state.items.length : state.items.filter(i => i.category === key).length
|
||||
|
||||
return {
|
||||
items,
|
||||
categories: computed(() => state.categories),
|
||||
getByCategory,
|
||||
search,
|
||||
getCategoryCount,
|
||||
/** 解析 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 useKnowledgeStore() {
|
||||
// ── 加载列表(默认排除 archived) ──
|
||||
async function loadList(status?: string) {
|
||||
state.loading = true
|
||||
state.error = null
|
||||
try {
|
||||
state.items = await knowledgeApi.list(status ?? null)
|
||||
} catch (e: any) {
|
||||
state.error = e?.toString() ?? '加载知识库失败'
|
||||
} finally {
|
||||
state.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── 加载审核收件箱(候选列表) ──
|
||||
async function loadCandidates() {
|
||||
try {
|
||||
state.candidates = await knowledgeApi.listCandidates()
|
||||
} catch (e: any) {
|
||||
state.error = e?.toString() ?? '加载收件箱失败'
|
||||
}
|
||||
}
|
||||
|
||||
async function search(query: string, kind?: string) {
|
||||
state.loading = true
|
||||
state.error = null
|
||||
try {
|
||||
state.items = await knowledgeApi.search({ query, kind })
|
||||
} catch (e: any) {
|
||||
state.error = e?.toString() ?? '检索失败'
|
||||
} finally {
|
||||
state.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function create(input: CreateKnowledgeInput) {
|
||||
const record = await knowledgeApi.create(input)
|
||||
// 手动录入默认 candidate,刷新收件箱
|
||||
await loadCandidates()
|
||||
return record
|
||||
}
|
||||
|
||||
async function updateStatus(id: string, status: string) {
|
||||
await knowledgeApi.updateStatus(id, status)
|
||||
// 从 items 和 candidates 中同步移除
|
||||
state.items = state.items.filter(k => k.id !== id)
|
||||
state.candidates = state.candidates.filter(k => k.id !== id)
|
||||
}
|
||||
|
||||
async function archive(id: string) {
|
||||
await knowledgeApi.archive(id)
|
||||
state.items = state.items.filter(k => k.id !== id)
|
||||
state.candidates = state.candidates.filter(k => k.id !== id)
|
||||
}
|
||||
|
||||
// ── 配置 ──
|
||||
async function loadConfig() {
|
||||
try {
|
||||
state.config = await knowledgeApi.getConfig()
|
||||
} catch (e: any) {
|
||||
state.error = e?.toString() ?? '加载配置失败'
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig(config: KnowledgeConfig) {
|
||||
await knowledgeApi.saveConfig(config)
|
||||
state.config = config
|
||||
}
|
||||
|
||||
async function extractNow() {
|
||||
await knowledgeApi.extractNow()
|
||||
await loadCandidates()
|
||||
}
|
||||
|
||||
// ── 生命线:详情 / 编辑 / 事件查询(按需调用,不入全局 state) ──
|
||||
async function getDetail(id: string): Promise<KnowledgeDetailPayload> {
|
||||
return knowledgeApi.getDetail(id)
|
||||
}
|
||||
|
||||
async function update(id: string, input: UpdateKnowledgeInput): Promise<KnowledgeRecord> {
|
||||
const updated = await knowledgeApi.update(id, input)
|
||||
// 同步刷新内存中的列表/收件箱条目
|
||||
const patch = (arr: KnowledgeRecord[]) => {
|
||||
const idx = arr.findIndex(k => k.id === id)
|
||||
if (idx >= 0) arr[idx] = { ...arr[idx], ...updated }
|
||||
}
|
||||
patch(state.items)
|
||||
patch(state.candidates)
|
||||
return updated
|
||||
}
|
||||
|
||||
async function getEvents(knowledgeId: string, eventType?: string, limit?: number): Promise<KnowledgeEventRecord[]> {
|
||||
return knowledgeApi.events(knowledgeId, eventType, limit)
|
||||
}
|
||||
|
||||
// 候选数量(供侧栏 badge 用)
|
||||
const candidateCount = computed(() => state.candidates.length)
|
||||
|
||||
return reactive({
|
||||
// 状态用 getter 实时读 state(防快照不跟随,同 project store 模式)
|
||||
get items() { return state.items },
|
||||
get candidates() { return state.candidates },
|
||||
get config() { return state.config },
|
||||
get loading() { return state.loading },
|
||||
get error() { return state.error },
|
||||
candidateCount,
|
||||
// actions
|
||||
loadList, loadCandidates, search, create, updateStatus, archive,
|
||||
loadConfig, saveConfig, extractNow,
|
||||
getDetail, update, getEvents,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { ProjectRecord, TaskRecord, IdeaRecord, WorkflowRecord, WorkflowEve
|
||||
|
||||
const state = reactive({
|
||||
projects: [] as ProjectRecord[],
|
||||
deletedProjects: [] as ProjectRecord[],
|
||||
tasks: [] as TaskRecord[],
|
||||
ideas: [] as IdeaRecord[],
|
||||
workflowExecutions: [] as WorkflowRecord[],
|
||||
@@ -24,7 +25,7 @@ const state = reactive({
|
||||
|
||||
let _eventUnlisten: (() => void) | null = null
|
||||
|
||||
export function useProjectStore() {
|
||||
function createStore() {
|
||||
// ── 项目 CRUD ──
|
||||
async function loadProjects() {
|
||||
state.loading = true
|
||||
@@ -38,10 +39,18 @@ export function useProjectStore() {
|
||||
}
|
||||
}
|
||||
|
||||
async function createProject(name: string, description = '', ideaId?: string) {
|
||||
const record = await projectApi.create({ name, description, idea_id: ideaId })
|
||||
state.projects.push(record)
|
||||
return record
|
||||
// 清除错误状态(供 toast 显示后重置,允许连续同值错误再次触发 watch)
|
||||
function clearError() { state.error = null }
|
||||
|
||||
async function createProject(name: string, description = '', ideaId?: string, path?: string, stack?: string) {
|
||||
try {
|
||||
const record = await projectApi.create({ name, description, idea_id: ideaId, path, stack })
|
||||
state.projects.push(record)
|
||||
return record
|
||||
} catch (e: any) {
|
||||
state.error = e?.toString() ?? '创建项目失败'
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function updateProject(id: string, field: string, value: string) {
|
||||
@@ -52,9 +61,48 @@ export function useProjectStore() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 重定位项目目录(后端重探测 stack,返回最新记录并更新本地状态) */
|
||||
async function relocateProjectPath(id: string, newPath: string) {
|
||||
const record = await projectApi.relocatePath(id, newPath)
|
||||
const idx = state.projects.findIndex(p => p.id === id)
|
||||
if (idx >= 0) state.projects[idx] = record
|
||||
return record
|
||||
}
|
||||
|
||||
async function deleteProject(id: string) {
|
||||
await projectApi.delete(id)
|
||||
state.projects = state.projects.filter(p => p.id !== id)
|
||||
try {
|
||||
await projectApi.delete(id) // 软删 → 回收站(可恢复)
|
||||
state.projects = state.projects.filter(p => p.id !== id)
|
||||
} catch (e: any) {
|
||||
state.error = e?.toString() ?? '删除项目失败'
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDeletedProjects() {
|
||||
try {
|
||||
state.deletedProjects = await projectApi.listDeleted()
|
||||
} catch (e: any) {
|
||||
state.error = e?.toString() ?? '加载回收站失败'
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreProject(id: string) {
|
||||
try {
|
||||
await projectApi.restore(id)
|
||||
state.deletedProjects = state.deletedProjects.filter(p => p.id !== id)
|
||||
await loadProjects() // 恢复后刷新活跃列表
|
||||
} catch (e: any) {
|
||||
state.error = e?.toString() ?? '恢复项目失败'
|
||||
}
|
||||
}
|
||||
|
||||
async function purgeProject(id: string) {
|
||||
try {
|
||||
await projectApi.purge(id) // 彻底删(级联物理删,不可恢复)
|
||||
state.deletedProjects = state.deletedProjects.filter(p => p.id !== id)
|
||||
} catch (e: any) {
|
||||
state.error = e?.toString() ?? '彻底删除失败'
|
||||
}
|
||||
}
|
||||
|
||||
// ── 任务 CRUD ──
|
||||
@@ -67,9 +115,14 @@ export function useProjectStore() {
|
||||
}
|
||||
|
||||
async function createTask(input: { project_id: string; title: string; description?: string; priority?: number; branch_name?: string; assignee?: string }) {
|
||||
const record = await taskApi.create(input)
|
||||
state.tasks.push(record)
|
||||
return record
|
||||
try {
|
||||
const record = await taskApi.create(input)
|
||||
state.tasks.push(record)
|
||||
return record
|
||||
} catch (e: any) {
|
||||
state.error = e?.toString() ?? '创建任务失败'
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function updateTask(id: string, field: string, value: string) {
|
||||
@@ -81,8 +134,12 @@ export function useProjectStore() {
|
||||
}
|
||||
|
||||
async function deleteTask(id: string) {
|
||||
await taskApi.delete(id)
|
||||
state.tasks = state.tasks.filter(t => t.id !== id)
|
||||
try {
|
||||
await taskApi.delete(id)
|
||||
state.tasks = state.tasks.filter(t => t.id !== id)
|
||||
} catch (e: any) {
|
||||
state.error = e?.toString() ?? '删除任务失败'
|
||||
}
|
||||
}
|
||||
|
||||
// ── 想法 CRUD ──
|
||||
@@ -95,9 +152,14 @@ export function useProjectStore() {
|
||||
}
|
||||
|
||||
async function createIdea(input: { title: string; description?: string; priority?: number; tags?: string; source?: string }) {
|
||||
const record = await ideaApi.create(input)
|
||||
state.ideas.push(record)
|
||||
return record
|
||||
try {
|
||||
const record = await ideaApi.create(input)
|
||||
state.ideas.push(record)
|
||||
return record
|
||||
} catch (e: any) {
|
||||
state.error = e?.toString() ?? '创建想法失败'
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function updateIdea(id: string, field: string, value: string) {
|
||||
@@ -109,8 +171,25 @@ export function useProjectStore() {
|
||||
}
|
||||
|
||||
async function deleteIdea(id: string) {
|
||||
await ideaApi.delete(id)
|
||||
state.ideas = state.ideas.filter(i => i.id !== id)
|
||||
try {
|
||||
await ideaApi.delete(id)
|
||||
state.ideas = state.ideas.filter(i => i.id !== id)
|
||||
} catch (e: any) {
|
||||
state.error = e?.toString() ?? '删除想法失败'
|
||||
}
|
||||
}
|
||||
|
||||
async function evaluateIdea(id: string) {
|
||||
const record = await ideaApi.evaluate(id)
|
||||
const idx = state.ideas.findIndex(i => i.id === id)
|
||||
if (idx >= 0) state.ideas[idx] = record
|
||||
return record
|
||||
}
|
||||
|
||||
async function promoteIdea(id: string) {
|
||||
const res = await ideaApi.promote(id)
|
||||
await loadIdeas() // 后端已回写 status=promoted/promoted_to,刷新列表
|
||||
return res
|
||||
}
|
||||
|
||||
// ── 工作流 ──
|
||||
@@ -129,11 +208,14 @@ export function useProjectStore() {
|
||||
async function startEventListener() {
|
||||
if (_eventUnlisten) return _eventUnlisten
|
||||
_eventUnlisten = await workflowApi.onEvent((payload) => {
|
||||
state.liveEvents.push(payload)
|
||||
|
||||
// 处理人工审批请求
|
||||
if (payload.event.type === 'HumanApprovalRequest') {
|
||||
state.pendingApproval = payload.event.data as typeof state.pendingApproval
|
||||
try {
|
||||
state.liveEvents.push(payload)
|
||||
// 处理人工审批请求
|
||||
if (payload.event?.type === 'HumanApprovalRequest') {
|
||||
state.pendingApproval = payload.event.data as typeof state.pendingApproval
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('处理工作流事件失败:', e, payload)
|
||||
}
|
||||
})
|
||||
return _eventUnlisten
|
||||
@@ -182,20 +264,24 @@ export function useProjectStore() {
|
||||
}))
|
||||
|
||||
return reactive({
|
||||
// reactive state — 直接引用 state 属性,已经是响应式的
|
||||
projects: state.projects,
|
||||
tasks: state.tasks,
|
||||
ideas: state.ideas,
|
||||
workflowExecutions: state.workflowExecutions,
|
||||
liveEvents: state.liveEvents,
|
||||
loading: state.loading,
|
||||
error: state.error,
|
||||
// 状态用 getter 实时读 state —— 直接 ideas: state.ideas 会快照引用,
|
||||
// loadIdeas 等重新赋值 state.ideas 时返回对象的 ideas 属性不跟随,刷新后视图为空
|
||||
get projects() { return state.projects },
|
||||
get tasks() { return state.tasks },
|
||||
get ideas() { return state.ideas },
|
||||
get workflowExecutions() { return state.workflowExecutions },
|
||||
get liveEvents() { return state.liveEvents },
|
||||
get loading() { return state.loading },
|
||||
get error() { return state.error },
|
||||
clearError,
|
||||
get deletedProjects() { return state.deletedProjects },
|
||||
// project actions
|
||||
loadProjects, createProject, updateProject, deleteProject,
|
||||
loadProjects, createProject, updateProject, deleteProject, relocateProjectPath,
|
||||
loadDeletedProjects, restoreProject, purgeProject,
|
||||
// task actions
|
||||
loadTasks, createTask, updateTask, deleteTask,
|
||||
// idea actions
|
||||
loadIdeas, createIdea, updateIdea, deleteIdea,
|
||||
loadIdeas, createIdea, updateIdea, deleteIdea, evaluateIdea, promoteIdea,
|
||||
// workflow actions
|
||||
runWorkflow, loadWorkflowExecutions, startEventListener, stopEventListener, clearLiveEvents,
|
||||
approveHumanApproval, pendingApproval: computed(() => state.pendingApproval),
|
||||
@@ -203,3 +289,13 @@ export function useProjectStore() {
|
||||
stats,
|
||||
})
|
||||
}
|
||||
|
||||
type ProjectStore = ReturnType<typeof createStore>
|
||||
let _storeInstance: ProjectStore | null = null
|
||||
|
||||
/** 项目/任务/想法/工作流 全局状态(单例,多组件复用同一 reactive 包装) */
|
||||
export function useProjectStore(): ProjectStore {
|
||||
if (_storeInstance) return _storeInstance
|
||||
_storeInstance = createStore()
|
||||
return _storeInstance
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
--df-gap-grid: 12px; /* 卡片/网格 gap */
|
||||
--df-gap-head: 10px; /* 面板标题→内容 */
|
||||
--df-pad-panel: 14px 16px; /* 面板/卡片内边距 */
|
||||
--df-msg-max-width: 90%; /* AI 气泡/工具卡片最大宽度(对齐不撑满) */
|
||||
|
||||
/* — Typography: 仅 400/500 字重 — */
|
||||
--df-font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div class="ideas">
|
||||
<header class="page-header">
|
||||
<h1>💡 灵感</h1>
|
||||
<h1>{{ $t('ideas.title') }}</h1>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-ghost" @click="refresh">🔄 刷新</button>
|
||||
<button class="btn btn-primary" @click="openCaptureModal">✨ 捕捉灵感</button>
|
||||
<button class="btn btn-ghost" @click="refresh">{{ $t('ideas.refresh') }}</button>
|
||||
<button class="btn btn-primary" @click="openCaptureModal">{{ $t('ideas.capture') }}</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -14,8 +14,7 @@
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="搜索灵感..."
|
||||
@input="filterIdeas"
|
||||
:placeholder="$t('ideas.searchPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
@@ -25,7 +24,7 @@
|
||||
:class="{ active: activeFilter === f.key }"
|
||||
@click="activeFilter = f.key"
|
||||
>
|
||||
{{ f.icon }} {{ f.label }}
|
||||
{{ f.icon }} {{ $t(f.labelKey) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -47,7 +46,7 @@
|
||||
</div>
|
||||
<p class="idea-desc-preview">{{ idea.description?.slice(0, 60) ?? '' }}{{ idea.description && idea.description.length > 60 ? '...' : '' }}</p>
|
||||
<div class="idea-card-footer">
|
||||
<span class="status-tag" :class="'status-' + idea.status">{{ statusLabel(idea.status) }}</span>
|
||||
<span class="status-tag" :class="'status-' + idea.status">{{ $t(statusLabelKey(idea.status)) }}</span>
|
||||
<span class="idea-date">{{ formatDate(idea.created_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -58,20 +57,22 @@
|
||||
<section class="idea-detail-panel" v-if="currentIdea">
|
||||
<div class="detail-header">
|
||||
<h2 class="detail-title">{{ currentIdea.title }}</h2>
|
||||
<span class="status-tag" :class="'status-' + currentIdea.status">{{ statusLabel(currentIdea.status) }}</span>
|
||||
<span class="status-tag" :class="'status-' + currentIdea.status">{{ $t(statusLabelKey(currentIdea.status)) }}</span>
|
||||
</div>
|
||||
<p class="detail-desc">{{ currentIdea.description }}</p>
|
||||
|
||||
<!-- 对抗式评估 -->
|
||||
<div class="detail-section">
|
||||
<h3>⚖️ 对抗式评估</h3>
|
||||
<h3>{{ $t('ideas.adversarialTitle') }} <span class="eval-mode-tag">{{ $t('ideas.evalModeHeuristic') }}</span></h3>
|
||||
<div v-if="adversarialEval" class="adversarial-eval">
|
||||
<!-- 正反方观点 -->
|
||||
<div class="debate-container">
|
||||
<div class="debate-column positive">
|
||||
<h4>📈 正方观点</h4>
|
||||
<div class="confidence-bar" :style="{ width: (adversarialEval.positive_strength * 100) + '%' }"></div>
|
||||
<div class="confidence-text">{{ (adversarialEval.positive_strength * 100).toFixed(0) }}% 置信度</div>
|
||||
<h4>{{ $t('ideas.positive') }}</h4>
|
||||
<div class="confidence-bar">
|
||||
<div class="confidence-fill" :style="{ width: (adversarialEval.positive_strength * 100) + '%' }"></div>
|
||||
</div>
|
||||
<div class="confidence-text">{{ $t('ideas.confidence', { n: (adversarialEval.positive_strength * 100).toFixed(0) }) }}</div>
|
||||
<p class="thesis">{{ adversarialEval.positive.thesis }}</p>
|
||||
<ul>
|
||||
<li v-for="evidence in adversarialEval.positive.evidence" :key="evidence">• {{ evidence }}</li>
|
||||
@@ -79,9 +80,11 @@
|
||||
</div>
|
||||
|
||||
<div class="debate-column negative">
|
||||
<h4>📉 反方观点</h4>
|
||||
<div class="confidence-bar" :style="{ width: (adversarialEval.negative_strength * 100) + '%' }"></div>
|
||||
<div class="confidence-text">{{ (adversarialEval.negative_strength * 100).toFixed(0) }}% 置信度</div>
|
||||
<h4>{{ $t('ideas.negative') }}</h4>
|
||||
<div class="confidence-bar">
|
||||
<div class="confidence-fill" :style="{ width: (adversarialEval.negative_strength * 100) + '%' }"></div>
|
||||
</div>
|
||||
<div class="confidence-text">{{ $t('ideas.confidence', { n: (adversarialEval.negative_strength * 100).toFixed(0) }) }}</div>
|
||||
<p class="thesis">{{ adversarialEval.negative.thesis }}</p>
|
||||
<ul>
|
||||
<li v-for="evidence in adversarialEval.negative.evidence" :key="evidence">• {{ evidence }}</li>
|
||||
@@ -91,34 +94,36 @@
|
||||
|
||||
<!-- AI 分析师结论 -->
|
||||
<div class="analyst-conclusion">
|
||||
<h4>🧠 AI 分析师结论</h4>
|
||||
<h4>{{ $t('ideas.analystTitle') }}</h4>
|
||||
<div class="assessment-badge" :class="assessmentClass(adversarialEval.recommendation)">
|
||||
{{ assessmentLabel(adversarialEval.recommendation) }}
|
||||
</div>
|
||||
<p class="final-score">综合评分: {{ adversarialEval.final_score.toFixed(1) }}/10</p>
|
||||
<p class="final-score">{{ $t('ideas.finalScore', { score: adversarialEval.final_score.toFixed(1) }) }}</p>
|
||||
<div class="net-sentiment" :class="sentimentClass(adversarialEval.net_sentiment)">
|
||||
整体倾向: {{ adversarialEval.net_sentiment > 0 ? '积极' : '谨慎' }}
|
||||
({{ (adversarialEval.net_sentiment * 100).toFixed(0) }})
|
||||
{{ $t('ideas.netSentiment', { tone: adversarialEval.net_sentiment > 0 ? $t('ideas.sentimentPositive') : $t('ideas.sentimentNegative'), n: (adversarialEval.net_sentiment * 100).toFixed(0) }) }}
|
||||
</div>
|
||||
<p class="summary">{{ adversarialEval.analyst.summary }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 行动建议 -->
|
||||
<div class="action-recommendations">
|
||||
<h4>💡 行动建议</h4>
|
||||
<h4>{{ $t('ideas.actionTitle') }}</h4>
|
||||
<ul>
|
||||
<li v-for="action in adversarialEval.action_items" :key="action">• {{ action }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="eval-report" style="opacity:0.5">
|
||||
<button class="btn-evaluate" @click="evaluateCurrentIdea">🔍 开始对抗评估</button>
|
||||
<button class="btn-evaluate" :disabled="evaluating" @click="evaluateCurrentIdea">
|
||||
{{ evaluating ? $t('ideas.evaluating') : $t('ideas.startEval') }}
|
||||
</button>
|
||||
<div v-if="evalError" class="eval-error">⚠️ {{ evalError }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 传统评分雷达图 -->
|
||||
<div class="detail-section">
|
||||
<h3>📊 多维评分</h3>
|
||||
<h3>{{ $t('ideas.multiScoreTitle') }}</h3>
|
||||
<div class="radar-chart" v-if="parseScores(currentIdea).length > 0">
|
||||
<div class="radar-row" v-for="dim in parseScores(currentIdea)" :key="dim.name">
|
||||
<span class="radar-label">{{ dim.name }}</span>
|
||||
@@ -128,28 +133,24 @@
|
||||
<span class="radar-value">{{ dim.score }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="eval-report" style="opacity:0.5">暂无评估</div>
|
||||
<div v-else class="eval-report" style="opacity:0.5">{{ $t('ideas.noEval') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 标签 -->
|
||||
<div class="detail-section">
|
||||
<h3>🏷️ 标签</h3>
|
||||
<h3>{{ $t('ideas.tagsTitle') }}</h3>
|
||||
<div class="tag-list" v-if="parseTags(currentIdea).length > 0">
|
||||
<span class="tag" v-for="tag in parseTags(currentIdea)" :key="tag">{{ tag }}</span>
|
||||
</div>
|
||||
<div v-else class="eval-report" style="opacity:0.5">暂无标签</div>
|
||||
<div v-else class="eval-report" style="opacity:0.5">{{ $t('ideas.noTags') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 状态管理 -->
|
||||
<div class="detail-section">
|
||||
<h3>📋 状态管理</h3>
|
||||
<h3>{{ $t('ideas.statusTitle') }}</h3>
|
||||
<div class="status-controls">
|
||||
<select v-model="currentStatus" @change="updateIdeaStatus" class="status-select">
|
||||
<option value="draft">📝 草稿</option>
|
||||
<option value="pending_review">⏳ 待评估</option>
|
||||
<option value="approved">✅ 已批准</option>
|
||||
<option value="promoted">🚀 已立项</option>
|
||||
<option value="rejected">❌ 已拒绝</option>
|
||||
<select :value="currentStatus" @change="onStatusChange" class="status-select">
|
||||
<option v-for="s in statusOptions" :key="s.value" :value="s.value">{{ $t(s.labelKey) }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -162,9 +163,9 @@
|
||||
class="btn btn-primary"
|
||||
@click="promoteToProject"
|
||||
>
|
||||
🚀 立项为项目
|
||||
{{ $t('ideas.promoteToProject') }}
|
||||
</button>
|
||||
<button class="btn btn-ghost" @click="deleteCurrentIdea">🗑️ 删除想法</button>
|
||||
<button class="btn btn-ghost" @click="deleteCurrentIdea">{{ $t('ideas.deleteIdea') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -173,7 +174,7 @@
|
||||
<section class="idea-detail-panel idea-empty" v-else>
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">💡</div>
|
||||
<p>选择一个想法查看详情</p>
|
||||
<p>{{ $t('ideas.emptyState') }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -181,14 +182,14 @@
|
||||
<!-- 捕捉想法模态框 -->
|
||||
<div class="modal-overlay" v-if="showCaptureModal" @click.self="showCaptureModal = false">
|
||||
<div class="modal-box">
|
||||
<h3>✨ 捕捉新想法</h3>
|
||||
<label style="font-size:12px;color:var(--df-text-secondary);margin-bottom:4px;display:block">标题</label>
|
||||
<input v-model="newIdeaTitle" placeholder="一句话描述你的想法..." @keyup.enter="confirmCapture" />
|
||||
<label style="font-size:12px;color:var(--df-text-secondary);margin-bottom:4px;display:block">描述</label>
|
||||
<textarea v-model="newIdeaDesc" placeholder="详细说明(可选)..." rows="3" style="resize:vertical"></textarea>
|
||||
<h3>{{ $t('ideas.captureTitle') }}</h3>
|
||||
<label style="font-size:12px;color:var(--df-text-secondary);margin-bottom:4px;display:block">{{ $t('ideas.fieldTitle') }}</label>
|
||||
<input v-model="newIdeaTitle" :placeholder="$t('ideas.titlePlaceholder')" @keyup.enter="confirmCapture" />
|
||||
<label style="font-size:12px;color:var(--df-text-secondary);margin-bottom:4px;display:block">{{ $t('ideas.fieldDesc') }}</label>
|
||||
<textarea v-model="newIdeaDesc" :placeholder="$t('ideas.descPlaceholder')" rows="3" style="resize:vertical"></textarea>
|
||||
<div class="modal-actions">
|
||||
<button class="btn-cancel" @click="showCaptureModal = false">取消</button>
|
||||
<button class="btn-confirm" @click="confirmCapture">确认</button>
|
||||
<button class="btn-cancel" @click="showCaptureModal = false">{{ $t('common.cancel') }}</button>
|
||||
<button class="btn-confirm" @click="confirmCapture">{{ $t('common.confirm') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -198,10 +199,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useProjectStore } from '../stores/project'
|
||||
import { formatDate } from '../utils/time'
|
||||
import type { IdeaRecord } from '../api/types'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const store = useProjectStore()
|
||||
|
||||
@@ -216,13 +219,27 @@ const showCaptureModal = ref(false)
|
||||
const newIdeaTitle = ref('')
|
||||
const newIdeaDesc = ref('')
|
||||
|
||||
const filters: { key: FilterKey; label: string; icon: string }[] = [
|
||||
{ key: 'all', label: '全部', icon: '📋' },
|
||||
{ key: 'hot', label: '热门', icon: '🔥' },
|
||||
{ key: 'pending', label: '待评估', icon: '⏳' },
|
||||
{ key: 'promoted', label: '已立项', icon: '🚀' },
|
||||
const filters: { key: FilterKey; labelKey: string; icon: string }[] = [
|
||||
{ key: 'all', labelKey: 'ideas.filter.all', icon: '📋' },
|
||||
{ key: 'hot', labelKey: 'ideas.filter.hot', icon: '🔥' },
|
||||
{ key: 'pending', labelKey: 'ideas.filter.pending', icon: '⏳' },
|
||||
{ key: 'promoted', labelKey: 'ideas.filter.promoted', icon: '🚀' },
|
||||
]
|
||||
|
||||
// ── 状态映射(单一数据源,列表/详情/下拉共用) ──
|
||||
const statusOptions: { value: string; labelKey: string }[] = [
|
||||
{ value: 'draft', labelKey: 'ideas.status.draft' },
|
||||
{ value: 'pending_review', labelKey: 'ideas.status.pending_review' },
|
||||
{ value: 'approved', labelKey: 'ideas.status.approved' },
|
||||
{ value: 'promoted', labelKey: 'ideas.status.promoted' },
|
||||
{ value: 'rejected', labelKey: 'ideas.status.rejected' },
|
||||
]
|
||||
|
||||
// 任意 status 字符串 → 对应 i18n key;未知状态回退到原值显示
|
||||
function statusLabelKey(status: string): string {
|
||||
return statusOptions.find(s => s.value === status)?.labelKey ?? status
|
||||
}
|
||||
|
||||
const filteredIdeas = computed(() => {
|
||||
let ideas = store.ideas
|
||||
|
||||
@@ -247,10 +264,6 @@ const filteredIdeas = computed(() => {
|
||||
return ideas
|
||||
})
|
||||
|
||||
function filterIdeas() {
|
||||
// filteredIdeas 是 computed,会自动响应变化
|
||||
}
|
||||
|
||||
const currentIdea = computed(() => {
|
||||
if (!selectedId.value) return null
|
||||
return store.ideas.find(i => i.id === selectedId.value) ?? null
|
||||
@@ -313,19 +326,23 @@ const adversarialEval = computed<AdversarialEval | null>(() => {
|
||||
})
|
||||
|
||||
function assessmentClass(recommendation: string) {
|
||||
return recommendation.toLowerCase().replace(/ /g, '-')
|
||||
// 映射到 CSS 定义的 badge 颜色类(.immediate/.soon/.conditional/.revised/.defer/.cancel)
|
||||
const map: Record<string, string> = {
|
||||
'immediate action': 'immediate',
|
||||
'soon': 'soon',
|
||||
'with resources': 'conditional',
|
||||
'research more': 'revised',
|
||||
'monitor': 'defer',
|
||||
'cancel': 'cancel',
|
||||
}
|
||||
return map[recommendation.toLowerCase()] ?? 'conditional'
|
||||
}
|
||||
|
||||
function assessmentLabel(recommendation: string) {
|
||||
const map: Record<string, string> = {
|
||||
'immediate action': '🚀 立即行动',
|
||||
'soon': '📅 尽快行动',
|
||||
'with resources': '📦 配置资源后行动',
|
||||
'research more': '🔍 需要更多研究',
|
||||
'monitor': '👁️ 持续监控',
|
||||
'cancel': '❌ 取消想法'
|
||||
}
|
||||
return map[recommendation] ?? recommendation
|
||||
const key = `ideas.assessment.${recommendation}`
|
||||
// 未命中 i18n key 时回退到原始 recommendation 字符串
|
||||
const translated = t(key)
|
||||
return translated === key ? recommendation : translated
|
||||
}
|
||||
|
||||
function sentimentClass(sentiment: number) {
|
||||
@@ -339,17 +356,6 @@ function scoreClass(score: number | null) {
|
||||
return 'score-low'
|
||||
}
|
||||
|
||||
function statusLabel(status: string) {
|
||||
const map: Record<string, string> = {
|
||||
draft: '📝 草稿',
|
||||
pending_review: '⏳ 待评估',
|
||||
approved: '✅ 已批准',
|
||||
promoted: '🚀 已立项',
|
||||
rejected: '❌ 已拒绝',
|
||||
}
|
||||
return map[status] ?? status
|
||||
}
|
||||
|
||||
// formatDate 由 ../utils/time 提供(统一毫秒字符串解析,根治 Invalid Date)
|
||||
|
||||
function openCaptureModal() {
|
||||
@@ -369,74 +375,49 @@ async function confirmCapture() {
|
||||
|
||||
async function deleteCurrentIdea() {
|
||||
if (!currentIdea.value) return
|
||||
if (!confirm(t('ideas.confirmDelete', { title: currentIdea.value.title }))) return
|
||||
await store.deleteIdea(currentIdea.value.id)
|
||||
selectedId.value = null
|
||||
}
|
||||
|
||||
async function promoteToProject() {
|
||||
if (!currentIdea.value) return
|
||||
|
||||
// 创建新项目,基于想法(store.createProject 第 3 参 = idea_id)
|
||||
const project = await store.createProject(
|
||||
currentIdea.value.title,
|
||||
currentIdea.value.description,
|
||||
currentIdea.value.id,
|
||||
)
|
||||
const projectId = project.id
|
||||
|
||||
// 更新想法状态和晋升信息(store.updateIdea 单字段,分两次调用)
|
||||
await store.updateIdea(currentIdea.value.id, 'status', 'promoted')
|
||||
await store.updateIdea(currentIdea.value.id, 'promoted_to', projectId)
|
||||
|
||||
// 跳转到项目详情
|
||||
router.push(`/projects/${projectId}`)
|
||||
|
||||
// 刷新想法列表
|
||||
await store.loadIdeas()
|
||||
try {
|
||||
const res = await store.promoteIdea(currentIdea.value.id)
|
||||
router.push(`/projects/${res.project_id}`)
|
||||
} catch (e: any) {
|
||||
const msg = e?.toString() ?? t('ideas.promoteFailed')
|
||||
console.error(t('ideas.promoteFailed'), e)
|
||||
alert(msg)
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
await store.loadIdeas()
|
||||
}
|
||||
|
||||
const evaluating = ref(false)
|
||||
const evalError = ref('')
|
||||
|
||||
async function evaluateCurrentIdea() {
|
||||
if (!currentIdea.value) return
|
||||
if (!currentIdea.value || evaluating.value) return
|
||||
|
||||
// 模拟对抗式评估(实际应该调用后端 API)
|
||||
// 这里使用模拟数据展示界面
|
||||
const mockEval: AdversarialEval = {
|
||||
positive_strength: 0.75,
|
||||
negative_strength: 0.65,
|
||||
net_sentiment: 0.1,
|
||||
recommendation: 'with resources',
|
||||
final_score: 6.5,
|
||||
summary: '该想法整体价值评估中等偏上,建议在有条件的情况下执行。主要价值在于技术创新性较强,需要关注风险控制和资源投入。',
|
||||
action_items: [
|
||||
'确认资源预算',
|
||||
'评估ROI',
|
||||
'制定风险预案'
|
||||
],
|
||||
positive: {
|
||||
thesis: '技术创新性强,潜在回报高',
|
||||
evidence: ['技术栈成熟', '市场需求明确', '团队有相关经验'],
|
||||
},
|
||||
negative: {
|
||||
thesis: '资源投入大,存在执行风险',
|
||||
evidence: ['开发周期长', '需要额外人力', '竞品已有类似方案'],
|
||||
},
|
||||
analyst: {
|
||||
summary: '综合正反方观点,建议在资源到位后启动,并设立阶段性验收点控制风险。',
|
||||
},
|
||||
evaluating.value = true
|
||||
evalError.value = ''
|
||||
try {
|
||||
await store.evaluateIdea(currentIdea.value.id)
|
||||
} catch (e: any) {
|
||||
evalError.value = e?.toString() ?? t('ideas.evalFailed')
|
||||
console.error(t('ideas.evalFailed'), e)
|
||||
} finally {
|
||||
evaluating.value = false
|
||||
}
|
||||
|
||||
// 更新想法的评估结果(实际应该调用 API)
|
||||
await store.updateIdea(currentIdea.value.id, 'ai_analysis', JSON.stringify(mockEval, null, 2))
|
||||
}
|
||||
|
||||
async function updateIdeaStatus() {
|
||||
if (!currentIdea.value || !currentStatus.value) return
|
||||
|
||||
await store.updateIdea(currentIdea.value.id, 'status', currentStatus.value)
|
||||
async function onStatusChange(e: Event) {
|
||||
if (!currentIdea.value) return
|
||||
const newStatus = (e.target as HTMLSelectElement).value
|
||||
await store.updateIdea(currentIdea.value.id, 'status', newStatus)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -629,25 +610,14 @@ onMounted(async () => {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.debate-column.positive .confidence-bar::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
.confidence-fill {
|
||||
height: 100%;
|
||||
background: currentColor;
|
||||
border-radius: var(--df-radius-xs);
|
||||
transition: width 0.4s;
|
||||
}
|
||||
|
||||
.debate-column.negative .confidence-bar::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
background: currentColor;
|
||||
border-radius: var(--df-radius-xs);
|
||||
}
|
||||
.debate-column.positive .confidence-fill { background: var(--df-success); }
|
||||
.debate-column.negative .confidence-fill { background: var(--df-danger); }
|
||||
|
||||
.confidence-text {
|
||||
font-size: 11px;
|
||||
@@ -765,6 +735,23 @@ onMounted(async () => {
|
||||
background: var(--df-accent-hover);
|
||||
}
|
||||
|
||||
.eval-error {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--df-danger);
|
||||
}
|
||||
|
||||
.eval-mode-tag {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
padding: 1px 8px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
background: rgba(255, 217, 61, 0.15);
|
||||
color: var(--df-warning);
|
||||
margin-left: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* ===== 右侧详情 ===== */
|
||||
.idea-detail-panel {
|
||||
background: var(--df-bg-card);
|
||||
|
||||
@@ -2,337 +2,685 @@
|
||||
<div class="knowledge">
|
||||
<!-- 页面头部 -->
|
||||
<header class="page-header">
|
||||
<h1>📚 知识库</h1>
|
||||
<h1>{{ $t('knowledge.title') }}</h1>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-primary" @click="addKnowledge">+ 新增知识</button>
|
||||
<button class="btn btn-primary" @click="openCreateModal">{{ $t('knowledge.add') }}</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 搜索栏 -->
|
||||
<div class="search-bar">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="搜索知识标题、标签、内容..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 分类标签页 -->
|
||||
<div class="category-tabs">
|
||||
<button
|
||||
v-for="cat in categories"
|
||||
:key="cat.key"
|
||||
class="tab-btn"
|
||||
:class="{ 'tab-active': activeCategory === cat.key }"
|
||||
@click="activeCategory = cat.key"
|
||||
>
|
||||
<span class="tab-icon">{{ cat.icon }}</span>
|
||||
<span class="tab-label">{{ cat.label }}</span>
|
||||
<span class="tab-count">{{ getCategoryCount(cat.key) }}</span>
|
||||
<!-- 顶层 Tab: 知识库 / 审核收件箱 -->
|
||||
<div class="top-tabs">
|
||||
<button class="top-tab" :class="{ active: topTab === 'library' }" @click="switchTopTab('library')">
|
||||
{{ $t('knowledge.tab.library') }}
|
||||
</button>
|
||||
<button class="top-tab" :class="{ active: topTab === 'inbox' }" @click="switchTopTab('inbox')">
|
||||
{{ $t('knowledge.tab.inbox') }}
|
||||
<span v-if="store.candidates.length" class="inbox-badge">{{ store.candidates.length }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 知识卡片列表 -->
|
||||
<div class="knowledge-grid">
|
||||
<div class="knowledge-card" v-for="item in filteredItems" :key="item.id">
|
||||
<div class="card-header">
|
||||
<span class="card-title">{{ item.title }}</span>
|
||||
<span class="card-score" :class="scoreClass(item.score)">{{ item.score }}分</span>
|
||||
<!-- 左右分栏: 列表 + 详情 -->
|
||||
<div class="kn-layout">
|
||||
<!-- ===== 左侧:列表 ===== -->
|
||||
<div class="kn-list-panel">
|
||||
<!-- 搜索栏(仅知识库) -->
|
||||
<div v-if="topTab === 'library'" class="search-bar">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
class="search-input"
|
||||
:placeholder="$t('knowledge.searchPlaceholder')"
|
||||
@input="onSearchInput"
|
||||
/>
|
||||
</div>
|
||||
<div class="card-desc">{{ item.description }}</div>
|
||||
<div class="card-tags">
|
||||
<span class="tag" v-for="tag in item.tags" :key="tag">{{ tag }}</span>
|
||||
|
||||
<!-- 分类(仅知识库) -->
|
||||
<div v-if="topTab === 'library'" class="category-tabs">
|
||||
<button
|
||||
v-for="cat in categories"
|
||||
:key="cat.key"
|
||||
class="cat-chip"
|
||||
:class="{ 'cat-active': activeKind === cat.key }"
|
||||
@click="activeKind = cat.key"
|
||||
>
|
||||
<span>{{ cat.icon }}</span>
|
||||
<span>{{ catLabel(cat.key) }}</span>
|
||||
<span class="cat-count">{{ getCategoryCount(cat.key) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<span class="card-reuse">🔄 复用 {{ item.reuseCount }} 次</span>
|
||||
<span class="card-category">{{ getCategoryLabel(item.category) }}</span>
|
||||
<span class="card-time">{{ item.updatedAt }}</span>
|
||||
<button class="btn btn-ghost" @click="deleteKnowledge(item.id)">删除</button>
|
||||
|
||||
<!-- 卡片列表 -->
|
||||
<div class="list-cards">
|
||||
<div
|
||||
v-for="item in listItems"
|
||||
:key="item.id"
|
||||
class="kn-card"
|
||||
:class="{ selected: selectedId === item.id }"
|
||||
@click="selectKnowledge(item.id)"
|
||||
>
|
||||
<div class="kn-card-head">
|
||||
<span class="kn-card-title">{{ item.title }}</span>
|
||||
<span class="kn-card-status" :class="'st-' + item.status">{{ statusLabel(item.status) }}</span>
|
||||
</div>
|
||||
<div class="kn-card-desc">{{ item.content }}</div>
|
||||
<div class="kn-card-meta">
|
||||
<span class="meta-kind">{{ kindLabel(item.kind) }}</span>
|
||||
<span v-if="item.confidence" :class="'conf-' + item.confidence">{{ confidenceLabel(item.confidence) }}</span>
|
||||
<span class="meta-reuse">🔄 {{ item.reuse_count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="listItems.length === 0" class="list-empty">
|
||||
<div>{{ topTab === 'inbox' ? $t('knowledge.emptyInbox') : $t('knowledge.emptyLibrary') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== 右侧:详情 ===== -->
|
||||
<div class="kn-detail-panel">
|
||||
<template v-if="detail">
|
||||
<!-- ① 基本信息 -->
|
||||
<section class="detail-section">
|
||||
<div class="detail-head">
|
||||
<div class="detail-title-row">
|
||||
<span class="detail-kind-icon">{{ kindIcon(detail.knowledge.kind) }}</span>
|
||||
<span v-if="!editing" class="detail-title">{{ detail.knowledge.title }}</span>
|
||||
<input v-else v-model="editForm.title" class="edit-input edit-input-title" />
|
||||
</div>
|
||||
<div class="detail-actions" v-if="!editing">
|
||||
<button class="btn btn-ghost btn-sm" @click="startEdit">{{ $t('knowledge.edit') }}</button>
|
||||
<template v-if="topTab === 'inbox'">
|
||||
<button class="btn btn-primary btn-sm" @click="publishCurrent">{{ $t('knowledge.publish') }}</button>
|
||||
<button class="btn btn-ghost btn-sm" @click="rejectCurrent">{{ $t('knowledge.reject') }}</button>
|
||||
</template>
|
||||
<button v-else class="btn btn-ghost btn-sm" @click="archiveCurrent">{{ $t('knowledge.archive') }}</button>
|
||||
</div>
|
||||
<div class="detail-actions" v-else>
|
||||
<button class="btn btn-ghost btn-sm" @click="cancelEdit">{{ $t('common.cancel') }}</button>
|
||||
<button class="btn btn-primary btn-sm" @click="saveEdit" :disabled="!editForm.title.trim()">{{ $t('common.save') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-badges">
|
||||
<span class="kn-card-status" :class="'st-' + detail.knowledge.status">{{ statusLabel(detail.knowledge.status) }}</span>
|
||||
<span v-if="detail.knowledge.confidence" :class="'conf-' + detail.knowledge.confidence">{{ $t('knowledge.confidenceBadge', { label: confidenceLabel(detail.knowledge.confidence) }) }}</span>
|
||||
<span class="badge-plain">{{ $t('knowledge.reuseCount', { n: detail.knowledge.reuse_count }) }}</span>
|
||||
<span class="badge-plain">{{ kindLabel(detail.knowledge.kind) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-field">
|
||||
<label>{{ $t('knowledge.contentLabel') }}</label>
|
||||
<div v-if="!editing" class="detail-content">{{ detail.knowledge.content }}</div>
|
||||
<textarea v-else v-model="editForm.content" class="edit-input edit-textarea"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="detail-field">
|
||||
<label>{{ $t('knowledge.tagsLabel') }}</label>
|
||||
<div v-if="!editing" class="detail-tags">
|
||||
<span class="tag" v-for="tag in parseTags(detail.knowledge.tags)" :key="tag">{{ tag }}</span>
|
||||
<span v-if="parseTags(detail.knowledge.tags).length === 0" class="muted">{{ $t('knowledge.tagsNone') }}</span>
|
||||
</div>
|
||||
<input v-else v-model="editForm.tagsInput" class="edit-input" :placeholder="$t('knowledge.tagsPlaceholder')" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ② 溯源 -->
|
||||
<section class="detail-section">
|
||||
<div class="section-title">{{ $t('knowledge.traceTitle') }}</div>
|
||||
<div class="trace-row">
|
||||
<span class="trace-label">{{ $t('knowledge.traceMethod') }}</span>
|
||||
<span>{{ originMethod }}</span>
|
||||
</div>
|
||||
<div class="trace-row" v-if="originConvTitle">
|
||||
<span class="trace-label">{{ $t('knowledge.traceSource') }}</span>
|
||||
<span>{{ originConvTitle }}</span>
|
||||
</div>
|
||||
<div class="trace-row" v-if="originTime">
|
||||
<span class="trace-label">{{ $t('knowledge.traceTime') }}</span>
|
||||
<span>{{ originTime }}</span>
|
||||
</div>
|
||||
<div class="detail-field" v-if="reasoningText">
|
||||
<label>{{ $t('knowledge.reasoningLabel') }}</label>
|
||||
<div class="reasoning-box">{{ reasoningText }}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ③ 引用 -->
|
||||
<section class="detail-section">
|
||||
<div class="section-title">{{ $t('knowledge.refTitle', { n: referenceEvents.length }) }}</div>
|
||||
<div v-if="referenceEvents.length === 0" class="muted">{{ $t('knowledge.refEmpty') }}</div>
|
||||
<div v-else class="ref-list">
|
||||
<div v-for="ref in referenceEvents.slice(0, refLimit)" :key="ref.id" class="ref-item">
|
||||
<span class="ref-time">{{ relativeTime(ref.timestamp) }}</span>
|
||||
<span class="ref-conv">{{ refConvTitle(ref) }}</span>
|
||||
<span class="ref-query" v-if="refQuery(ref)">“{{ refQuery(ref) }}”</span>
|
||||
</div>
|
||||
<button v-if="referenceEvents.length > refLimit" class="btn btn-ghost btn-sm load-more" @click="refLimit += 10">
|
||||
{{ $t('knowledge.loadMore', { n: referenceEvents.length - refLimit }) }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ④ 生命周期时间线 -->
|
||||
<section class="detail-section">
|
||||
<div class="section-title">{{ $t('knowledge.lifecycleTitle') }}</div>
|
||||
<div class="timeline">
|
||||
<div v-for="(node, idx) in timelineNodes" :key="node.id" class="tl-node">
|
||||
<div class="tl-dot">{{ node.icon }}</div>
|
||||
<div v-if="idx < timelineNodes.length - 1" class="tl-line"></div>
|
||||
<div class="tl-body">
|
||||
<div class="tl-label">{{ node.label }}</div>
|
||||
<div class="tl-time">{{ relativeTime(node.timestamp) }}</div>
|
||||
<div class="tl-summary" v-if="node.summary">{{ node.summary }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<div v-else class="detail-empty">
|
||||
<div class="detail-empty-icon">👈</div>
|
||||
<div>{{ $t('knowledge.detailEmptyHint') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="filteredItems.length === 0" class="empty-state">
|
||||
<div class="empty-icon">📭</div>
|
||||
<div class="empty-text">没有找到匹配的知识条目</div>
|
||||
<!-- ===== 新增知识对话框 ===== -->
|
||||
<div v-if="showCreateModal" class="modal-overlay" @click.self="closeCreateModal">
|
||||
<div class="modal-box">
|
||||
<div class="modal-title">{{ $t('knowledge.createTitle') }}</div>
|
||||
<div class="form-row">
|
||||
<label>{{ $t('knowledge.kindLabel') }}</label>
|
||||
<select v-model="form.kind" class="form-select">
|
||||
<option v-for="k in KNOWLEDGE_KINDS" :key="k.key" :value="k.key">{{ k.icon }} {{ kindText(k.key) }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>{{ $t('knowledge.titleLabel') }}</label>
|
||||
<input v-model="form.title" type="text" class="form-input" :placeholder="$t('knowledge.titlePlaceholder')" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>{{ $t('knowledge.contentLabel') }}</label>
|
||||
<textarea v-model="form.content" class="form-input form-textarea" :placeholder="$t('knowledge.contentPlaceholder')"></textarea>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>{{ $t('knowledge.tagsFieldLabel') }}</label>
|
||||
<input v-model="form.tagsInput" type="text" class="form-input" :placeholder="$t('knowledge.tagsFieldPlaceholder')" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>{{ $t('knowledge.confidenceLabel') }}</label>
|
||||
<select v-model="form.confidence" class="form-select">
|
||||
<option value="">{{ $t('knowledge.confidenceNone') }}</option>
|
||||
<option value="high">{{ $t('knowledge.confidence.high') }}</option>
|
||||
<option value="medium">{{ $t('knowledge.confidence.medium') }}</option>
|
||||
<option value="low">{{ $t('knowledge.confidence.low') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-ghost" @click="closeCreateModal">{{ $t('common.cancel') }}</button>
|
||||
<button class="btn btn-primary" @click="submitCreate" :disabled="!form.title.trim()">{{ $t('knowledge.create') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useKnowledgeStore, KNOWLEDGE_KINDS, parseTags } from '../stores/knowledge'
|
||||
import type { KnowledgeDetailPayload, KnowledgeEventRecord } from '../api/types'
|
||||
|
||||
interface KnowledgeItem {
|
||||
id: number
|
||||
title: string
|
||||
description: string
|
||||
category: string
|
||||
tags: string[]
|
||||
reuseCount: number
|
||||
score: number
|
||||
updatedAt: string
|
||||
}
|
||||
const { t } = useI18n()
|
||||
const store = useKnowledgeStore()
|
||||
|
||||
// 顶层 Tab: library(知识库) | inbox(审核收件箱)
|
||||
const topTab = ref<'library' | 'inbox'>('library')
|
||||
|
||||
const categories = [
|
||||
{ key: 'all', label: '全部', icon: '📦' },
|
||||
{ key: 'review', label: '审查规则', icon: '🔍' },
|
||||
{ key: 'prompt', label: 'Prompt模板', icon: '💬' },
|
||||
{ key: 'pitfall', label: '踩坑经验', icon: '⚠️' },
|
||||
{ key: 'diagnosis', label: '诊断知识', icon: '🩺' },
|
||||
{ key: 'deploy', label: '部署经验', icon: '🚀' },
|
||||
{ key: 'all', icon: '📦' },
|
||||
...KNOWLEDGE_KINDS.map(k => ({ key: k.key, icon: k.icon })),
|
||||
]
|
||||
|
||||
const activeCategory = ref('all')
|
||||
// 分类 chip 的展示文案:"全部" 走 categoryAll,其余走 knowledge.kind.<key>
|
||||
function catLabel(key: string): string {
|
||||
return key === 'all' ? t('knowledge.categoryAll') : kindText(key)
|
||||
}
|
||||
// 纯类型文案(无 icon),用于下拉/分类
|
||||
function kindText(key: string): string {
|
||||
return t(`knowledge.kind.${key}`)
|
||||
}
|
||||
const activeKind = ref('all')
|
||||
const searchQuery = ref('')
|
||||
const knowledgeItems = ref<KnowledgeItem[]>(loadKnowledgeItems())
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// 从 localStorage 加载知识数据
|
||||
function loadKnowledgeItems(): KnowledgeItem[] {
|
||||
const saved = localStorage.getItem('devflow-knowledge')
|
||||
if (saved) {
|
||||
try {
|
||||
return JSON.parse(saved)
|
||||
} catch {
|
||||
// 解析失败,使用默认数据
|
||||
function onSearchInput() {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(() => {
|
||||
if (searchQuery.value.trim()) {
|
||||
store.search(searchQuery.value)
|
||||
} else {
|
||||
store.loadList()
|
||||
}
|
||||
}
|
||||
return getDefaultData()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// 获取默认数据
|
||||
function getDefaultData(): KnowledgeItem[] {
|
||||
return [
|
||||
// 审查规则
|
||||
{ id: 1, title: 'SQL 注入防护检查', description: '所有 SQL 拼接必须使用参数化查询,禁止字符串拼接用户输入', category: 'review', tags: ['安全', 'SQL', 'Go'], reuseCount: 23, score: 95, updatedAt: '3 天前' },
|
||||
{ id: 2, title: '错误处理规范', description: '禁止吞掉 error,必须向上传播或记录日志', category: 'review', tags: ['Go', '规范', '错误处理'], reuseCount: 18, score: 88, updatedAt: '1 周前' },
|
||||
{ id: 3, title: 'API 响应格式一致性', description: '统一使用 { code, message, data } 结构, HTTP 状态码语义正确', category: 'review', tags: ['API', '规范'], reuseCount: 15, score: 82, updatedAt: '5 天前' },
|
||||
|
||||
// Prompt 模板
|
||||
{ id: 4, title: 'SQL 查询生成 Prompt', description: '根据自然语言生成 SQL,包含表结构上下文和示例输出格式', category: 'prompt', tags: ['SQL', 'LLM', '模板'], reuseCount: 142, score: 91, updatedAt: '昨天' },
|
||||
{ id: 5, title: '代码审查 Prompt', description: 'AI 代码审查专用 Prompt,涵盖安全、性能、可维护性维度', category: 'prompt', tags: ['代码审查', 'LLM'], reuseCount: 87, score: 85, updatedAt: '3 天前' },
|
||||
{ id: 6, title: 'API 文档生成 Prompt', description: '从 Handler 代码自动生成 API 文档的 Prompt 模板', category: 'prompt', tags: ['文档', 'LLM', '自动生成'], reuseCount: 34, score: 78, updatedAt: '1 周前' },
|
||||
|
||||
// 踩坑经验
|
||||
{ id: 7, title: 'Go context 传递陷阱', description: 'goroutine 中必须传递 context 而非创建新的,否则超时控制失效', category: 'pitfall', tags: ['Go', '并发', 'context'], reuseCount: 12, score: 92, updatedAt: '2 天前' },
|
||||
{ id: 8, title: 'Vue 3 作用域坑', description: 'v-for 内 ref 绑定不会自动响应式,需使用数组形式', category: 'pitfall', tags: ['Vue', '前端', '响应式'], reuseCount: 8, score: 75, updatedAt: '1 周前' },
|
||||
{ id: 9, title: 'Docker 网络模式选择', description: 'host 模式在 Mac/Win 上无效,必须用端口映射', category: 'pitfall', tags: ['Docker', '网络'], reuseCount: 6, score: 70, updatedAt: '2 周前' },
|
||||
|
||||
// 诊断知识
|
||||
{ id: 10, title: 'MySQL 慢查询诊断流程', description: 'EXPLAIN → 索引检查 → 慢查询日志分析 → 优化建议', category: 'diagnosis', tags: ['MySQL', '性能', '诊断'], reuseCount: 31, score: 89, updatedAt: '4 天前' },
|
||||
{ id: 11, title: 'Go 内存泄漏排查', description: 'pprof heap 分析 → goroutine 泄漏检查 → GC 调优', category: 'diagnosis', tags: ['Go', '内存', 'pprof'], reuseCount: 9, score: 83, updatedAt: '1 周前' },
|
||||
{ id: 12, title: '前端白屏诊断', description: 'Console 错误 → 网络请求 → 路由配置 → 构建产物检查', category: 'diagnosis', tags: ['前端', '调试', 'Vue'], reuseCount: 14, score: 80, updatedAt: '5 天前' },
|
||||
|
||||
// 部署经验
|
||||
{ id: 13, title: 'Go 二进制热更新', description: 'kill + mv + nohup 启动,无需 systemd 的轻量部署方案', category: 'deploy', tags: ['Go', '部署', 'Linux'], reuseCount: 19, score: 86, updatedAt: '昨天' },
|
||||
{ id: 14, title: 'SCP 上传最佳实践', description: '使用 ssh-proxy upload 代替 scp,支持配置化管理', category: 'deploy', tags: ['SCP', '部署', '工具'], reuseCount: 11, score: 72, updatedAt: '3 天前' },
|
||||
{ id: 15, title: 'Nginx 反向代理配置', description: 'u-ask 的 Nginx 配置模板,含 WebSocket 支持和 gzip', category: 'deploy', tags: ['Nginx', '配置', '反向代理'], reuseCount: 7, score: 77, updatedAt: '1 周前' },
|
||||
]
|
||||
}
|
||||
|
||||
// 保存到 localStorage
|
||||
function saveToLocalStorage() {
|
||||
localStorage.setItem('devflow-knowledge', JSON.stringify(knowledgeItems.value))
|
||||
}
|
||||
|
||||
// 添加知识
|
||||
function addKnowledge() {
|
||||
// TODO: 实现添加知识的对话框逻辑
|
||||
console.log('添加知识')
|
||||
}
|
||||
|
||||
// 删除知识
|
||||
function deleteKnowledge(id: number) {
|
||||
const index = knowledgeItems.value.findIndex(item => item.id === id)
|
||||
if (index !== -1) {
|
||||
knowledgeItems.value.splice(index, 1)
|
||||
saveToLocalStorage()
|
||||
function switchTopTab(tab: 'library' | 'inbox') {
|
||||
topTab.value = tab
|
||||
selectedId.value = null
|
||||
detail.value = null
|
||||
if (tab === 'inbox') {
|
||||
store.loadCandidates()
|
||||
} else {
|
||||
store.loadList()
|
||||
}
|
||||
}
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
let items = knowledgeItems.value
|
||||
if (activeCategory.value !== 'all') {
|
||||
items = items.filter(i => i.category === activeCategory.value)
|
||||
// 左侧列表(随 Tab 切换数据源 + 知识库的分类过滤)
|
||||
const listItems = computed(() => {
|
||||
const src = topTab.value === 'inbox' ? store.candidates : store.items
|
||||
if (topTab.value === 'library' && activeKind.value !== 'all') {
|
||||
return src.filter(i => i.kind === activeKind.value)
|
||||
}
|
||||
if (searchQuery.value.trim()) {
|
||||
const q = searchQuery.value.toLowerCase()
|
||||
items = items.filter(i =>
|
||||
i.title.toLowerCase().includes(q) ||
|
||||
i.tags.some(t => t.toLowerCase().includes(q)) ||
|
||||
i.description.toLowerCase().includes(q)
|
||||
)
|
||||
}
|
||||
return items
|
||||
return src
|
||||
})
|
||||
|
||||
function getCategoryCount(key: string): number {
|
||||
if (key === 'all') return knowledgeItems.value.length
|
||||
return knowledgeItems.value.filter(i => i.category === key).length
|
||||
if (key === 'all') return store.items.length
|
||||
return store.items.filter(i => i.kind === key).length
|
||||
}
|
||||
|
||||
function getCategoryLabel(key: string): string {
|
||||
const cat = categories.find(c => c.key === key)
|
||||
return cat ? cat.label : key
|
||||
// ===== 详情选中 + 加载 =====
|
||||
const selectedId = ref<string | null>(null)
|
||||
const detail = ref<KnowledgeDetailPayload | null>(null)
|
||||
const refLimit = ref(10)
|
||||
|
||||
async function selectKnowledge(id: string) {
|
||||
selectedId.value = id
|
||||
refLimit.value = 10
|
||||
try {
|
||||
detail.value = await store.getDetail(id)
|
||||
} catch (e: any) {
|
||||
console.error('加载详情失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
function scoreClass(score: number): string {
|
||||
if (score >= 90) return 'score-high'
|
||||
if (score >= 75) return 'score-mid'
|
||||
return 'score-low'
|
||||
// 列表数据变化时(如刷新),若选中项已被移除则清空详情
|
||||
watch(listItems, items => {
|
||||
if (selectedId.value && !items.some(i => i.id === selectedId.value)) {
|
||||
selectedId.value = null
|
||||
detail.value = null
|
||||
}
|
||||
})
|
||||
|
||||
// ===== 编辑 =====
|
||||
const editing = ref(false)
|
||||
const editForm = ref({ title: '', content: '', tagsInput: '', confidence: '' })
|
||||
|
||||
function startEdit() {
|
||||
if (!detail.value) return
|
||||
const k = detail.value.knowledge
|
||||
editForm.value = {
|
||||
title: k.title,
|
||||
content: k.content,
|
||||
tagsInput: parseTags(k.tags).join(', '),
|
||||
confidence: k.confidence ?? '',
|
||||
}
|
||||
editing.value = true
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editing.value = false
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!detail.value || !editForm.value.title.trim()) return
|
||||
const tags = editForm.value.tagsInput.split(',').map(t => t.trim()).filter(Boolean)
|
||||
await store.update(detail.value.knowledge.id, {
|
||||
title: editForm.value.title.trim(),
|
||||
content: editForm.value.content,
|
||||
tags: JSON.stringify(tags),
|
||||
confidence: editForm.value.confidence, // 空串=清空(后端映射 None),非 undefined
|
||||
})
|
||||
editing.value = false
|
||||
// 重新拉详情
|
||||
await selectKnowledge(detail.value.knowledge.id)
|
||||
}
|
||||
|
||||
// ===== 审核操作(收件箱) =====
|
||||
async function publishCurrent() {
|
||||
if (!detail.value) return
|
||||
await store.updateStatus(detail.value.knowledge.id, 'published')
|
||||
selectedId.value = null
|
||||
detail.value = null
|
||||
}
|
||||
|
||||
async function rejectCurrent() {
|
||||
if (!detail.value) return
|
||||
await store.archive(detail.value.knowledge.id)
|
||||
selectedId.value = null
|
||||
detail.value = null
|
||||
}
|
||||
|
||||
async function archiveCurrent() {
|
||||
if (!detail.value) return
|
||||
await store.archive(detail.value.knowledge.id)
|
||||
selectedId.value = null
|
||||
detail.value = null
|
||||
}
|
||||
|
||||
// ===== 新增对话框 =====
|
||||
const showCreateModal = ref(false)
|
||||
const form = ref({ kind: 'pitfall', title: '', content: '', tagsInput: '', confidence: '' })
|
||||
|
||||
function openCreateModal() {
|
||||
form.value = { kind: 'pitfall', title: '', content: '', tagsInput: '', confidence: '' }
|
||||
showCreateModal.value = true
|
||||
}
|
||||
function closeCreateModal() {
|
||||
showCreateModal.value = false
|
||||
}
|
||||
async function submitCreate() {
|
||||
const tags = form.value.tagsInput.split(',').map(t => t.trim()).filter(Boolean)
|
||||
await store.create({
|
||||
kind: form.value.kind,
|
||||
title: form.value.title.trim(),
|
||||
content: form.value.content.trim(),
|
||||
tags: JSON.stringify(tags),
|
||||
confidence: form.value.confidence || undefined,
|
||||
})
|
||||
showCreateModal.value = false
|
||||
switchTopTab('inbox')
|
||||
}
|
||||
|
||||
// ===== 事件解析辅助 =====
|
||||
function parseContext(e: KnowledgeEventRecord): Record<string, any> {
|
||||
if (!e.context_json) return {}
|
||||
try { return JSON.parse(e.context_json) } catch { return {} }
|
||||
}
|
||||
|
||||
function refConvTitle(e: KnowledgeEventRecord): string {
|
||||
return parseContext(e).conv_title || parseContext(e).conv_id || ''
|
||||
}
|
||||
function refQuery(e: KnowledgeEventRecord): string {
|
||||
return parseContext(e).query || ''
|
||||
}
|
||||
|
||||
// 溯源:提取事件(created/extracted)
|
||||
const extractedEvent = computed(() => detail.value?.events.find(e => e.event_type === 'extracted'))
|
||||
const createdEvent = computed(() => detail.value?.events.find(e => e.event_type === 'created'))
|
||||
|
||||
const originMethod = computed(() => {
|
||||
if (extractedEvent.value) return t('knowledge.originExtracted')
|
||||
if (createdEvent.value) return t('knowledge.originManual')
|
||||
return t('knowledge.originUnknown')
|
||||
})
|
||||
const originConvTitle = computed(() => {
|
||||
if (!extractedEvent.value) return ''
|
||||
const ctx = parseContext(extractedEvent.value)
|
||||
return ctx.conv_title || ''
|
||||
})
|
||||
const originTime = computed(() => {
|
||||
const e = extractedEvent.value || createdEvent.value
|
||||
return e ? relativeTime(e.timestamp) : ''
|
||||
})
|
||||
const reasoningText = computed(() => {
|
||||
// 优先取主表 reasoning 字段,降级取 extracted 事件 context
|
||||
if (detail.value?.knowledge.reasoning) return detail.value.knowledge.reasoning
|
||||
if (extractedEvent.value) return parseContext(extractedEvent.value).reasoning || ''
|
||||
return ''
|
||||
})
|
||||
|
||||
// 引用事件(倒序)
|
||||
const referenceEvents = computed(() =>
|
||||
(detail.value?.events ?? []).filter(e => e.event_type === 'referenced').reverse()
|
||||
)
|
||||
|
||||
// 生命周期时间线节点(正序 + 未来占位)
|
||||
const timelineNodes = computed(() => {
|
||||
const events = detail.value?.events ?? []
|
||||
const nodes = events.map(e => {
|
||||
const ctx = parseContext(e)
|
||||
let icon = '•', label = e.event_type, summary = ''
|
||||
if (e.event_type === 'extracted') {
|
||||
icon = '🤖'; label = t('knowledge.timeline.extracted')
|
||||
summary = ctx.conv_title ? t('knowledge.timeline.summarySource', { title: ctx.conv_title }) : ''
|
||||
} else if (e.event_type === 'created') {
|
||||
icon = '✍️'; label = t('knowledge.timeline.created')
|
||||
} else if (e.event_type === 'status_changed') {
|
||||
const to = ctx.to
|
||||
if (to === 'published') { icon = '✅'; label = t('knowledge.timeline.published') }
|
||||
else if (to === 'archived') { icon = '🗄️'; label = t('knowledge.timeline.archived') }
|
||||
else if (to === 'pending_review') { icon = '👀'; label = t('knowledge.timeline.enterReview') }
|
||||
else { icon = '🔄'; label = t('knowledge.timeline.statusChanged', { to }) }
|
||||
} else if (e.event_type === 'referenced') {
|
||||
icon = '🔄'; label = t('knowledge.timeline.referenced')
|
||||
summary = ctx.conv_title ? t('knowledge.timeline.summaryConv', { title: ctx.conv_title }) : ''
|
||||
}
|
||||
return { id: e.id, icon, label, summary, timestamp: e.timestamp }
|
||||
})
|
||||
return nodes
|
||||
})
|
||||
|
||||
// ===== 标签/时间辅助 =====
|
||||
function kindLabel(key: string): string {
|
||||
const k = KNOWLEDGE_KINDS.find(c => c.key === key)
|
||||
return k ? `${k.icon} ${kindText(key)}` : key
|
||||
}
|
||||
function kindIcon(key: string): string {
|
||||
return KNOWLEDGE_KINDS.find(c => c.key === key)?.icon ?? '📄'
|
||||
}
|
||||
function statusLabel(status: string): string {
|
||||
const key = `knowledge.status.${status}`
|
||||
const msg = t(key)
|
||||
return msg === key ? status : msg
|
||||
}
|
||||
function confidenceLabel(c: string): string {
|
||||
const key = `knowledge.confidence.${c}`
|
||||
const msg = t(key)
|
||||
return msg === key ? c : msg
|
||||
}
|
||||
// 相对时间:复用 common.* 既有 key(justNow/minutesAgo/hoursAgo/dayAgo),30 天以上回退绝对值
|
||||
function relativeTime(millisStr: string): string {
|
||||
const ms = Number(millisStr)
|
||||
if (!ms) return ''
|
||||
const diff = Date.now() - ms
|
||||
const min = Math.floor(diff / 60000)
|
||||
if (min < 1) return t('common.justNow')
|
||||
if (min < 60) return t('common.minutesAgo', { n: min })
|
||||
const hr = Math.floor(min / 60)
|
||||
if (hr < 24) return t('common.hoursAgo', { n: hr })
|
||||
const day = Math.floor(hr / 24)
|
||||
if (day < 30) return t('common.dayAgo', { n: day })
|
||||
return t('common.ago', { time: new Date(ms).toLocaleDateString() })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
store.loadList()
|
||||
store.loadCandidates()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.knowledge { padding: 16px 20px 20px; }
|
||||
.knowledge { padding: 16px 20px 20px; height: 100%; display: flex; flex-direction: column; }
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
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; }
|
||||
|
||||
/* ===== 顶层 Tab ===== */
|
||||
.top-tabs { display: flex; gap: 4px; margin-bottom: var(--df-gap-page); border-bottom: 0.5px solid var(--df-border); }
|
||||
.top-tab {
|
||||
padding: 8px 16px; border: none; background: transparent; color: var(--df-text-secondary);
|
||||
font-size: 13px; cursor: pointer; border-bottom: 2px solid transparent; transition: all 0.15s;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.top-tab:hover { color: var(--df-text); }
|
||||
.top-tab.active { color: var(--df-accent); border-bottom-color: var(--df-accent); }
|
||||
.inbox-badge {
|
||||
font-size: 11px; background: var(--df-danger); color: #fff;
|
||||
padding: 1px 6px; border-radius: var(--df-radius); min-width: 16px; text-align: center;
|
||||
}
|
||||
|
||||
/* ===== 左右分栏 ===== */
|
||||
.kn-layout {
|
||||
display: grid; grid-template-columns: 360px 1fr; gap: var(--df-gap-page);
|
||||
flex: 1; min-height: 0;
|
||||
}
|
||||
.kn-list-panel, .kn-detail-panel {
|
||||
background: var(--df-bg-panel, var(--df-bg-card));
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-lg);
|
||||
overflow: hidden; display: flex; flex-direction: column;
|
||||
}
|
||||
.kn-detail-panel { overflow-y: auto; padding: 20px; }
|
||||
|
||||
/* 左侧搜索 + 分类 */
|
||||
.search-bar { padding: 12px 12px 0; }
|
||||
.search-input {
|
||||
width: 100%; padding: 8px 12px; background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border); border-radius: var(--df-radius);
|
||||
color: var(--df-text); font-size: 13px; outline: none; box-sizing: border-box;
|
||||
}
|
||||
.search-input:focus { border-color: var(--df-accent); }
|
||||
.category-tabs { display: flex; flex-wrap: wrap; gap: 4px; padding: 10px 12px; }
|
||||
.cat-chip {
|
||||
display: flex; align-items: center; gap: 4px; padding: 4px 8px;
|
||||
border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm);
|
||||
background: transparent; color: var(--df-text-secondary); font-size: 12px; cursor: pointer;
|
||||
}
|
||||
.cat-chip:hover { background: var(--df-bg-card); }
|
||||
.cat-active { background: var(--df-accent); color: #fff; border-color: var(--df-accent); }
|
||||
.cat-count { font-size: 10px; opacity: 0.8; }
|
||||
|
||||
/* 左侧卡片列表 */
|
||||
.list-cards { flex: 1; overflow-y: auto; padding: 8px 12px 12px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.kn-card {
|
||||
background: var(--df-bg-card); border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius); padding: 10px 12px; cursor: pointer; transition: all 0.15s;
|
||||
}
|
||||
.kn-card:hover { border-color: var(--df-accent); }
|
||||
.kn-card.selected { border-color: var(--df-accent); box-shadow: inset 3px 0 0 var(--df-accent); }
|
||||
.kn-card-head { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-bottom: 6px; }
|
||||
.kn-card-title { font-size: 13px; font-weight: 500; color: var(--df-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.kn-card-desc {
|
||||
font-size: 12px; color: var(--df-text-secondary); line-height: 1.4; margin-bottom: 6px;
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||||
}
|
||||
.kn-card-meta { display: flex; gap: 8px; align-items: center; font-size: 11px; color: var(--df-text-dim); flex-wrap: wrap; }
|
||||
.meta-kind { background: rgba(90,99,128,0.2); padding: 1px 6px; border-radius: var(--df-radius-sm); }
|
||||
.meta-reuse { color: var(--df-info); }
|
||||
.list-empty { text-align: center; padding: 40px 0; color: var(--df-text-dim); font-size: 13px; }
|
||||
|
||||
/* ===== 详情面板 ===== */
|
||||
.detail-empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; color: var(--df-text-dim); gap: 12px; }
|
||||
.detail-empty-icon { font-size: 40px; }
|
||||
|
||||
.detail-section { padding-bottom: 18px; margin-bottom: 18px; border-bottom: 0.5px solid var(--df-border); }
|
||||
.detail-section:last-child { border-bottom: none; margin-bottom: 0; }
|
||||
|
||||
.detail-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; margin-bottom: 12px; }
|
||||
.detail-title-row { display: flex; align-items: center; gap: 8px; min-width: 0; flex: 1; }
|
||||
.detail-kind-icon { font-size: 20px; }
|
||||
.detail-title { font-size: 18px; font-weight: 500; color: var(--df-text); word-break: break-word; }
|
||||
.detail-actions { display: flex; gap: 6px; flex-shrink: 0; }
|
||||
|
||||
.detail-badges { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 14px; font-size: 11px; }
|
||||
.badge-plain { color: var(--df-text-dim); }
|
||||
.detail-field { margin-bottom: 12px; }
|
||||
.detail-field label { display: block; font-size: 12px; color: var(--df-text-secondary); margin-bottom: 4px; }
|
||||
.detail-content { font-size: 13px; color: var(--df-text); line-height: 1.6; white-space: pre-wrap; }
|
||||
.detail-tags { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.tag { font-size: 11px; padding: 2px 8px; border-radius: var(--df-radius-xs); background: rgba(108,99,255,0.1); color: var(--df-accent); }
|
||||
.muted { color: var(--df-text-dim); font-size: 12px; }
|
||||
|
||||
/* 状态徽章 / 置信度 */
|
||||
.kn-card-status { font-size: 11px; font-weight: 500; padding: 2px 8px; border-radius: var(--df-radius-xs); white-space: nowrap; }
|
||||
.st-candidate { background: rgba(255,217,61,0.15); color: var(--df-warning); }
|
||||
.st-pending_review { background: rgba(108,159,255,0.15); color: var(--df-info); }
|
||||
.st-published { background: rgba(100,255,218,0.15); color: var(--df-success); }
|
||||
.st-archived { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
|
||||
.conf-high { background: rgba(100,255,218,0.15); color: var(--df-success); padding: 1px 6px; border-radius: var(--df-radius-xs); }
|
||||
.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); }
|
||||
|
||||
/* 溯源 */
|
||||
.section-title { font-size: 13px; font-weight: 500; color: var(--df-text); margin-bottom: 10px; }
|
||||
.trace-row { display: flex; gap: 12px; font-size: 12px; margin-bottom: 6px; }
|
||||
.trace-label { color: var(--df-text-dim); min-width: 72px; }
|
||||
.reasoning-box {
|
||||
font-size: 13px; color: var(--df-text-secondary); line-height: 1.6;
|
||||
background: var(--df-bg-card); border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius); padding: 10px 12px; white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* 引用 */
|
||||
.ref-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.ref-item {
|
||||
display: flex; gap: 10px; align-items: center; font-size: 12px;
|
||||
padding: 6px 10px; background: var(--df-bg-card); border-radius: var(--df-radius-sm);
|
||||
}
|
||||
.ref-time { color: var(--df-text-dim); white-space: nowrap; }
|
||||
.ref-conv { color: var(--df-text); }
|
||||
.ref-query { color: var(--df-text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.load-more { align-self: flex-start; margin-top: 4px; }
|
||||
|
||||
/* 生命周期时间线 */
|
||||
.timeline { display: flex; flex-direction: column; }
|
||||
.tl-node { display: flex; gap: 12px; position: relative; padding-bottom: 4px; }
|
||||
.tl-dot {
|
||||
width: 28px; height: 28px; border-radius: 50%; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 13px;
|
||||
background: var(--df-bg-card); border: 0.5px solid var(--df-border); z-index: 1;
|
||||
}
|
||||
.tl-line { position: absolute; left: 14px; top: 28px; bottom: -4px; width: 0.5px; background: var(--df-border); }
|
||||
.tl-body { padding-top: 4px; }
|
||||
.tl-label { font-size: 13px; color: var(--df-text); }
|
||||
.tl-time { font-size: 11px; color: var(--df-text-dim); }
|
||||
.tl-summary { font-size: 11px; color: var(--df-text-secondary); margin-top: 2px; }
|
||||
|
||||
/* ===== 编辑输入 ===== */
|
||||
.edit-input {
|
||||
width: 100%; padding: 8px 10px; background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm);
|
||||
color: var(--df-text); font-size: 13px; outline: none; box-sizing: border-box;
|
||||
}
|
||||
.edit-input:focus { border-color: var(--df-accent); }
|
||||
.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;
|
||||
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-accent { background: rgba(100,255,218,0.15); color: var(--df-success); border: 0.5px solid rgba(100,255,218,0.3); }
|
||||
.btn-accent:hover { background: rgba(100,255,218,0.25); }
|
||||
.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); }
|
||||
|
||||
/* ===== 搜索栏 ===== */
|
||||
.search-bar { margin-bottom: var(--df-gap-page); }
|
||||
.search-input {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
padding: 10px 16px;
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius);
|
||||
color: var(--df-text);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
/* ===== 新增对话框 ===== */
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.5);
|
||||
display: flex; align-items: center; justify-content: center; z-index: 1000;
|
||||
}
|
||||
.search-input::placeholder { color: var(--df-text-dim); }
|
||||
.search-input:focus { border-color: var(--df-accent); }
|
||||
|
||||
/* ===== 分类标签 ===== */
|
||||
.category-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: var(--df-gap-page);
|
||||
flex-wrap: wrap;
|
||||
.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;
|
||||
}
|
||||
.tab-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius);
|
||||
background: transparent;
|
||||
color: var(--df-text-secondary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
.modal-title { font-size: 16px; font-weight: 500; color: var(--df-text); margin-bottom: 16px; }
|
||||
.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 {
|
||||
width: 100%; padding: 8px 10px; background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm);
|
||||
color: var(--df-text); font-size: 13px; outline: none; box-sizing: border-box;
|
||||
}
|
||||
.tab-btn:hover { background: var(--df-bg-card); color: var(--df-text); }
|
||||
.tab-active {
|
||||
background: var(--df-accent) !important;
|
||||
color: #fff !important;
|
||||
border-color: var(--df-accent) !important;
|
||||
}
|
||||
.tab-icon { font-size: 14px; }
|
||||
.tab-count {
|
||||
font-size: 11px;
|
||||
background: rgba(255,255,255,0.15);
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--df-radius);
|
||||
min-width: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
.tab-active .tab-count { background: rgba(255,255,255,0.25); }
|
||||
|
||||
/* ===== 知识卡片网格 ===== */
|
||||
.knowledge-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||
gap: var(--df-gap-grid);
|
||||
}
|
||||
.knowledge-card {
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-lg);
|
||||
padding: var(--df-pad-panel);
|
||||
transition: border-color 0.15s, transform 0.15s;
|
||||
}
|
||||
.knowledge-card:hover {
|
||||
border-color: var(--df-accent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.card-title { font-size: 14px; font-weight: 500; color: var(--df-text); }
|
||||
.card-score {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
}
|
||||
.score-high { background: rgba(100,255,218,0.15); color: var(--df-success); }
|
||||
.score-mid { background: rgba(255,217,61,0.15); color: var(--df-warning); }
|
||||
.score-low { background: rgba(255,107,107,0.15); color: var(--df-danger); }
|
||||
|
||||
.card-desc {
|
||||
font-size: 13px;
|
||||
color: var(--df-text-secondary);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.card-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
|
||||
.tag {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
background: rgba(108,99,255,0.1);
|
||||
color: var(--df-accent);
|
||||
}
|
||||
.card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 11px;
|
||||
color: var(--df-text-dim);
|
||||
}
|
||||
.card-reuse { color: var(--df-info); }
|
||||
.card-category {
|
||||
background: rgba(90,99,128,0.2);
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--df-radius-sm);
|
||||
}
|
||||
|
||||
/* ===== 空状态 ===== */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 0;
|
||||
}
|
||||
.empty-icon { font-size: 48px; margin-bottom: 12px; }
|
||||
.empty-text { font-size: 14px; color: var(--df-text-dim); }
|
||||
</style>
|
||||
.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; }
|
||||
</style>
|
||||
|
||||
@@ -3,35 +3,36 @@
|
||||
<!-- 页面头部 -->
|
||||
<header class="page-header">
|
||||
<div class="header-left">
|
||||
<router-link to="/projects" class="back-link">← 项目列表</router-link>
|
||||
<router-link to="/projects" class="back-link">{{ $t('projectDetail.backToList') }}</router-link>
|
||||
<h1>{{ currentProject?.name ?? '...' }}</h1>
|
||||
<span class="stage-badge" :class="'stage-' + stageKey">{{ statusLabel }}</span>
|
||||
<span class="stage-badge" :class="'stage-' + stageKey">{{ $t(statusLabel) }}</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-ghost" @click="handleSync">🔄 同步</button>
|
||||
<button class="btn btn-primary" @click="showNewTaskModal = true">+ 新任务</button>
|
||||
<button class="btn btn-ghost" @click="handleSync">{{ $t('projectDetail.sync') }}</button>
|
||||
<button class="btn btn-danger" @click="handleDelete">{{ $t('projectDetail.delete') }}</button>
|
||||
<button class="btn btn-primary" @click="showNewTaskModal = true">{{ $t('projectDetail.newTask') }}</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 新建任务模态框 -->
|
||||
<div v-if="showNewTaskModal" class="modal-overlay" @click.self="showNewTaskModal = false">
|
||||
<div class="modal-box">
|
||||
<h3 class="modal-title">新建任务</h3>
|
||||
<h3 class="modal-title">{{ $t('projectDetail.newTaskTitle') }}</h3>
|
||||
<div class="modal-field">
|
||||
<label>任务标题</label>
|
||||
<input v-model="newTaskTitle" placeholder="输入任务标题" @keyup.enter="submitNewTask" />
|
||||
<label>{{ $t('projectDetail.taskTitleLabel') }}</label>
|
||||
<input v-model="newTaskTitle" :placeholder="$t('projectDetail.taskTitlePlaceholder')" @keyup.enter="submitNewTask" />
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<label>描述</label>
|
||||
<textarea v-model="newTaskDesc" placeholder="简要描述" rows="3"></textarea>
|
||||
<label>{{ $t('projectDetail.descLabel') }}</label>
|
||||
<textarea v-model="newTaskDesc" :placeholder="$t('projectDetail.descPlaceholder')" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<label>分支名称</label>
|
||||
<label>{{ $t('projectDetail.branchLabel') }}</label>
|
||||
<input v-model="newTaskBranch" placeholder="feature/xxx" />
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-ghost" @click="showNewTaskModal = false">取消</button>
|
||||
<button class="btn btn-primary" @click="submitNewTask" :disabled="!newTaskTitle.trim()">确认创建</button>
|
||||
<button class="btn btn-ghost" @click="showNewTaskModal = false">{{ $t('common.cancel') }}</button>
|
||||
<button class="btn btn-primary" @click="submitNewTask" :disabled="!newTaskTitle.trim()">{{ $t('projectDetail.confirmCreate') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -51,7 +52,7 @@
|
||||
<span v-if="idx < currentStageIndex">✓</span>
|
||||
<span v-else>{{ idx + 1 }}</span>
|
||||
</div>
|
||||
<div class="stage-label">{{ stage.label }}</div>
|
||||
<div class="stage-label">{{ $t('projectDetail.' + stage.labelKey) }}</div>
|
||||
<div v-if="idx < stages.length - 1" class="stage-connector" :class="{ 'connector-done': idx < currentStageIndex }"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -61,27 +62,60 @@
|
||||
<!-- 左栏:项目信息 -->
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>📋 项目信息</h2>
|
||||
<h2>{{ $t('projectDetail.infoTitle') }}</h2>
|
||||
</div>
|
||||
<div class="project-info" v-if="currentProject">
|
||||
<div class="info-item">
|
||||
<span class="label">来源想法</span>
|
||||
<span class="label">{{ $t('projectDetail.sourceIdea') }}</span>
|
||||
<router-link v-if="currentProject.idea_id" :to="`/ideas/${currentProject.idea_id}`" class="idea-link">
|
||||
查看想法详情
|
||||
{{ $t('projectDetail.viewIdea') }}
|
||||
</router-link>
|
||||
<span v-else class="no-idea">原始想法已删除</span>
|
||||
<span v-else class="no-idea">{{ $t('projectDetail.ideaDeleted') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 代码目录(绑定 + 重定位) -->
|
||||
<div class="info-item">
|
||||
<span class="label">{{ $t('projectDetail.codeDirLabel') }}</span>
|
||||
<div v-if="currentProject.path" class="path-row">
|
||||
<span class="path-text" :title="currentProject.path">{{ currentProject.path }}</span>
|
||||
<button class="btn btn-ghost btn-sm" type="button" @click="relocateDir">{{ $t('projectDetail.relocateDir') }}</button>
|
||||
</div>
|
||||
<div v-else class="path-row">
|
||||
<span class="no-idea">{{ $t('projectDetail.noDirBound') }}</span>
|
||||
<button class="btn btn-ghost btn-sm" type="button" @click="relocateDir">{{ $t('projectDetail.bindDir') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 目录状态(仅绑定时显示) -->
|
||||
<div class="info-item" v-if="currentProject.path">
|
||||
<span class="label">{{ $t('projectDetail.dirStatusLabel') }}</span>
|
||||
<span :class="pathExists === false ? 'path-missing' : 'path-ok'">
|
||||
{{ pathExists === null ? '检测中…' : pathExists ? '✓ 目录存在' : '⚠ 目录已移走,建议重定位' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 技术栈 -->
|
||||
<div class="info-item" v-if="parseStack(currentProject.stack).length">
|
||||
<span class="label">{{ $t('projectDetail.techStackLabel') }}</span>
|
||||
<div class="info-tags">
|
||||
<span class="tech-tag" v-for="t in parseStack(currentProject.stack)" :key="t">{{ t }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">创建时间</span>
|
||||
<span class="label">{{ $t('projectDetail.createdAt') }}</span>
|
||||
<span>{{ formatDate(currentProject.created_at) }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">更新时间</span>
|
||||
<span class="label">{{ $t('projectDetail.updatedAt') }}</span>
|
||||
<span>{{ formatDate(currentProject.updated_at) }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">项目状态</span>
|
||||
<span class="status-badge" :class="currentProject.status">{{ currentProject.status }}</span>
|
||||
<span class="label">{{ $t('projectDetail.description') }}</span>
|
||||
<span>{{ currentProject.description || '—' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">{{ $t('projectDetail.projectStatus') }}</span>
|
||||
<span class="status-badge" :class="currentProject.status">{{ $t(statusLabel) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -89,14 +123,14 @@
|
||||
<!-- 左栏:任务列表 -->
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>🔀 任务列表</h2>
|
||||
<span class="task-count">{{ projectTasks.length }} 个任务</span>
|
||||
<h2>{{ $t('projectDetail.taskListTitle') }}</h2>
|
||||
<span class="task-count">{{ $t('projectDetail.taskCount', { n: projectTasks.length }) }}</span>
|
||||
</div>
|
||||
<div class="task-list">
|
||||
<div class="task-card" v-for="task in projectTasks" :key="task.id">
|
||||
<div class="task-top">
|
||||
<span class="task-title">{{ task.title }}</span>
|
||||
<span class="task-status" :class="taskStatusClass(task.status)">{{ taskStatusLabel(task.status) }}</span>
|
||||
<span class="task-status" :class="taskStatusClass(task.status)">{{ $t(taskStatusLabel(task.status)) }}</span>
|
||||
</div>
|
||||
<div class="task-branch" v-if="task.branch_name">
|
||||
<span class="branch-icon">⑂</span>
|
||||
@@ -109,7 +143,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="projectTasks.length === 0" class="empty-hint">暂无任务</div>
|
||||
<div v-if="projectTasks.length === 0" class="empty-hint">{{ $t('projectDetail.emptyTasks') }}</div>
|
||||
</section>
|
||||
|
||||
<!-- 右栏 -->
|
||||
@@ -117,8 +151,8 @@
|
||||
<!-- 工作流执行日志 -->
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>📋 工作流日志</h2>
|
||||
<button class="btn btn-ghost btn-sm" @click="runDemoWorkflow" :disabled="workflowRunning">▶ 运行测试工作流</button>
|
||||
<h2>{{ $t('projectDetail.workflowLogTitle') }}</h2>
|
||||
<button class="btn btn-ghost btn-sm" @click="runDemoWorkflow" :disabled="workflowRunning">{{ $t('projectDetail.runDemoWorkflow') }}</button>
|
||||
</div>
|
||||
<div class="log-list" ref="logListRef">
|
||||
<div class="log-item" v-for="(evt, idx) in formattedEvents" :key="idx" :class="'log-' + evt.level">
|
||||
@@ -126,36 +160,7 @@
|
||||
<span class="log-level">{{ evt.level }}</span>
|
||||
<span class="log-msg">{{ evt.message }}</span>
|
||||
</div>
|
||||
<div v-if="formattedEvents.length === 0" class="empty-hint">点击"运行测试工作流"查看实时日志</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 项目上下文 -->
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>🏷️ 项目信息</h2>
|
||||
</div>
|
||||
<div class="info-grid">
|
||||
<div class="info-row">
|
||||
<span class="info-label">状态</span>
|
||||
<span class="info-value">{{ statusLabel }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">描述</span>
|
||||
<span class="info-value">{{ currentProject?.description ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">来源想法</span>
|
||||
<span class="info-value">{{ currentProject?.idea_id ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">创建时间</span>
|
||||
<span class="info-value">{{ formatDate(currentProject?.created_at ?? '') }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">更新时间</span>
|
||||
<span class="info-value">{{ formatDate(currentProject?.updated_at ?? '') }}</span>
|
||||
</div>
|
||||
<div v-if="formattedEvents.length === 0" class="empty-hint">{{ $t('projectDetail.emptyWorkflowLog') }}</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -164,18 +169,18 @@
|
||||
<!-- 人工审批对话框 -->
|
||||
<div v-if="showApprovalDialog" class="modal-overlay">
|
||||
<div class="modal-box" style="width: 500px">
|
||||
<h3 class="modal-title">需要人工审批</h3>
|
||||
<h3 class="modal-title">{{ $t('projectDetail.approvalTitle') }}</h3>
|
||||
<div v-if="store.pendingApproval">
|
||||
<h3>{{ store.pendingApproval.title }}</h3>
|
||||
<p style="margin: 16px 0; color: var(--df-text-secondary);">{{ store.pendingApproval.description }}</p>
|
||||
<div style="margin-bottom: 20px;">
|
||||
<p style="margin-bottom: 8px; font-size: 14px;">请选择操作:</p>
|
||||
<p style="margin-bottom: 8px; font-size: 14px;">{{ $t('projectDetail.approvalHint') }}</p>
|
||||
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
|
||||
<button
|
||||
v-for="option in store.pendingApproval.options"
|
||||
:key="option"
|
||||
v-for="(option, idx) in store.pendingApproval.options"
|
||||
:key="idx"
|
||||
class="btn"
|
||||
:class="option === '同意' ? 'btn-primary' : 'btn-ghost'"
|
||||
:class="idx === 0 ? 'btn-primary' : 'btn-ghost'"
|
||||
@click="handleApproval(option)"
|
||||
>
|
||||
{{ option }}
|
||||
@@ -184,7 +189,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-ghost" @click="showApprovalDialog = false">稍后处理</button>
|
||||
<button class="btn btn-ghost" @click="showApprovalDialog = false">{{ $t('projectDetail.approvalLater') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -193,76 +198,48 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
import { useProjectStore } from '../stores/project'
|
||||
import { projectApi } from '../api'
|
||||
import { formatDate } from '../utils/time'
|
||||
import { projectStatusLabel, projectStageInfo, taskStatusLabel, taskStatusClass } from '../constants/project'
|
||||
import type { WorkflowEventPayload } from '../api/types'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useProjectStore()
|
||||
const { t, locale } = useI18n()
|
||||
const logListRef = ref<HTMLElement | null>(null)
|
||||
const showApprovalDialog = ref(false)
|
||||
let unlisten: (() => void) | null = null
|
||||
|
||||
// ── 阶段定义 ──
|
||||
// ── 阶段定义(labelKey 对应 i18n projectDetail.stage*) ──
|
||||
const stages = [
|
||||
{ key: 'idea', label: '💡 想法' },
|
||||
{ key: 'requirement', label: '📋 需求' },
|
||||
{ key: 'coding', label: '💻 编码' },
|
||||
{ key: 'testing', label: '🧪 测试' },
|
||||
{ key: 'release', label: '🚀 发布' },
|
||||
{ key: 'idea', labelKey: 'stageIdea' },
|
||||
{ key: 'requirement', labelKey: 'stageRequirement' },
|
||||
{ key: 'coding', labelKey: 'stageCoding' },
|
||||
{ key: 'testing', labelKey: 'stageTesting' },
|
||||
{ key: 'release', labelKey: 'stageRelease' },
|
||||
]
|
||||
|
||||
// ── 状态映射 ──
|
||||
const stageMap: Record<string, number> = {
|
||||
planning: 0, in_progress: 2, testing: 3, completed: 4, paused: 2, cancelled: 0,
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
planning: '📐 规划中',
|
||||
in_progress: '💻 进行中',
|
||||
paused: '⏸ 已暂停',
|
||||
completed: '✅ 已完成',
|
||||
cancelled: '❌ 已取消',
|
||||
}
|
||||
|
||||
const taskStatusLabels: Record<string, string> = {
|
||||
todo: '📋 待开始',
|
||||
in_progress: '🔄 进行中',
|
||||
review_ready: '👀 待审查',
|
||||
merged: '✅ 已合并',
|
||||
abandoned: '❌ 已放弃',
|
||||
}
|
||||
|
||||
const taskStatusClasses: Record<string, string> = {
|
||||
todo: 'status-todo',
|
||||
in_progress: 'status-progress',
|
||||
review_ready: 'status-review',
|
||||
merged: 'status-done',
|
||||
abandoned: 'status-todo',
|
||||
}
|
||||
|
||||
// ── 当前项目 ──
|
||||
// 状态文案/阶段进度统一走 ../constants/project(与 Projects/Tasks/Dashboard 一致,
|
||||
// 根治此前 stageMap 含不存在的 testing 状态、三套映射互相矛盾)
|
||||
const projectId = computed(() => route.params.id as string)
|
||||
const currentProject = computed(() =>
|
||||
store.projects.find(p => p.id === projectId.value)
|
||||
)
|
||||
const stageKey = computed(() => currentProject.value?.status ?? 'planning')
|
||||
const statusLabel = computed(() => statusLabels[stageKey.value] ?? stageKey.value)
|
||||
const currentStageIndex = computed(() => stageMap[stageKey.value] ?? 0)
|
||||
const statusLabel = computed(() => projectStatusLabel(stageKey.value))
|
||||
const currentStageIndex = computed(() => projectStageInfo(stageKey.value).stepIndex)
|
||||
|
||||
// ── 任务 ──
|
||||
const projectTasks = computed(() =>
|
||||
store.tasks.filter(t => t.project_id === projectId.value)
|
||||
)
|
||||
|
||||
function taskStatusLabel(status: string) {
|
||||
return taskStatusLabels[status] ?? status
|
||||
}
|
||||
|
||||
function taskStatusClass(status: string) {
|
||||
return taskStatusClasses[status] ?? 'status-todo'
|
||||
}
|
||||
// taskStatusLabel / taskStatusClass 由 ../constants/project 提供
|
||||
|
||||
// ── 工作流 ──
|
||||
const workflowRunning = ref(false)
|
||||
@@ -302,7 +279,7 @@ const formattedEvents = computed<LogEntry[]>(() => {
|
||||
const type = ev.type ?? 'unknown'
|
||||
const level = type.includes('error') || type.includes('fail') ? 'error'
|
||||
: type.includes('warn') ? 'warn' : 'info'
|
||||
const time = new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
const time = new Date().toLocaleTimeString(locale.value, { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
const message = ev.label
|
||||
? `[${ev.label}] ${type}: ${ev.output ?? ev.code ?? ev.message ?? JSON.stringify(ev)}`
|
||||
: `${type}: ${ev.output ?? ev.code ?? ev.message ?? JSON.stringify(ev)}`
|
||||
@@ -318,12 +295,13 @@ const newTaskBranch = ref('')
|
||||
|
||||
async function submitNewTask() {
|
||||
if (!newTaskTitle.value.trim()) return
|
||||
await store.createTask({
|
||||
const r = await store.createTask({
|
||||
project_id: projectId.value,
|
||||
title: newTaskTitle.value.trim(),
|
||||
description: newTaskDesc.value.trim(),
|
||||
branch_name: newTaskBranch.value.trim() || undefined,
|
||||
})
|
||||
if (!r) return // 失败已 toast,保持弹窗不关
|
||||
showNewTaskModal.value = false
|
||||
newTaskTitle.value = ''
|
||||
newTaskDesc.value = ''
|
||||
@@ -332,7 +310,58 @@ async function submitNewTask() {
|
||||
|
||||
// ── 同步 ──
|
||||
async function handleSync() {
|
||||
await Promise.all([store.loadProjects(), store.loadTasks(projectId.value)])
|
||||
await Promise.all([store.loadProjects(), store.loadTasks()])
|
||||
await checkPath()
|
||||
}
|
||||
|
||||
// ── 代码目录绑定 ──
|
||||
const pathExists = ref<boolean | null>(null)
|
||||
|
||||
// 解析技术栈 JSON 数组字符串(防崩)
|
||||
function parseStack(stack: string | null): string[] {
|
||||
if (!stack) return []
|
||||
try {
|
||||
const arr = JSON.parse(stack)
|
||||
return Array.isArray(arr) ? arr : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// 检查绑定目录是否存在(详情页「目录是否还在」)
|
||||
async function checkPath() {
|
||||
const p = currentProject.value
|
||||
if (!p?.path) { pathExists.value = null; return }
|
||||
try {
|
||||
pathExists.value = await projectApi.checkPathExists(p.path)
|
||||
} catch {
|
||||
pathExists.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 重定位/绑定目录(选目录 → 查重 → 确认 → 后端重探测 stack)
|
||||
async function relocateDir() {
|
||||
const p = currentProject.value
|
||||
if (!p) return
|
||||
try {
|
||||
const selected = await open({ directory: true, multiple: false })
|
||||
if (!selected || Array.isArray(selected)) return
|
||||
const dir = selected as string
|
||||
// 查重(排除自身)
|
||||
try {
|
||||
const conflict = await projectApi.checkBinding(dir, p.id)
|
||||
if (conflict) {
|
||||
alert(`该目录已被项目「${conflict.name}」绑定`)
|
||||
return
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
if (!confirm(`将项目目录${currentProject.value?.path ? '重定位' : '绑定'}到:\n${dir}\n\n技术栈将重新探测。确认?`)) return
|
||||
await store.relocateProjectPath(p.id, dir)
|
||||
await checkPath()
|
||||
} catch (e: any) {
|
||||
console.error('重定位失败:', e)
|
||||
alert('重定位失败: ' + (e?.toString() ?? '未知错误'))
|
||||
}
|
||||
}
|
||||
|
||||
// ── 审批处理 ──
|
||||
@@ -341,6 +370,20 @@ async function handleApproval(decision: string) {
|
||||
showApprovalDialog.value = false
|
||||
}
|
||||
|
||||
// ── 删除项目(软删 → 回收站,可恢复)──
|
||||
async function handleDelete() {
|
||||
const p = currentProject.value
|
||||
if (!p) return
|
||||
if (!confirm(t('projectDetail.confirmDelete', { name: p.name }))) return
|
||||
await store.deleteProject(p.id)
|
||||
router.push('/projects')
|
||||
}
|
||||
|
||||
// 日志自动滚到底部(让 logListRef 不再是死 ref)
|
||||
watch(formattedEvents, () => {
|
||||
if (logListRef.value) logListRef.value.scrollTop = logListRef.value.scrollHeight
|
||||
})
|
||||
|
||||
// 监听审批请求
|
||||
watch(() => store.pendingApproval, (newApproval) => {
|
||||
if (newApproval) {
|
||||
@@ -354,10 +397,14 @@ watch(() => store.pendingApproval, (newApproval) => {
|
||||
// ── 生命周期 ──
|
||||
onMounted(async () => {
|
||||
await store.loadProjects()
|
||||
await store.loadTasks(projectId.value)
|
||||
unlisten = (await store.startEventListener()) as any
|
||||
await store.loadTasks() // 全量加载,详情页用 computed 过滤本项目(避免覆盖全局 tasks 单例)
|
||||
unlisten = await store.startEventListener()
|
||||
await checkPath()
|
||||
})
|
||||
|
||||
// 目录变更(重定位后)重新检测存在性
|
||||
watch(() => currentProject.value?.path, () => { checkPath() })
|
||||
|
||||
onUnmounted(() => {
|
||||
if (unlisten) {
|
||||
unlisten()
|
||||
@@ -413,6 +460,21 @@ onUnmounted(() => {
|
||||
background: rgba(108,99,255,0.1);
|
||||
color: var(--df-accent);
|
||||
}
|
||||
|
||||
/* ===== 代码目录 / 技术栈 ===== */
|
||||
.path-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.path-text {
|
||||
font-size: 12px; font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
color: var(--df-text-secondary); word-break: break-all;
|
||||
}
|
||||
.path-ok { color: var(--df-success); font-size: 12px; }
|
||||
.path-missing { color: var(--df-danger); font-size: 12px; }
|
||||
.info-tags { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.info-tags .tech-tag {
|
||||
font-size: 11px; padding: 2px 8px; border-radius: var(--df-radius-xs);
|
||||
background: rgba(108, 99, 255, 0.08); color: var(--df-text-secondary);
|
||||
border: 0.5px solid var(--df-border);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
@@ -457,6 +519,8 @@ onUnmounted(() => {
|
||||
.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); }
|
||||
|
||||
/* ===== 阶段进度条 ===== */
|
||||
.stage-pipeline {
|
||||
@@ -609,17 +673,7 @@ onUnmounted(() => {
|
||||
.log-error .log-level { color: var(--df-danger); }
|
||||
.log-msg { color: var(--df-text-secondary); flex: 1; }
|
||||
|
||||
/* ===== 项目信息 ===== */
|
||||
.info-grid { display: flex; flex-direction: column; gap: 0; }
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 10px 0;
|
||||
border-bottom: 0.5px solid var(--df-border);
|
||||
}
|
||||
.info-row:last-child { border-bottom: none; }
|
||||
.info-label { font-size: 13px; color: var(--df-text-dim); }
|
||||
.info-value { font-size: 13px; color: var(--df-text-secondary); font-weight: 500; }
|
||||
/* ===== 项目信息(左栏 .project-info/.info-item,右栏重复面板已删)===== */
|
||||
|
||||
/* ===== 模态框 ===== */
|
||||
.modal-overlay {
|
||||
|
||||
@@ -1,27 +1,70 @@
|
||||
<template>
|
||||
<div class="projects">
|
||||
<header class="page-header">
|
||||
<h1>📂 项目列表</h1>
|
||||
<h1>{{ $t('projects.title') }}</h1>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-primary" @click="showCreateModal = true">+ 新建项目</button>
|
||||
<button class="btn btn-ghost" @click="openTrash">{{ $t('projects.trash') }}</button>
|
||||
<button class="btn btn-primary" @click="showCreateModal = true">{{ $t('projects.create') }}</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 新建项目模态框 -->
|
||||
<div v-if="showCreateModal" class="modal-overlay" @click.self="showCreateModal = false">
|
||||
<div class="modal-box">
|
||||
<h3 class="modal-title">新建项目</h3>
|
||||
<h3 class="modal-title">{{ $t('projects.createTitle') }}</h3>
|
||||
<div class="modal-field">
|
||||
<label>项目名称</label>
|
||||
<input v-model="newName" placeholder="输入项目名称" @keyup.enter="submitCreate" />
|
||||
<label>{{ $t('projects.nameLabel') }}</label>
|
||||
<input v-model="newName" :placeholder="$t('projects.namePlaceholder')" @keyup.enter="submitCreate" />
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<label>项目描述</label>
|
||||
<textarea v-model="newDesc" placeholder="简要描述项目目标" rows="3"></textarea>
|
||||
<label>{{ $t('projects.descLabel') }}</label>
|
||||
<textarea v-model="newDesc" :placeholder="$t('projects.descPlaceholder')" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<label>{{ $t('projects.codeDirLabel') }}(可选,绑定后自动识别技术栈)</label>
|
||||
<div class="dir-row">
|
||||
<input v-model="newPath" :placeholder="$t('projects.codeDirPlaceholder')" readonly />
|
||||
<button class="btn btn-ghost btn-sm" type="button" @click="pickDir">{{ $t('projects.selectDir') }}</button>
|
||||
<button v-if="newPath" class="btn btn-ghost btn-sm" type="button" @click="clearDir">{{ $t('projects.clearDir') }}</button>
|
||||
</div>
|
||||
<div v-if="pathWarning" class="path-warning">⚠ {{ pathWarning }}</div>
|
||||
<div v-if="newPath" class="scan-row">
|
||||
<button class="btn btn-ghost btn-sm" type="button" @click="aiScan" :disabled="scanning">
|
||||
{{ scanning ? 'AI 扫描中…' : '🤖 AI 扫描填信息' }}
|
||||
</button>
|
||||
<span v-if="scanProjectType" class="tech-tag">{{ scanProjectType }}</span>
|
||||
</div>
|
||||
<div v-if="scanError" class="path-warning">⚠ {{ scanError }}</div>
|
||||
<div v-if="detectedStack.length" class="card-tags" style="margin-top: 8px">
|
||||
<span class="tech-tag" v-for="t in detectedStack" :key="t">{{ t }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-ghost" @click="showCreateModal = false">取消</button>
|
||||
<button class="btn btn-primary" @click="submitCreate" :disabled="!newName.trim()">确认创建</button>
|
||||
<button class="btn btn-ghost" @click="showCreateModal = false">{{ $t('common.cancel') }}</button>
|
||||
<button class="btn btn-primary" @click="submitCreate" :disabled="!newName.trim()">{{ $t('projects.confirmCreate') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 回收站模态框 -->
|
||||
<div v-if="showTrashModal" class="modal-overlay" @click.self="showTrashModal = false">
|
||||
<div class="modal-box" style="width: 520px">
|
||||
<h3 class="modal-title">{{ $t('projects.trashTitle') }}</h3>
|
||||
<div v-if="store.deletedProjects.length === 0" class="empty-state" style="padding: 30px 0">{{ $t('projects.trashEmpty') }}</div>
|
||||
<div v-else class="trash-list">
|
||||
<div class="trash-item" v-for="p in store.deletedProjects" :key="p.id">
|
||||
<div class="trash-info">
|
||||
<span class="trash-name">{{ p.name }}</span>
|
||||
<span class="trash-date">{{ formatDate(p.updated_at) }} {{ $t('projects.movedIn') }}</span>
|
||||
</div>
|
||||
<div class="trash-actions">
|
||||
<button class="btn btn-ghost btn-sm" @click="handleRestore(p.id)">{{ $t('projects.restore') }}</button>
|
||||
<button class="btn btn-danger btn-sm" @click="handlePurge(p)">{{ $t('projects.purge') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-ghost" @click="showTrashModal = false">{{ $t('common.close') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -33,7 +76,10 @@
|
||||
<div class="card-title-row">
|
||||
<h2 class="card-name">{{ project.name }}</h2>
|
||||
</div>
|
||||
<span class="stage-badge" :class="stageClass(project.status)">{{ statusLabel(project.status) }}</span>
|
||||
<div class="card-top-actions">
|
||||
<span class="stage-badge" :class="stageClass(project.status)">{{ $t(statusLabel(project.status)) }}</span>
|
||||
<button class="card-del-btn" :title="$t('projects.deleteTitle')" @click.stop="handleDelete(project)">🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="card-desc">{{ project.description }}</p>
|
||||
@@ -49,12 +95,17 @@
|
||||
<span>{{ formatDate(project.updated_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 技术栈标签 -->
|
||||
<div class="card-tags" v-if="parseStack(project.stack).length">
|
||||
<span class="tech-tag" v-for="t in parseStack(project.stack)" :key="t">{{ t }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="!store.loading && store.projects.length === 0" class="empty-state">
|
||||
暂无项目,点击右上角创建第一个项目
|
||||
{{ $t('projects.empty') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -62,50 +113,123 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
import { useProjectStore } from '../stores/project'
|
||||
import { projectApi } from '../api'
|
||||
import { formatDate } from '../utils/time'
|
||||
import { projectStatusLabel as statusLabel, projectBadgeClass as stageClass } from '../constants/project'
|
||||
import type { ProjectRecord } from '../api/types'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useProjectStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
// ── 状态映射 ──
|
||||
const statusLabels: Record<string, string> = {
|
||||
planning: '📐 规划中',
|
||||
in_progress: '💻 进行中',
|
||||
paused: '⏸ 已暂停',
|
||||
completed: '✅ 已完成',
|
||||
cancelled: '❌ 已取消',
|
||||
}
|
||||
|
||||
const statusStageClass: Record<string, string> = {
|
||||
planning: 'stage-design',
|
||||
in_progress: 'stage-coding',
|
||||
paused: 'stage-testing',
|
||||
completed: 'stage-release',
|
||||
cancelled: 'stage-design',
|
||||
}
|
||||
|
||||
function statusLabel(status: string) {
|
||||
return statusLabels[status] ?? status
|
||||
}
|
||||
|
||||
function stageClass(status: string) {
|
||||
return statusStageClass[status] ?? 'stage-design'
|
||||
}
|
||||
|
||||
// 状态映射统一走 ../constants/project(与 ProjectDetail/Tasks/Dashboard 一致)
|
||||
// formatDate 由 ../utils/time 提供(统一毫秒字符串解析,根治 Invalid Date)
|
||||
|
||||
// ── 新建项目 ──
|
||||
const showCreateModal = ref(false)
|
||||
const newName = ref('')
|
||||
const newDesc = ref('')
|
||||
const newPath = ref('')
|
||||
const detectedStack = ref<string[]>([])
|
||||
const pathWarning = ref('')
|
||||
const scanning = ref(false)
|
||||
const scanError = ref('')
|
||||
const scanProjectType = ref('')
|
||||
|
||||
// 解析技术栈 JSON 数组字符串(防崩)
|
||||
function parseStack(stack: string | null): string[] {
|
||||
if (!stack) return []
|
||||
try {
|
||||
const arr = JSON.parse(stack)
|
||||
return Array.isArray(arr) ? arr : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// 选择目录 → 探测技术栈 + 查重
|
||||
async function pickDir() {
|
||||
try {
|
||||
const selected = await open({ directory: true, multiple: false })
|
||||
if (!selected || Array.isArray(selected)) return
|
||||
newPath.value = selected as string
|
||||
detectedStack.value = []
|
||||
pathWarning.value = ''
|
||||
// 探测技术栈(失败不阻塞,后端创建时再探)
|
||||
try { detectedStack.value = await projectApi.scanStack(selected as string) } catch { /* ignore */ }
|
||||
// 查重(已被其他项目绑定则警告,禁止创建)
|
||||
try {
|
||||
const conflict = await projectApi.checkBinding(selected as string)
|
||||
if (conflict) pathWarning.value = `该目录已被项目「${conflict.name}」绑定`
|
||||
} catch { /* ignore */ }
|
||||
} catch (e) {
|
||||
console.error('选择目录失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function clearDir() {
|
||||
newPath.value = ''
|
||||
detectedStack.value = []
|
||||
pathWarning.value = ''
|
||||
scanning.value = false
|
||||
scanError.value = ''
|
||||
scanProjectType.value = ''
|
||||
}
|
||||
|
||||
// AI 扫描目录 → 自动填描述/技术栈/项目类型(LLM 失败降级规则探测)
|
||||
async function aiScan() {
|
||||
if (!newPath.value) return
|
||||
scanning.value = true
|
||||
scanError.value = ''
|
||||
scanProjectType.value = ''
|
||||
try {
|
||||
const result = await projectApi.scanWithAi(newPath.value)
|
||||
detectedStack.value = result.stack
|
||||
if (result.description) newDesc.value = result.description
|
||||
scanProjectType.value = result.project_type ?? ''
|
||||
if (!result.description) scanError.value = 'AI 未能生成描述,可手动填写'
|
||||
} catch (e: any) {
|
||||
scanError.value = e?.toString() ?? 'AI 扫描失败'
|
||||
} finally {
|
||||
scanning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
if (!newName.value.trim()) return
|
||||
await store.createProject(newName.value.trim(), newDesc.value.trim())
|
||||
if (pathWarning.value) return // 目录冲突,禁止创建
|
||||
const stack = detectedStack.value.length ? JSON.stringify(detectedStack.value) : undefined
|
||||
const r = await store.createProject(newName.value.trim(), newDesc.value.trim(), undefined, newPath.value || undefined, stack)
|
||||
if (!r) return // 失败已 toast,保持弹窗不关
|
||||
showCreateModal.value = false
|
||||
newName.value = ''
|
||||
newDesc.value = ''
|
||||
clearDir()
|
||||
}
|
||||
|
||||
// ── 删除 / 回收站 ──
|
||||
const showTrashModal = ref(false)
|
||||
|
||||
async function handleDelete(project: ProjectRecord) {
|
||||
if (!confirm(t('projects.confirmDelete', { name: project.name }))) return
|
||||
await store.deleteProject(project.id)
|
||||
}
|
||||
|
||||
async function openTrash() {
|
||||
await store.loadDeletedProjects()
|
||||
showTrashModal.value = true
|
||||
}
|
||||
|
||||
async function handleRestore(id: string) {
|
||||
await store.restoreProject(id)
|
||||
}
|
||||
|
||||
async function handlePurge(project: ProjectRecord) {
|
||||
if (!confirm(t('projects.confirmPurge', { name: project.name }))) return
|
||||
await store.purgeProject(project.id)
|
||||
}
|
||||
|
||||
// ── 加载 ──
|
||||
@@ -137,6 +261,9 @@ onMounted(() => {
|
||||
}
|
||||
.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: 4px 10px; font-size: 12px; }
|
||||
|
||||
/* ===== 卡片网格 ===== */
|
||||
.project-grid {
|
||||
@@ -241,6 +368,12 @@ onMounted(() => {
|
||||
border: 0.5px solid var(--df-border);
|
||||
}
|
||||
|
||||
/* ===== 目录选择行 ===== */
|
||||
.dir-row { display: flex; gap: 8px; align-items: center; }
|
||||
.dir-row input { flex: 1; cursor: pointer; }
|
||||
.path-warning { margin-top: 6px; font-size: 12px; color: var(--df-danger); }
|
||||
.scan-row { display: flex; gap: 8px; align-items: center; margin-top: 8px; }
|
||||
|
||||
/* ===== 模态框 ===== */
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0;
|
||||
@@ -279,6 +412,26 @@ onMounted(() => {
|
||||
font-size: 14px; color: var(--df-text-dim);
|
||||
}
|
||||
|
||||
/* ===== 卡片删除按钮 + 回收站 ===== */
|
||||
.card-top-actions { display: flex; align-items: center; gap: 8px; }
|
||||
.card-del-btn {
|
||||
background: transparent; border: none; cursor: pointer;
|
||||
font-size: 14px; padding: 2px 4px; opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.project-card:hover .card-del-btn { opacity: 0.55; }
|
||||
.card-del-btn:hover { opacity: 1 !important; }
|
||||
.trash-list { display: flex; flex-direction: column; max-height: 400px; overflow-y: auto; }
|
||||
.trash-item {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 10px 0; border-bottom: 0.5px solid var(--df-border);
|
||||
}
|
||||
.trash-item:last-child { border-bottom: none; }
|
||||
.trash-info { display: flex; flex-direction: column; gap: 2px; }
|
||||
.trash-name { font-size: 14px; color: var(--df-text); font-weight: 500; }
|
||||
.trash-date { font-size: 11px; color: var(--df-text-dim); }
|
||||
.trash-actions { display: flex; gap: 8px; }
|
||||
|
||||
/* ===== 响应式 ===== */
|
||||
@media (max-width: 900px) {
|
||||
.projects { padding: 16px; }
|
||||
|
||||
@@ -1,82 +1,96 @@
|
||||
<template>
|
||||
<div class="settings">
|
||||
<Transition name="toast">
|
||||
<div v-if="toast.visible" class="toast" :class="'toast-' + toast.type">{{ toast.msg }}</div>
|
||||
</Transition>
|
||||
<Transition name="confirm">
|
||||
<div v-if="confirmState.visible" class="confirm-mask" @click.self="answerConfirm(false)">
|
||||
<div class="confirm-box">
|
||||
<div class="confirm-msg">{{ confirmState.msg }}</div>
|
||||
<div class="confirm-actions">
|
||||
<button class="btn btn-ghost btn-sm" @click="answerConfirm(false)">{{ $t('common.cancel') }}</button>
|
||||
<button class="btn btn-danger btn-sm" @click="answerConfirm(true)">{{ $t('common.delete') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<!-- 页面头部 -->
|
||||
<header class="page-header">
|
||||
<h1>⚙️ 设置</h1>
|
||||
<h1>{{ $t('settings.title') }}</h1>
|
||||
</header>
|
||||
|
||||
<div class="settings-grid">
|
||||
<!-- ═══ AI 模型配置 ═══ -->
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>🤖 AI 模型配置</h2>
|
||||
<button class="btn btn-ghost btn-sm" @click="openProviderForm()">+ 添加 Provider</button>
|
||||
<h2>{{ $t('settings.panelAiProvider') }}</h2>
|
||||
<button class="btn btn-ghost btn-sm" @click="openProviderForm()">{{ $t('settings.addProvider') }}</button>
|
||||
</div>
|
||||
<div class="provider-list" v-if="aiProviders.length > 0">
|
||||
<div class="provider-card" v-for="p in aiProviders" :key="p.id">
|
||||
<div class="provider-card" :class="{ 'provider-card--default': p.is_default }" v-for="p in aiProviders" :key="p.id">
|
||||
<div class="provider-top">
|
||||
<div class="provider-info">
|
||||
<span class="provider-name">{{ p.name }}</span>
|
||||
<span v-if="p.is_default" class="default-badge">默认</span>
|
||||
<span v-if="p.is_default" class="default-badge">{{ $t('settings.badgeDefault') }}</span>
|
||||
</div>
|
||||
<div class="provider-actions">
|
||||
<button class="btn-link" @click="setDefaultProvider(p.id)" v-if="!p.is_default">设为默认</button>
|
||||
<button class="btn-link" @click="openProviderForm(p)">编辑</button>
|
||||
<button class="btn-link btn-link-danger" @click="deleteProvider(p.id)">删除</button>
|
||||
<button class="btn-link" @click="setDefaultProvider(p.id)" v-if="!p.is_default">{{ $t('settings.actionSetDefault') }}</button>
|
||||
<button class="btn-link" @click="openProviderForm(p)">{{ $t('common.edit') }}</button>
|
||||
<button class="btn-link btn-link-danger" @click="deleteProvider(p.id)">{{ $t('common.delete') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="provider-detail">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Base URL</span>
|
||||
<span class="detail-label">{{ $t('settings.detailBaseUrl') }}</span>
|
||||
<span class="detail-value">{{ p.base_url }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">模型</span>
|
||||
<span class="detail-label">{{ $t('settings.detailModel') }}</span>
|
||||
<span class="detail-value">{{ p.default_model }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">API Key</span>
|
||||
<span class="detail-label">{{ $t('settings.detailApiKey') }}</span>
|
||||
<span class="detail-value mask">{{ maskKey(p.api_key) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="empty-hint" v-else>暂无 AI 提供商,点击上方按钮添加</div>
|
||||
<div class="empty-hint" v-else>{{ $t('settings.emptyProvider') }}</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══ AI 提供商表单 ═══ -->
|
||||
<section class="panel" v-if="providerForm.visible">
|
||||
<div class="panel-header">
|
||||
<h2>{{ providerForm.editId ? '编辑 Provider' : '添加 Provider' }}</h2>
|
||||
<button class="btn btn-ghost btn-sm" @click="providerForm.visible = false">取消</button>
|
||||
<h2>{{ providerForm.editId ? $t('settings.editProviderTitle') : $t('settings.addProviderTitle') }}</h2>
|
||||
<button class="btn btn-ghost btn-sm" @click="providerForm.visible = false">{{ $t('common.cancel') }}</button>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<div class="form-field">
|
||||
<label class="form-label">名称</label>
|
||||
<input v-model="providerForm.name" class="setting-input" placeholder="如 DeepSeek、GLM-4" />
|
||||
<label class="form-label">{{ $t('settings.labelName') }}</label>
|
||||
<input v-model="providerForm.name" class="setting-input" :placeholder="$t('settings.phProviderName')" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label">协议类型</label>
|
||||
<label class="form-label">{{ $t('settings.labelProviderType') }}</label>
|
||||
<select v-model="providerForm.providerType" class="setting-select">
|
||||
<option value="openai_compat">OpenAI 兼容(DeepSeek / GLM v4 / OpenAI)</option>
|
||||
<option value="anthropic">Anthropic 兼容(GLM 订阅 / Claude)</option>
|
||||
<option value="openai_compat">{{ $t('settings.providerTypeOpenaiCompat') }}</option>
|
||||
<option value="anthropic">{{ $t('settings.providerTypeAnthropic') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label">Base URL</label>
|
||||
<input v-model="providerForm.baseUrl" class="setting-input" placeholder="https://api.deepseek.com" />
|
||||
<label class="form-label">{{ $t('settings.labelBaseUrl') }}</label>
|
||||
<input v-model="providerForm.baseUrl" class="setting-input" :placeholder="$t('settings.phBaseUrl')" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label">API Key</label>
|
||||
<input v-model="providerForm.apiKey" class="setting-input" type="password" placeholder="sk-..." />
|
||||
<label class="form-label">{{ $t('settings.labelApiKey') }}</label>
|
||||
<input v-model="providerForm.apiKey" class="setting-input" type="password" :placeholder="$t('settings.phApiKey')" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label">默认模型</label>
|
||||
<input v-model="providerForm.defaultModel" class="setting-input" placeholder="deepseek-chat" />
|
||||
<label class="form-label">{{ $t('settings.labelDefaultModel') }}</label>
|
||||
<input v-model="providerForm.defaultModel" class="setting-input" :placeholder="$t('settings.phDefaultModel')" />
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-primary" @click="saveProvider" :disabled="providerForm.saving">
|
||||
{{ providerForm.saving ? '保存中...' : '保存' }}
|
||||
{{ providerForm.saving ? $t('settings.saving') : $t('common.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -85,8 +99,8 @@
|
||||
<!-- ═══ 连接管理 ═══ -->
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>🔗 连接管理</h2>
|
||||
<button class="btn btn-ghost btn-sm" @click="openConnForm()">+ 添加连接</button>
|
||||
<h2>{{ $t('settings.panelConnection') }}</h2>
|
||||
<button class="btn btn-ghost btn-sm" @click="openConnForm()">{{ $t('settings.addConnection') }}</button>
|
||||
</div>
|
||||
<div class="connection-list" v-if="connections.length > 0">
|
||||
<div class="connection-card" v-for="conn in connections" :key="conn.id">
|
||||
@@ -102,27 +116,27 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="conn-actions">
|
||||
<button class="btn-link" @click="openConnForm(conn)">编辑</button>
|
||||
<button class="btn-link btn-link-danger" @click="removeConn(conn.id)">删除</button>
|
||||
<button class="btn-link" @click="openConnForm(conn)">{{ $t('common.edit') }}</button>
|
||||
<button class="btn-link btn-link-danger" @click="removeConn(conn.id)">{{ $t('common.delete') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="empty-hint" v-else>暂无连接,点击上方按钮添加</div>
|
||||
<div class="empty-hint" v-else>{{ $t('settings.emptyConnection') }}</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══ 连接表单 ═══ -->
|
||||
<section class="panel" v-if="connForm.visible">
|
||||
<div class="panel-header">
|
||||
<h2>{{ connForm.editId ? '编辑连接' : '添加连接' }}</h2>
|
||||
<button class="btn btn-ghost btn-sm" @click="connForm.visible = false">取消</button>
|
||||
<h2>{{ connForm.editId ? $t('settings.editConnectionTitle') : $t('settings.addConnectionTitle') }}</h2>
|
||||
<button class="btn btn-ghost btn-sm" @click="connForm.visible = false">{{ $t('common.cancel') }}</button>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<div class="form-field">
|
||||
<label class="form-label">名称</label>
|
||||
<input v-model="connForm.name" class="setting-input" placeholder="flux_dev" />
|
||||
<label class="form-label">{{ $t('settings.labelName') }}</label>
|
||||
<input v-model="connForm.name" class="setting-input" :placeholder="$t('settings.phConnName')" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label">类型</label>
|
||||
<label class="form-label">{{ $t('settings.labelType') }}</label>
|
||||
<select v-model="connForm.type" class="setting-select">
|
||||
<option value="mysql">MySQL</option>
|
||||
<option value="ssh">SSH</option>
|
||||
@@ -131,19 +145,19 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label">Host</label>
|
||||
<input v-model="connForm.host" class="setting-input" placeholder="127.0.0.1" />
|
||||
<label class="form-label">{{ $t('settings.labelHost') }}</label>
|
||||
<input v-model="connForm.host" class="setting-input" :placeholder="$t('settings.phHost')" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label">Port</label>
|
||||
<input v-model.number="connForm.port" class="setting-input" type="number" placeholder="3306" />
|
||||
<label class="form-label">{{ $t('settings.labelPort') }}</label>
|
||||
<input v-model.number="connForm.port" class="setting-input" type="number" :placeholder="$t('settings.phPort')" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label">用户名</label>
|
||||
<input v-model="connForm.user" class="setting-input" placeholder="root" />
|
||||
<label class="form-label">{{ $t('settings.labelUser') }}</label>
|
||||
<input v-model="connForm.user" class="setting-input" :placeholder="$t('settings.phUser')" />
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-primary" @click="saveConn">保存</button>
|
||||
<button class="btn btn-primary" @click="saveConn">{{ $t('common.save') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -151,51 +165,51 @@
|
||||
<!-- ═══ 通用设置 ═══ -->
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>🎨 通用设置</h2>
|
||||
<h2>{{ $t('settings.panelGeneral') }}</h2>
|
||||
</div>
|
||||
<div class="general-settings">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">主题</span>
|
||||
<span class="setting-desc">深色 / 浅色模式</span>
|
||||
<span class="setting-label">{{ $t('settings.labelTheme') }}</span>
|
||||
<span class="setting-desc">{{ $t('settings.descTheme') }}</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<select v-model="settings.theme" class="setting-select">
|
||||
<option value="dark">🌙 深色模式</option>
|
||||
<option value="light">☀️ 浅色模式</option>
|
||||
<option value="system">💻 跟随系统</option>
|
||||
<option value="dark">{{ $t('settings.themeDark') }}</option>
|
||||
<option value="light">{{ $t('settings.themeLight') }}</option>
|
||||
<option value="system">{{ $t('settings.themeSystem') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">语言</span>
|
||||
<span class="setting-desc">界面显示语言</span>
|
||||
<span class="setting-label">{{ $t('settings.labelLanguage') }}</span>
|
||||
<span class="setting-desc">{{ $t('settings.descLanguage') }}</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<select v-model="settings.language" class="setting-select">
|
||||
<option value="zh-CN">简体中文</option>
|
||||
<option value="en">English</option>
|
||||
<option value="zh-CN">{{ $t('settings.aiLanguageZh') }}</option>
|
||||
<option value="en">{{ $t('settings.aiLanguageEn') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">AI 对话语言</span>
|
||||
<span class="setting-desc">AI 回复使用的默认语言</span>
|
||||
<span class="setting-label">{{ $t('settings.labelAiLanguage') }}</span>
|
||||
<span class="setting-desc">{{ $t('settings.descAiLanguage') }}</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<select v-model="settings.aiLanguage" class="setting-select">
|
||||
<option value="auto">🔄 跟随界面语言</option>
|
||||
<option value="zh-CN">🇨🇳 简体中文</option>
|
||||
<option value="en">🇺🇸 English</option>
|
||||
<option value="auto">{{ $t('settings.aiLanguageAuto') }}</option>
|
||||
<option value="zh-CN">{{ $t('settings.aiLanguageZh') }}</option>
|
||||
<option value="en">{{ $t('settings.aiLanguageEn') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">AI 自动执行</span>
|
||||
<span class="setting-desc">AI 可自动执行低风险操作</span>
|
||||
<span class="setting-label">{{ $t('settings.labelAutoExecute') }}</span>
|
||||
<span class="setting-desc">{{ $t('settings.descAutoExecute') }}</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="toggle">
|
||||
@@ -206,8 +220,8 @@
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">日志级别</span>
|
||||
<span class="setting-desc">工作流日志详细程度</span>
|
||||
<span class="setting-label">{{ $t('settings.labelLogLevel') }}</span>
|
||||
<span class="setting-desc">{{ $t('settings.descLogLevel') }}</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<select v-model="settings.logLevel" class="setting-select">
|
||||
@@ -218,6 +232,136 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">{{ $t('settings.labelShowTokenUsage') }}</span>
|
||||
<span class="setting-desc">{{ $t('settings.descShowTokenUsage') }}</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" v-model="settings.showTokenUsage" />
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">{{ $t('settings.labelGlobalConcurrency') }}</span>
|
||||
<span class="setting-desc">{{ $t('settings.descGlobalConcurrency') }}</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<input type="number" class="setting-select" min="1" style="width:80px"
|
||||
v-model.number="settings.llmGlobalConcurrency"
|
||||
@change="syncConcurrencyConfig" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">{{ $t('settings.labelPerConvConcurrency') }}</span>
|
||||
<span class="setting-desc">{{ $t('settings.descPerConvConcurrency') }}</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<input type="number" class="setting-select" min="1" style="width:80px"
|
||||
v-model.number="settings.llmPerConvConcurrency"
|
||||
@change="syncConcurrencyConfig" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>{{ $t('settings.panelKnowledge') }}</h2>
|
||||
</div>
|
||||
<div class="general-settings">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">{{ $t('settings.labelAutoExtract') }}</span>
|
||||
<span class="setting-desc">{{ $t('settings.descAutoExtract') }}</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" v-model="knowledgeConfig.auto_extract" />
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">{{ $t('settings.labelTriggerMode') }}</span>
|
||||
<span class="setting-desc">{{ $t('settings.descTriggerMode') }}</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<select v-model="knowledgeConfig.trigger_mode" class="setting-select">
|
||||
<option value="on_complete">{{ $t('settings.triggerOnComplete') }}</option>
|
||||
<option value="on_idle">{{ $t('settings.triggerOnIdle') }}</option>
|
||||
<option value="manual_only">{{ $t('settings.triggerManualOnly') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">{{ $t('settings.labelMinMessages') }}</span>
|
||||
<span class="setting-desc">{{ $t('settings.descMinMessages') }}</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<input type="number" min="2" max="50" v-model.number="knowledgeConfig.min_messages" class="setting-select" style="width:80px" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">{{ $t('settings.labelIdleTimeout') }}</span>
|
||||
<span class="setting-desc">{{ $t('settings.descIdleTimeout') }}</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<input type="number" min="5" max="600" :value="Math.round(knowledgeConfig.idle_timeout_ms / 1000)" @input="knowledgeConfig.idle_timeout_ms = ($event.target as HTMLInputElement).valueAsNumber * 1000 || 30000" class="setting-select" style="width:80px" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">{{ $t('settings.labelAutoInject') }}</span>
|
||||
<span class="setting-desc">{{ $t('settings.descAutoInject') }}</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" v-model="knowledgeConfig.auto_inject" />
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">{{ $t('settings.labelVectorEnabled') }}</span>
|
||||
<span class="setting-desc">{{ $t('settings.descVectorEnabled') }}</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" v-model="knowledgeConfig.vector_enabled" />
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row" v-if="knowledgeConfig.vector_enabled">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">{{ $t('settings.labelEmbeddingProvider') }}</span>
|
||||
<span class="setting-desc">{{ $t('settings.descEmbeddingProvider') }}</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<select v-model="knowledgeConfig.embedding_provider_id" class="setting-select">
|
||||
<option :value="null">{{ $t('settings.embeddingProviderNone') }}</option>
|
||||
<option v-for="p in embeddingProviders" :key="p.id" :value="p.id">{{ p.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row" v-if="knowledgeConfig.vector_enabled">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">{{ $t('settings.labelEmbeddingModel') }}</span>
|
||||
<span class="setting-desc">{{ $t('settings.descEmbeddingModel') }}</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<input type="text" :value="knowledgeConfig.embedding_model ?? ''" @input="knowledgeConfig.embedding_model = ($event.target as HTMLInputElement).value || null" class="setting-select" :placeholder="$t('settings.phEmbeddingModel')" style="width:220px" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -225,17 +369,55 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch, onMounted } from 'vue'
|
||||
import { aiApi } from '../api'
|
||||
import { reactive, ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { aiApi, knowledgeApi } from '../api'
|
||||
import { useAppSettingsStore } from '../stores/appSettings'
|
||||
import type { AiProviderConfig } from '../api/types'
|
||||
import i18n from '../i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appSettings = useAppSettingsStore()
|
||||
|
||||
// ============================================================
|
||||
// AI 提供商 — 真实后端 CRUD
|
||||
// ============================================================
|
||||
|
||||
const aiProviders = ref<AiProviderConfig[]>([])
|
||||
|
||||
// 顶部轻量提示(错误/警告/完成),3s 自动消失;零依赖,替代未接入的 Arco Message
|
||||
const toast = reactive({ visible: false, msg: '', type: 'info' as 'error' | 'warning' | 'info' })
|
||||
let _toastTimer: ReturnType<typeof setTimeout> | null = null
|
||||
/** 安全提取错误消息——Tauri IPC 错误是字符串非 Error 对象 */
|
||||
function errMsg(e: unknown): string { return e instanceof Error ? e.message : String(e) }
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 自建确认弹层(替代 window.confirm —— 后者带 webview 来源域名/端口,无法去除)
|
||||
const confirmState = reactive({
|
||||
visible: false,
|
||||
msg: '',
|
||||
resolve: null as null | ((v: boolean) => void),
|
||||
})
|
||||
function confirmDialog(msg: string): Promise<boolean> {
|
||||
return new Promise(resolve => {
|
||||
confirmState.msg = msg
|
||||
confirmState.visible = true
|
||||
confirmState.resolve = resolve
|
||||
})
|
||||
}
|
||||
function answerConfirm(ok: boolean) {
|
||||
confirmState.visible = false
|
||||
confirmState.resolve?.(ok)
|
||||
confirmState.resolve = null
|
||||
}
|
||||
|
||||
const providerForm = reactive({
|
||||
visible: false,
|
||||
editId: '' as string,
|
||||
@@ -251,7 +433,8 @@ async function loadProviders() {
|
||||
try {
|
||||
aiProviders.value = await aiApi.listProviders()
|
||||
} catch (e) {
|
||||
console.error('加载提供商失败:', e)
|
||||
console.error(t('settings.toastLoadProviderFail'), e)
|
||||
showToast(t('settings.toastLoadProviderFail'), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,7 +458,10 @@ function openProviderForm(p?: AiProviderConfig) {
|
||||
}
|
||||
|
||||
async function saveProvider() {
|
||||
if (!providerForm.name || !providerForm.baseUrl || !providerForm.apiKey || !providerForm.defaultModel) return
|
||||
if (!providerForm.name || !providerForm.baseUrl || !providerForm.apiKey || !providerForm.defaultModel) {
|
||||
showToast(t('settings.toastSaveIncomplete'), 'warning')
|
||||
return
|
||||
}
|
||||
providerForm.saving = true
|
||||
try {
|
||||
await aiApi.saveProvider({
|
||||
@@ -288,25 +474,36 @@ async function saveProvider() {
|
||||
})
|
||||
providerForm.visible = false
|
||||
await loadProviders()
|
||||
showToast(t('settings.toastSaved'), 'info')
|
||||
} catch (e) {
|
||||
console.error('保存提供商失败:', e)
|
||||
console.error(t('settings.toastSaveFail', { msg: errMsg(e) }), e)
|
||||
showToast(t('settings.toastSaveFail', { msg: errMsg(e) }), 'error')
|
||||
} finally {
|
||||
providerForm.saving = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteProvider(id: string) {
|
||||
// 暂无后端 delete 命令,通过 save 空 id 不行,需要后端支持
|
||||
// 先从前端移除,后续补 delete IPC
|
||||
aiProviders.value = aiProviders.value.filter(p => p.id !== id)
|
||||
const p = aiProviders.value.find(x => x.id === id)
|
||||
if (!await confirmDialog(t('settings.confirmDeleteProvider', { name: p?.name || id }))) return
|
||||
try {
|
||||
await aiApi.deleteProvider(id)
|
||||
await loadProviders()
|
||||
showToast(t('settings.toastDeleted'), 'info')
|
||||
} catch (e) {
|
||||
console.error(t('settings.toastDeleteFail', { msg: errMsg(e) }), e)
|
||||
showToast(t('settings.toastDeleteFail', { msg: errMsg(e) }), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function setDefaultProvider(id: string) {
|
||||
try {
|
||||
await aiApi.setProvider(id)
|
||||
await loadProviders()
|
||||
showToast(t('settings.toastSetDefaultOk'), 'info')
|
||||
} catch (e) {
|
||||
console.error('设置默认提供商失败:', e)
|
||||
console.error(t('settings.toastSetDefaultFail', { msg: errMsg(e) }), e)
|
||||
showToast(t('settings.toastSetDefaultFail', { msg: errMsg(e) }), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,14 +540,12 @@ const connForm = reactive({
|
||||
const CONN_STORAGE_KEY = 'df-connections'
|
||||
|
||||
function loadConnections() {
|
||||
try {
|
||||
const raw = localStorage.getItem(CONN_STORAGE_KEY)
|
||||
if (raw) connections.value = JSON.parse(raw)
|
||||
} catch { /* ignore */ }
|
||||
const stored = appSettings.get<ConnRecord[] | null>(CONN_STORAGE_KEY, null)
|
||||
if (stored) connections.value = stored
|
||||
}
|
||||
|
||||
function persistConnections() {
|
||||
localStorage.setItem(CONN_STORAGE_KEY, JSON.stringify(connections.value))
|
||||
void appSettings.set(CONN_STORAGE_KEY, connections.value)
|
||||
}
|
||||
|
||||
function typeIcon(type: string) {
|
||||
@@ -383,7 +578,10 @@ function openConnForm(conn?: ConnRecord) {
|
||||
}
|
||||
|
||||
function saveConn() {
|
||||
if (!connForm.name || !connForm.host) return
|
||||
if (!connForm.name || !connForm.host) {
|
||||
showToast(t('settings.toastConnIncomplete'), 'warning')
|
||||
return
|
||||
}
|
||||
const record: ConnRecord = {
|
||||
id: connForm.editId || Date.now().toString(36),
|
||||
name: connForm.name,
|
||||
@@ -402,9 +600,12 @@ function saveConn() {
|
||||
connForm.visible = false
|
||||
}
|
||||
|
||||
function removeConn(id: string) {
|
||||
async function removeConn(id: string) {
|
||||
const c = connections.value.find(x => x.id === id)
|
||||
if (!await confirmDialog(t('settings.confirmDeleteConn', { name: c?.name || id }))) return
|
||||
connections.value = connections.value.filter(c => c.id !== id)
|
||||
persistConnections()
|
||||
showToast(t('settings.toastDeleted'), 'info')
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -412,11 +613,14 @@ function removeConn(id: string) {
|
||||
// ============================================================
|
||||
|
||||
const settings = reactive({
|
||||
theme: localStorage.getItem('df-theme') || 'dark',
|
||||
language: localStorage.getItem('df-language') || 'zh-CN',
|
||||
aiLanguage: localStorage.getItem('df-ai-language') || 'auto',
|
||||
theme: appSettings.get<string>('df-theme', 'dark'),
|
||||
language: appSettings.get<string>('df-language', 'zh-CN'),
|
||||
aiLanguage: appSettings.get<string>('df-ai-language', 'auto'),
|
||||
autoExecute: false,
|
||||
logLevel: 'info',
|
||||
showTokenUsage: appSettings.get<boolean>('df-show-token-usage', false),
|
||||
llmGlobalConcurrency: appSettings.get<number>('df-llm-global-concurrency', 3),
|
||||
llmPerConvConcurrency: appSettings.get<number>('df-llm-per-conv-concurrency', 2),
|
||||
})
|
||||
|
||||
function applyTheme(theme: string) {
|
||||
@@ -433,29 +637,167 @@ function applyTheme(theme: string) {
|
||||
|
||||
watch(() => settings.theme, (val) => {
|
||||
applyTheme(val)
|
||||
localStorage.setItem('df-theme', val)
|
||||
void appSettings.set('df-theme', val)
|
||||
})
|
||||
|
||||
watch(() => settings.aiLanguage, (val) => {
|
||||
localStorage.setItem('df-ai-language', val)
|
||||
void appSettings.set('df-ai-language', val)
|
||||
})
|
||||
|
||||
watch(() => settings.showTokenUsage, (val) => {
|
||||
void appSettings.set('df-show-token-usage', val)
|
||||
})
|
||||
|
||||
watch(() => settings.llmGlobalConcurrency, (v) => {
|
||||
void appSettings.set('df-llm-global-concurrency', v)
|
||||
})
|
||||
watch(() => settings.llmPerConvConcurrency, (v) => {
|
||||
void appSettings.set('df-llm-per-conv-concurrency', v)
|
||||
})
|
||||
|
||||
// 同步 LLM 并发上限到后端(debounce 300ms,防快速连续调整时频繁 IPC)
|
||||
// 持久化已由上方 watch 写 appSettings(SQLite) 完成;此处只负责同步内存限流器
|
||||
let _concurrencyTimer: ReturnType<typeof setTimeout> | null = null
|
||||
function syncConcurrencyConfig() {
|
||||
// clamp 到合理区间,防越界输入(缓存被篡改 / 手动输超限)致限流失效;
|
||||
// 修正后值同步回 v-model(input 显示) + watch 持久化
|
||||
// 下限 1,无上限;约束单对话并发不超过全局并发
|
||||
settings.llmGlobalConcurrency = Math.max(1, settings.llmGlobalConcurrency || 3)
|
||||
settings.llmPerConvConcurrency = Math.max(1, settings.llmPerConvConcurrency || 2)
|
||||
if (settings.llmPerConvConcurrency > settings.llmGlobalConcurrency) {
|
||||
settings.llmPerConvConcurrency = settings.llmGlobalConcurrency
|
||||
}
|
||||
if (_concurrencyTimer) clearTimeout(_concurrencyTimer)
|
||||
_concurrencyTimer = setTimeout(async () => {
|
||||
try {
|
||||
await aiApi.setConcurrencyConfig(settings.llmGlobalConcurrency, settings.llmPerConvConcurrency)
|
||||
} catch (e) {
|
||||
console.error('同步 LLM 并发配置失败:', e)
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
watch(() => settings.language, (val) => {
|
||||
localStorage.setItem('df-language', val)
|
||||
void appSettings.set('df-language', val)
|
||||
// 同步 i18n 实例,让界面立即跟随语言切换
|
||||
i18n.global.locale.value = val as 'zh-CN' | 'en'
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// 知识库配置(提取 + 注入) — 走后端 IPC,非 localStorage
|
||||
// ============================================================
|
||||
|
||||
const knowledgeConfig = reactive({
|
||||
loaded: false,
|
||||
auto_extract: true,
|
||||
trigger_mode: 'on_complete' as 'on_complete' | 'on_idle' | 'manual_only',
|
||||
min_messages: 4,
|
||||
idle_timeout_ms: 30000,
|
||||
auto_inject: true,
|
||||
vector_enabled: false,
|
||||
embedding_provider_id: null as string | null,
|
||||
embedding_model: null as string | null,
|
||||
})
|
||||
|
||||
// 可做 embedding 的 provider(仅 openai_compat,Anthropic 无 embed API)
|
||||
const embeddingProviders = computed(() =>
|
||||
aiProviders.value.filter(p => p.provider_type !== 'anthropic')
|
||||
)
|
||||
|
||||
async function loadKnowledgeConfig() {
|
||||
try {
|
||||
const cfg = await knowledgeApi.getConfig()
|
||||
Object.assign(knowledgeConfig, cfg)
|
||||
knowledgeConfig.loaded = true
|
||||
} catch (e) {
|
||||
console.error('加载知识库配置失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
let _knowledgeSaveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
function saveKnowledgeConfigDebounced() {
|
||||
if (_knowledgeSaveTimer) clearTimeout(_knowledgeSaveTimer)
|
||||
_knowledgeSaveTimer = setTimeout(async () => {
|
||||
try {
|
||||
const { loaded, ...cfg } = knowledgeConfig
|
||||
void loaded
|
||||
await knowledgeApi.saveConfig(cfg as any)
|
||||
} catch (e) {
|
||||
console.error('保存知识库配置失败:', e)
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => ({ ...knowledgeConfig }),
|
||||
() => {
|
||||
if (knowledgeConfig.loaded) saveKnowledgeConfigDebounced()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
applyTheme(settings.theme)
|
||||
loadProviders()
|
||||
loadConnections()
|
||||
loadKnowledgeConfig()
|
||||
// 启动同步一次 LLM 并发配置(防刷新页面后后端未恢复用户设定值)
|
||||
syncConcurrencyConfig()
|
||||
})
|
||||
|
||||
// 组件卸载清 debounce timer,防 unmount 后旧 timer 仍 fire 发无效 IPC(timer 泄漏)
|
||||
onUnmounted(() => {
|
||||
if (_concurrencyTimer) clearTimeout(_concurrencyTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.settings { padding: 16px 20px 20px; }
|
||||
|
||||
/* ===== 顶部 Toast 提示 ===== */
|
||||
.toast {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1000;
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--df-radius-sm);
|
||||
font-size: 13px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
|
||||
}
|
||||
.toast-error { background: var(--df-danger-bg); color: var(--df-danger); border: 0.5px solid var(--df-danger); }
|
||||
.toast-warning { background: var(--df-warning-bg); color: var(--df-warning); border: 0.5px solid var(--df-warning); }
|
||||
.toast-info { background: var(--df-accent-bg); color: var(--df-accent); border: 0.5px solid var(--df-accent); }
|
||||
.toast-enter-active, .toast-leave-active { transition: opacity 0.2s, transform 0.2s; }
|
||||
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translate(-50%, -8px); }
|
||||
|
||||
/* ===== 确认弹层 ===== */
|
||||
.confirm-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1100;
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.confirm-box {
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-lg);
|
||||
padding: 20px;
|
||||
min-width: 280px;
|
||||
max-width: 360px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
|
||||
}
|
||||
.confirm-msg { font-size: 13px; color: var(--df-text); line-height: 1.5; margin-bottom: 16px; }
|
||||
.confirm-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||
.btn-danger { background: var(--df-danger); color: #fff; }
|
||||
.btn-danger:hover { filter: brightness(1.1); }
|
||||
.confirm-enter-active, .confirm-leave-active { transition: opacity 0.15s; }
|
||||
.confirm-enter-from, .confirm-leave-to { opacity: 0; }
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -527,6 +869,10 @@ onMounted(() => {
|
||||
border-radius: var(--df-radius);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.provider-card--default {
|
||||
border-color: var(--df-accent);
|
||||
box-shadow: inset 3px 0 0 var(--df-accent);
|
||||
}
|
||||
.provider-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<template>
|
||||
<div class="tasks">
|
||||
<header class="page-header">
|
||||
<h1>🔀 任务队列</h1>
|
||||
<h1>{{ $t('tasks.title') }}</h1>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-ghost" @click="refresh">🔄 刷新</button>
|
||||
<button class="btn btn-primary" @click="openCreateModal">+ 新建任务</button>
|
||||
<button class="btn btn-ghost" @click="refresh">{{ $t('tasks.refresh') }}</button>
|
||||
<button class="btn btn-primary" @click="openCreateModal">{{ $t('tasks.create') }}</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 筛选栏 -->
|
||||
<div class="filter-bar">
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">项目:</span>
|
||||
<span class="filter-label">{{ $t('tasks.filter.project') }}</span>
|
||||
<button
|
||||
v-for="p in projectFilters"
|
||||
:key="p.key"
|
||||
@@ -23,7 +23,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">状态:</span>
|
||||
<span class="filter-label">{{ $t('tasks.filter.status') }}</span>
|
||||
<button
|
||||
v-for="s in statusFilters"
|
||||
:key="s.key"
|
||||
@@ -46,7 +46,7 @@
|
||||
<div class="group-header">
|
||||
<span class="group-icon">{{ group.icon }}</span>
|
||||
<h2 class="group-name">{{ group.projectName }}</h2>
|
||||
<span class="group-count">{{ group.tasks.length }} 个任务</span>
|
||||
<span class="group-count">{{ $t('tasks.group.taskCount', { n: group.tasks.length }) }}</span>
|
||||
</div>
|
||||
<div class="task-list">
|
||||
<div class="task-item" v-for="task in group.tasks" :key="task.id">
|
||||
@@ -60,11 +60,11 @@
|
||||
<span class="branch-icon">⑂</span>
|
||||
{{ task.branch_name }}
|
||||
</span>
|
||||
<span class="task-date">{{ formatTime(task.created_at) }}</span>
|
||||
<span class="task-time">{{ formatTime(task.updated_at) }}</span>
|
||||
<span class="task-date">{{ formatRelativeZh(task.created_at) }}</span>
|
||||
<span class="task-time">{{ formatRelativeZh(task.updated_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="status-tag" :class="'status-' + task.status">{{ statusLabel(task.status) }}</span>
|
||||
<span class="status-tag" :class="taskStatusClass(task.status)">{{ $t(statusLabel(task.status)) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -73,16 +73,27 @@
|
||||
<!-- 新建任务模态框 -->
|
||||
<div class="modal-overlay" v-if="showCreateModal" @click.self="showCreateModal = false">
|
||||
<div class="modal-box">
|
||||
<h3>新建任务</h3>
|
||||
<label style="font-size:12px;color:var(--df-text-secondary);margin-bottom:4px;display:block">项目</label>
|
||||
<h3>{{ $t('tasks.modal.title') }}</h3>
|
||||
<label style="font-size:12px;color:var(--df-text-secondary);margin-bottom:4px;display:block">{{ $t('tasks.modal.project') }}</label>
|
||||
<select v-model="newTaskProjectId" style="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">
|
||||
<option v-for="p in store.projects" :key="p.id" :value="p.id">{{ p.name }}</option>
|
||||
</select>
|
||||
<label style="font-size:12px;color:var(--df-text-secondary);margin-bottom:4px;display:block">标题</label>
|
||||
<input v-model="newTaskTitle" placeholder="输入任务标题..." @keyup.enter="confirmCreate" />
|
||||
<label style="font-size:12px;color:var(--df-text-secondary);margin-bottom:4px;display:block">{{ $t('tasks.modal.titleField') }}</label>
|
||||
<input v-model="newTaskTitle" :placeholder="$t('tasks.modal.titlePlaceholder')" @keyup.enter="confirmCreate" />
|
||||
<label style="font-size:12px;color:var(--df-text-secondary);margin-bottom:4px;display:block">{{ $t('tasks.modal.desc') }}</label>
|
||||
<input v-model="newTaskDesc" :placeholder="$t('tasks.modal.descPlaceholder')" style="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" />
|
||||
<label style="font-size:12px;color:var(--df-text-secondary);margin-bottom:4px;display:block">{{ $t('tasks.modal.branch') }}</label>
|
||||
<input v-model="newTaskBranch" :placeholder="$t('tasks.modal.branchPlaceholder')" style="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" />
|
||||
<label style="font-size:12px;color:var(--df-text-secondary);margin-bottom:4px;display:block">{{ $t('tasks.modal.priority') }}</label>
|
||||
<select v-model="newTaskPriority" style="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">
|
||||
<option :value="0">{{ $t('tasks.modal.priorityCritical') }}</option>
|
||||
<option :value="1">{{ $t('tasks.modal.priorityHigh') }}</option>
|
||||
<option :value="2">{{ $t('tasks.modal.priorityMedium') }}</option>
|
||||
<option :value="3">{{ $t('tasks.modal.priorityLow') }}</option>
|
||||
</select>
|
||||
<div class="modal-actions">
|
||||
<button class="btn-cancel" @click="showCreateModal = false">取消</button>
|
||||
<button class="btn-confirm" @click="confirmCreate">确认</button>
|
||||
<button class="btn-cancel" @click="showCreateModal = false">{{ $t('common.cancel') }}</button>
|
||||
<button class="btn-confirm" @click="confirmCreate">{{ $t('common.confirm') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -91,11 +102,14 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useProjectStore } from '../stores/project'
|
||||
import { formatRelativeZh } from '../utils/time'
|
||||
import { taskStatusLabel as statusLabel, taskStatusClass, priorityLabel, priorityClass } from '../constants/project'
|
||||
import type { TaskRecord } from '../api/types'
|
||||
|
||||
const store = useProjectStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const activeProject = ref('all')
|
||||
const activeStatus = ref('all')
|
||||
@@ -104,6 +118,9 @@ const activeStatus = ref('all')
|
||||
const showCreateModal = ref(false)
|
||||
const newTaskProjectId = ref('')
|
||||
const newTaskTitle = ref('')
|
||||
const newTaskDesc = ref('')
|
||||
const newTaskBranch = ref('')
|
||||
const newTaskPriority = ref(2) // 默认 medium
|
||||
|
||||
interface TaskGroup {
|
||||
projectName: string
|
||||
@@ -112,21 +129,21 @@ interface TaskGroup {
|
||||
}
|
||||
|
||||
const projectFilters = computed(() => [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'all', label: t('tasks.filter.all') },
|
||||
...store.projects.map(p => ({ key: p.id, label: p.name })),
|
||||
])
|
||||
|
||||
const statusFilters: { key: string; label: string; icon: string }[] = [
|
||||
{ key: 'all', label: '全部', icon: '📋' },
|
||||
{ key: 'todo', label: '待开始', icon: '📝' },
|
||||
{ key: 'in_progress', label: '进行中', icon: '🔨' },
|
||||
{ key: 'review_ready', label: '待审查', icon: '👀' },
|
||||
{ key: 'merged', label: '已合并', icon: '✅' },
|
||||
{ key: 'abandoned', label: '已废弃', icon: '🗑️' },
|
||||
]
|
||||
const statusFilters = computed<{ key: string; label: string; icon: string }[]>(() => [
|
||||
{ key: 'all', label: t('tasks.statusFilter.all'), icon: '📋' },
|
||||
{ key: 'todo', label: t('tasks.statusFilter.todo'), icon: '📝' },
|
||||
{ key: 'in_progress', label: t('tasks.statusFilter.in_progress'), icon: '🔨' },
|
||||
{ key: 'review_ready', label: t('tasks.statusFilter.review_ready'), icon: '👀' },
|
||||
{ key: 'merged', label: t('tasks.statusFilter.merged'), icon: '✅' },
|
||||
{ key: 'abandoned', label: t('tasks.statusFilter.abandoned'), icon: '🗑️' },
|
||||
])
|
||||
|
||||
function getProjectName(projectId: string): string {
|
||||
return store.projects.find(p => p.id === projectId)?.name ?? '未知项目'
|
||||
return store.projects.find(p => p.id === projectId)?.name ?? t('tasks.group.unknownProject')
|
||||
}
|
||||
|
||||
const projectIcons: Record<string, string> = {
|
||||
@@ -164,30 +181,7 @@ const filteredGroups = computed(() => {
|
||||
return result
|
||||
})
|
||||
|
||||
function statusLabel(status: string) {
|
||||
const map: Record<string, string> = {
|
||||
todo: '📝 待开始',
|
||||
in_progress: '🔨 进行中',
|
||||
review_ready: '👀 待审查',
|
||||
merged: '✅ 已合并',
|
||||
abandoned: '🗑️ 已废弃',
|
||||
}
|
||||
return map[status] ?? status
|
||||
}
|
||||
|
||||
function priorityLabel(priority: number) {
|
||||
const map: Record<number, string> = { 0: 'P0', 1: 'P1', 2: 'P2', 3: 'P3' }
|
||||
return map[priority] ?? `P${priority}`
|
||||
}
|
||||
|
||||
function priorityClass(priority: number): string {
|
||||
const map: Record<number, string> = { 0: 'priority-critical', 1: 'priority-high', 2: 'priority-medium', 3: 'priority-low' }
|
||||
return map[priority] ?? 'priority-low'
|
||||
}
|
||||
|
||||
function formatTime(timestamp: string | number): string {
|
||||
return formatRelativeZh(timestamp)
|
||||
}
|
||||
// statusLabel / priorityLabel / priorityClass 由 ../constants/project 提供(与 ProjectDetail 文案统一)
|
||||
|
||||
async function refresh() {
|
||||
await store.loadTasks()
|
||||
@@ -196,15 +190,22 @@ async function refresh() {
|
||||
function openCreateModal() {
|
||||
newTaskProjectId.value = store.projects.length > 0 ? store.projects[0].id : ''
|
||||
newTaskTitle.value = ''
|
||||
newTaskDesc.value = ''
|
||||
newTaskBranch.value = ''
|
||||
newTaskPriority.value = 2
|
||||
showCreateModal.value = true
|
||||
}
|
||||
|
||||
async function confirmCreate() {
|
||||
if (!newTaskTitle.value.trim() || !newTaskProjectId.value) return
|
||||
await store.createTask({
|
||||
const r = await store.createTask({
|
||||
project_id: newTaskProjectId.value,
|
||||
title: newTaskTitle.value.trim(),
|
||||
description: newTaskDesc.value.trim(),
|
||||
branch_name: newTaskBranch.value.trim() || undefined,
|
||||
priority: newTaskPriority.value,
|
||||
})
|
||||
if (!r) return // 失败已 toast,保持弹窗不关
|
||||
showCreateModal.value = false
|
||||
}
|
||||
|
||||
@@ -370,12 +371,11 @@ onMounted(async () => {
|
||||
border-radius: var(--df-radius-sm);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status-created { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
|
||||
.status-in_progress { background: rgba(100,181,246,0.2); color: var(--df-info); }
|
||||
.status-review_ready { background: rgba(255,217,61,0.2); color: var(--df-warning); }
|
||||
.status-merged { background: rgba(100,255,218,0.15); color: var(--df-success); }
|
||||
.status-abandoned { background: rgba(255,107,107,0.2); color: var(--df-danger); }
|
||||
.status-todo { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
|
||||
.status-progress { background: rgba(100,181,246,0.2); color: var(--df-info); }
|
||||
.status-review { background: rgba(255,217,61,0.2); color: var(--df-warning); }
|
||||
.status-done { background: rgba(100,255,218,0.15); color: var(--df-success); }
|
||||
.status-abandoned { background: rgba(255,107,107,0.2); color: var(--df-danger); }
|
||||
|
||||
/* ===== 模态框 ===== */
|
||||
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 100; }
|
||||
|
||||
Reference in New Issue
Block a user