- 窗口分离: FileExplorer 可弹出独立 Tauri 窗口 - 行号显示: 文件预览左侧显示行号列 - 按后缀图标: rs/ts/js/vue/css/html 等 20+ 类型彩色图标 - Diff 视图: 有 Git 变更的文件可切换 diff 红绿视图 - Git 变更面板: 变更文件列表(按目录缩进分组)+提交历史(分页加载) - 提交详情: 点击提交查看变更文件列表及文件级 diff - 时间显示: 提交历史显示相对时间/具体日期 - 面包屑导航不丢预览: 导航时不改变当前预览文件 - 刷新保留选中文件: 只清树缓存不清预览 - 中文编码修复: git 命令注入 LANG/LC_ALL 环境变量 - 自适应布局: 弹性 flex 布局,窄窗口适配 - 滚动条优化: 内容区内部滚动,不溢出页面层 - 多工程管理: 工具栏添加工程按钮+弹窗表单 - 审批加载超时缩短: 130s 降至 30s - 文件变更自动刷新: write_file/patch_file 触发 df-data-changed
196 lines
8.3 KiB
Vue
196 lines
8.3 KiB
Vue
<template>
|
|
<AppLayout :is-detached="isDetached">
|
|
<!-- 主窗口:页面内容(router-view + 页面切换过渡) -->
|
|
<!-- ErrorBoundary 包在 transition 外层兜底:transition 的直接子节点须是真实元素根,
|
|
若包 ErrorBoundary(slot 透传非元素根)会触发「non-element root」警告且 mode="out-in"
|
|
过渡卡死 → 路由切换时旧页不卸载/新页不挂载 → 主菜单点了能选中但打不开内容。 -->
|
|
<router-view v-slot="{ Component }">
|
|
<ErrorBoundary :key="route.path">
|
|
<transition name="page" mode="out-in">
|
|
<component :is="Component" />
|
|
</transition>
|
|
</ErrorBoundary>
|
|
</router-view>
|
|
|
|
<!-- 分离窗口:纯内容 -->
|
|
<template #detached>
|
|
<router-view v-slot="{ Component }">
|
|
<ErrorBoundary :key="route.path">
|
|
<transition name="page" mode="out-in">
|
|
<component :is="Component" />
|
|
</transition>
|
|
</ErrorBoundary>
|
|
</router-view>
|
|
</template>
|
|
|
|
<!-- AI Chat Panel 内容 -->
|
|
<template #ai-panel>
|
|
<AiChat />
|
|
</template>
|
|
</AppLayout>
|
|
|
|
<!-- 全局错误 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'
|
|
import ErrorBoundary from './components/ErrorBoundary.vue'
|
|
import AppLayout from './components/layout/AppLayout.vue'
|
|
import { aiApi } from '@/api'
|
|
|
|
const route = useRoute()
|
|
const isDetached = computed(() => route.path === '/ai-detached' || route.path === '/file-explorer-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) => {
|
|
if (!['/', '/ai-home', '/ai-detached'].includes(path) && aiStore.state.maximized) {
|
|
aiStore.state.maximized = false
|
|
}
|
|
})
|
|
|
|
// 主题初始化
|
|
function initTheme() {
|
|
const saved = appSettings.get<string>('df-theme', 'dark')
|
|
if (saved === 'light') {
|
|
document.documentElement.setAttribute('data-theme', 'light')
|
|
} else if (saved === 'system') {
|
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
if (!prefersDark) document.documentElement.setAttribute('data-theme', 'light')
|
|
}
|
|
}
|
|
|
|
// Ctrl+I / Cmd+I 切换 AI 面板
|
|
function handleKeydown(e: KeyboardEvent) {
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'i') {
|
|
e.preventDefault()
|
|
aiStore.togglePanel()
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
// 1. 先把 SQLite 全表 KV 填进 appSettings 缓存(后续 get/useSetting 才能读到真实值)
|
|
await appSettings.loadAll()
|
|
// 2. 一次性迁移:把旧 localStorage 的 11 个 key 导入 SQLite(仅首次跑,之后 localStorage 清净)
|
|
await migrateLegacyLocalStorage()
|
|
// 3. 缓存已就绪,初始化主题/locale
|
|
initTheme()
|
|
// F-260618-23: 根 onMounted 恢复 Agentic 轮次/重试到后端(AtomicUsize 纯内存,重启回默认)。
|
|
// GeneralPanel.vue onMounted 仅 Settings 页挂载,用户直接进 AI Chat 不同步 → 后端默认 10 与前端显示持久值不一致。
|
|
// clamp 与 GeneralPanel 语义对齐(0=不限透传,1-50 clamp;双保险,IPC 幂等值相同无害)
|
|
// 包 try/catch 防 IPC 失败 reject onMounted(对齐 dataChangedListener 模式)
|
|
try {
|
|
const iter = appSettings.get<number>('df-ai-agent-max-iterations', 10)
|
|
const iterClamped = iter === 0 ? 0 : Math.min(50, Math.max(1, iter ?? 10))
|
|
const retry = appSettings.get<number>('df-ai-agent-max-retries', 3)
|
|
const retryClamped = Math.min(10, Math.max(0, retry ?? 3))
|
|
await aiApi.setAgentMaxIterations(iterClamped)
|
|
await aiApi.setAgentMaxRetries(retryClamped)
|
|
// 设置走查-2026-06-21:并发配置同属后端 AtomicUsize 纯内存,重启回默认。原 onMounted
|
|
// 漏同步 global/per-conv-concurrency → 用户改并发→重启→不进 Settings 直接用→后端默认 3/2
|
|
// 前后端不一致。补齐对齐 iter/retry 恢复模式。
|
|
const globalConv = appSettings.get<number>('df-llm-global-concurrency', 3)
|
|
const perConvConv = appSettings.get<number>('df-llm-per-conv-concurrency', 2)
|
|
await aiApi.setConcurrencyConfig(Math.max(1, globalConv ?? 3), Math.max(1, perConvConv ?? 2))
|
|
} catch (e) {
|
|
console.error('恢复 Agentic 轮次/重试/并发配置失败:', e)
|
|
}
|
|
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()
|
|
// AR-11:数据变更联动监听(AI 工具 create/update/delete 后端 emit df-data-changed → store listen 刷新对应列表,全局挂载因用户可在任意页)
|
|
// CR-260615-21:包 try/catch 防 Tauri listen 失败 reject onMounted 致 AR-11 静默失效
|
|
try {
|
|
await projectStore.startDataChangedListener()
|
|
} catch (e) {
|
|
console.error('启动数据变更监听失败:', e)
|
|
}
|
|
})
|
|
|
|
/// 一次性迁移:遍历遗留的 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)
|
|
}
|
|
}
|
|
|
|
// 知识库 store(侧栏 badge 计数在 AppSidebar 内消费,此处仅确保 store 实例化供 onMounted loadCandidates)
|
|
const knowledgeStore = useKnowledgeStore()
|
|
|
|
onUnmounted(() => {
|
|
window.removeEventListener('keydown', handleKeydown)
|
|
projectStore.stopDataChangedListener?.()
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* — Page Transition — */
|
|
.page-enter-active { transition: opacity 0.2s var(--df-ease), transform 0.2s var(--df-ease); }
|
|
.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>
|