Files
DevFlow/src/App.vue
绝尘 cf017f81e2 新增: 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/儿童每日打卡应用/ 与本项目无关,已排除。
2026-06-14 14:08:20 +08:00

524 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<!-- 分离窗口纯内容不渲染主布局 -->
<div v-if="isDetached" class="df-detached-root">
<router-view v-slot="{ Component }">
<component :is="Component" />
</router-view>
</div>
<!-- 主窗口完整布局 -->
<div v-else class="df-shell">
<!-- Sidebar -->
<aside class="df-sidebar">
<!-- Logo -->
<div class="sidebar-brand">
<div class="brand-mark">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" fill="url(#brand-grad)" />
<defs><linearGradient id="brand-grad" x1="3" y1="2" x2="21" y2="22">
<stop stop-color="#7b6ff0"/><stop offset="1" stop-color="#5eaff0"/>
</linearGradient></defs>
</svg>
</div>
<span class="brand-text">DevFlow</span>
<span class="brand-version">v0.1</span>
</div>
<!-- Navigation -->
<nav class="sidebar-nav">
<div class="nav-group">
<span class="nav-group-label">{{ $t('nav.workspace') }}</span>
<router-link
v-for="item in primaryNav" :key="item.path"
:to="item.path"
class="nav-link"
active-class="nav-link--active"
>
<span class="nav-link-icon" v-html="item.svg"></span>
<span class="nav-link-text">{{ $t(item.label) }}</span>
<span v-if="item.badge" class="nav-link-badge">{{ item.badge }}</span>
</router-link>
</div>
<div class="nav-divider"></div>
<div class="nav-group">
<span class="nav-group-label">{{ $t('nav.traceability') }}</span>
<router-link
v-for="item in secondaryNav" :key="item.path"
:to="item.path"
class="nav-link"
active-class="nav-link--active"
>
<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>
<!-- Footer -->
<div class="sidebar-footer">
<div class="sidebar-status">
<span class="status-dot"></span>
<span class="status-text">{{ $t('nav.systemReady') }}</span>
</div>
<!-- AI 面板切换按钮 -->
<button class="nav-link ai-toggle-btn" :class="{ 'ai-toggle-btn--active': aiStore.state.panelOpen }" @click="aiStore.togglePanel()">
<span class="nav-link-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a7 7 0 014 12.74V17a1 1 0 01-1 1H9a1 1 0 01-1-1v-2.26A7 7 0 0112 2z"/><line x1="9" y1="21" x2="15" y2="21"/></svg>
</span>
<span class="nav-link-text">{{ $t('nav.aiPanel') }}</span>
<span class="ai-toggle-shortcut">I</span>
</button>
<router-link to="/settings" class="nav-link" active-class="nav-link--active">
<span class="nav-link-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
</span>
<span class="nav-link-text">{{ $t('nav.settings') }}</span>
</router-link>
</div>
</aside>
<!-- Main (页面内容) -->
<!-- 仅在 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" />
</transition>
</router-view>
</main>
<!-- AI Chat Panel -->
<transition name="ai-slide">
<div v-if="aiStore.state.panelOpen && !aiStore.state.detached" class="df-ai-panel" :style="panelStyle">
<div v-show="!aiStore.state.maximized" class="ai-resize-handle" :class="{ active: resizing }" @mousedown="startResize"></div>
<AiChat />
</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) => {
if (!['/', '/ai-home', '/ai-detached'].includes(path) && aiStore.state.maximized) {
aiStore.state.maximized = false
}
})
// ── AI 面板拖拽调宽 ──
// useSetting 监听 appSettings 缓存:loadAll 异步填充后会自动刷新 ref
const panelWidth = appSettings.useSetting<number>('df-ai-width', 500)
const resizing = ref(false)
// maximized=全宽(flex:1填充) | sidebar=固定宽
const panelStyle = computed(() => {
if (aiStore.state.maximized) return { flex: '1', minWidth: '0' }
return {
width: panelWidth.value + 'px',
minWidth: panelWidth.value + 'px',
maxWidth: panelWidth.value + 'px',
}
})
function startResize(e: MouseEvent) {
e.preventDefault()
resizing.value = true
const startX = e.clientX
const startWidth = panelWidth.value
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
function onMouseMove(ev: MouseEvent) {
const delta = startX - ev.clientX // 向左拖 = 增宽
const newWidth = Math.max(320, Math.min(800, startWidth + delta))
panelWidth.value = newWidth
}
function onMouseUp() {
resizing.value = false
document.body.style.cursor = ''
document.body.style.userSelect = ''
void appSettings.set('df-ai-width', panelWidth.value)
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
}
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}
// 主题初始化
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()
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)
})
const primaryNav = [
{ path: '/dashboard', label: 'nav.overview', badge: null,
svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>' },
{ path: '/ideas', label: 'nav.ideas', badge: null,
svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6M10 22h4M12 2a7 7 0 014 12.74V17a1 1 0 01-1 1h-6a1 1 0 01-1-1v-2.26A7 7 0 0112 2z"/></svg>' },
{ path: '/projects', label: 'nav.projects', badge: null,
svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>' },
{ path: '/tasks', label: 'nav.tasks', badge: null,
svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>' },
]
const secondaryNav = [
{ path: '/knowledge', label: 'nav.knowledge',
svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg>' },
{ path: '/decisions', label: 'nav.decisions',
svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="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>' },
]
</script>
<style scoped>
.df-detached-root { width: 100vw; height: 100vh; overflow: hidden; background: var(--df-bg); }
.df-shell { display: flex; height: 100vh; width: 100vw; overflow: hidden; }
/* ═══ Sidebar ═══ */
.df-sidebar {
width: 192px;
min-width: 192px;
background: var(--df-sidebar-bg);
border-right: 0.5px solid var(--df-border);
display: flex;
flex-direction: column;
position: relative;
user-select: none;
}
/* — Logo — */
.sidebar-brand {
display: flex;
align-items: center;
gap: 8px;
padding: 14px 16px 12px;
border-bottom: 0.5px solid var(--df-border);
}
.brand-mark {
width: 28px; height: 28px;
display: flex; align-items: center; justify-content: center;
background: var(--df-accent-bg);
border: 0.5px solid var(--df-border);
}
.brand-text {
font-size: 15px;
font-weight: 500;
letter-spacing: -0.3px;
color: var(--df-text);
}
.brand-version {
font-family: var(--df-font-mono);
font-size: 10px;
color: var(--df-text-dim);
background: rgba(255,255,255,0.04);
padding: 1px 6px;
border-radius: var(--df-radius-xs);
margin-left: auto;
}
/* — Nav — */
.sidebar-nav { flex: 1; overflow-y: auto; padding: 8px 8px; }
.nav-group { margin-bottom: 2px; }
.nav-group-label {
display: block;
padding: 8px 10px 4px;
font-size: 9px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 1.2px;
color: var(--df-text-dim);
}
.nav-divider {
height: 0.5px;
background: var(--df-border);
margin: 6px 12px;
}
.nav-link {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 10px;
margin: 1px 0;
border-radius: var(--df-radius-sm);
color: var(--df-text-secondary);
text-decoration: none;
font-size: 12px;
font-weight: 450;
transition: all 0.18s var(--df-ease);
position: relative;
}
.nav-link:hover {
background: var(--df-sidebar-hover);
color: var(--df-text);
}
.nav-link--active {
background: var(--df-sidebar-active);
color: var(--df-accent);
font-weight: 500;
}
.nav-link--active::before {
content: '';
position: absolute;
left: -10px;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 16px;
background: var(--df-accent);
border-radius: 0 3px 3px 0;
}
.nav-link-icon {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
opacity: 0.7;
flex-shrink: 0;
}
.nav-link--active .nav-link-icon { opacity: 1; }
.nav-link-text { flex: 1; }
.nav-link-badge {
font-family: var(--df-font-mono);
font-size: 10px;
font-weight: 500;
padding: 1px 6px;
border-radius: var(--df-radius);
min-width: 18px;
text-align: center;
background: var(--df-accent);
color: #fff;
line-height: 1.4;
}
/* — AI Toggle Button — */
.ai-toggle-btn {
cursor: pointer;
border: none;
background: transparent;
width: 100%;
font: inherit;
text-align: left;
appearance: none;
-webkit-appearance: none;
}
.ai-toggle-btn--active {
color: var(--df-accent);
background: var(--df-sidebar-active);
}
.ai-toggle-shortcut {
font-family: var(--df-font-mono);
font-size: 9px;
color: var(--df-text-dim);
background: rgba(255,255,255,0.04);
padding: 1px 5px;
border-radius: var(--df-radius-sm);
}
/* — Footer — */
.sidebar-footer {
padding: 6px 8px;
border-top: 0.5px solid var(--df-border);
}
.sidebar-status {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
margin-bottom: 1px;
}
.status-dot {
width: 6px; height: 6px;
border-radius: 50%;
background: var(--df-success);
animation: pulseGlow 3s ease-in-out infinite;
}
.status-text {
font-family: var(--df-font-mono);
font-size: 10px;
color: var(--df-text-dim);
letter-spacing: 0.3px;
}
/* — Main — */
.df-main {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
background: var(--df-bg);
min-width: 0;
}
/* — AI Panel (全宽) — */
.df-ai-panel {
position: relative;
height: 100vh;
overflow: hidden;
}
/* — AI Resize Handle — */
.ai-resize-handle {
position: absolute;
left: 0; top: 0; bottom: 0;
width: 4px;
cursor: col-resize;
z-index: 10;
transition: background 0.15s;
}
.ai-resize-handle:hover,
.ai-resize-handle.active {
background: var(--df-accent);
opacity: 0.4;
}
/* — AI Slide Transition — */
.ai-slide-enter-active { transition: opacity 0.2s var(--df-ease); }
.ai-slide-leave-active { transition: opacity 0.15s var(--df-ease); }
.ai-slide-enter-from, .ai-slide-leave-to {
opacity: 0;
}
/* ═══ Responsive ═══ */
@media (max-width: 768px) {
.df-sidebar { width: 56px; min-width: 56px; }
.brand-text, .brand-version, .nav-group-label, .nav-link-text,
.nav-link-badge, .sidebar-status, .status-text, .ai-toggle-shortcut { display: none; }
.sidebar-brand { padding: 12px 10px 10px; justify-content: center; }
.brand-mark { width: 32px; height: 32px; }
.nav-link { justify-content: center; padding: 8px 0; }
.nav-divider { margin: 4px 8px; }
.nav-link--active::before { display: none; }
.df-ai-panel { width: 100vw; min-width: 100vw; max-width: 100vw; position: absolute; right: 0; top: 0; z-index: 100; }
}
/* — 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>