Files
DevFlow/src/views/Settings.vue
T
lxy f736f435bc 优化: 所有剩余UI/UX待办一批完成(持久化+AuditLog+解耦+total+原12大改+P2)
持久化(P1-c):新建 usePersistedRef composable,Tasks/AuditLog/ProjectDetail 等接入 localStorage

AuditLog(P1-d):后端 list_tool_executions 加 WHERE 筛选+返 {items,total,has_more},前端对接+长列折叠+筛选持久化

数据源解耦(P1-g):ProjectDetail projectTasks 按 project_id 独立加载 + ChatInput @项目联想独立加载(不读 store.tasks 当前页)

GitChanges(12a):后端 get_module_commits 加 git rev-list --count 返 total,前端显真实总数

原12大改:Dashboard 统计卡压底行(1)/Projects 列表卡片视图(2)/project_event 埋点排序(3)/TaskDetail 重设计(4)/IdeaDetail 重设计(5)/KnowledgeDetail 重设计(6)/界面持久化+侧栏Ctrl+B+审批数字键(7)/ProjectDetail 三栏改两栏(10)

P2打磨:文件浏览器(FileTree去重/FilePreview行号.md Diff/selectedFilePath归位)/settings反馈(假保存/端口校验)/AI会话(try-catch/scrollIntoView)/后端计数(move_queue事件/timeline total/workflow分页/import_batch分块)/杂项(TopBar/ConfirmDialog键盘/CIStatus i18n/ToolResultBody/ModuleNode/ApprovalDialog全选)
2026-08-02 13:11:06 +08:00

288 lines
12 KiB
Vue

<template>
<div class="settings">
<Transition name="toast">
<div v-if="toast.visible" class="toast" :class="'toast-' + toast.type">{{ toast.msg }}</div>
</Transition>
<ConfirmDialog :visible="confirmState.visible" :msg="confirmState.msg" :danger-label="$t('common.delete')" @result="answerConfirm" />
<!-- 页面头部 -->
<header class="page-header">
<h1>{{ $t('settings.title') }}</h1>
</header>
<!-- ═══ master-detail 布局(阶段3 UX 重构)═══
SettingsNav(~200px 固定):6 类导航,activeCategory 受控 + appSettings 持久化
content(scroll): activeCategory 渲染对应 Section/Panel -->
<div class="settings-body">
<SettingsNav
v-model="activeCategory"
:horizontal="isNarrowScreen"
@scroll-target="scrollToSettingItem"
/>
<!-- 导入/导出已移除:实测价值低且占用 tab 底部空间,如需跨设备同步建议直接复制 SQLite 文件 -->
<div ref="contentRef" class="settings-content">
<!-- 外观:theme/language/aiLanguage/showTokenUsage -->
<AppearanceSection v-if="activeCategory === 'appearance'" />
<!-- AI 模型:provider 列表 + 负载均衡池 + 模型拉取 -->
<ProviderPanel
v-else-if="activeCategory === 'ai-model'"
ref="providerPanelRef"
:confirm-dialog="confirmDialog"
@toast="showToast"
@providers-changed="onProvidersChanged"
/>
<!-- 知识库:auto_extract/trigger_mode/embedding_* -->
<KnowledgePanel
v-else-if="activeCategory === 'knowledge'"
:ai-providers="aiProviders"
/>
<!-- 性能:global/per-conv concurrency/max-iterations/max-retries -->
<PerformanceSection v-else-if="activeCategory === 'performance'" />
<!-- 安全:allowed_dirs -->
<AllowedDirsPanel
v-else-if="activeCategory === 'security'"
@toast="showToast"
/>
<!-- 高级:autoExecuteMode/logLevel + 连接(暂留待定) -->
<template v-else-if="activeCategory === 'advanced'">
<AdvancedSection />
<ConnectionPanel
ref="connectionPanelRef"
:confirm-dialog="confirmDialog"
@toast="showToast"
/>
</template>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, shallowRef, onMounted, onUnmounted, nextTick } from 'vue'
import { useConfirm } from '@/composables/useConfirm'
import { useToast } from '@/composables/useToast'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import SettingsNav, { type SettingsCategory } from '@/components/settings/SettingsNav.vue'
import ProviderPanel from '@/components/settings/ProviderPanel.vue'
import ConnectionPanel from '@/components/settings/ConnectionPanel.vue'
import AppearanceSection from '@/components/settings/AppearanceSection.vue'
import PerformanceSection from '@/components/settings/PerformanceSection.vue'
import AdvancedSection from '@/components/settings/AdvancedSection.vue'
import KnowledgePanel from '@/components/settings/KnowledgePanel.vue'
import AllowedDirsPanel from '@/components/settings/AllowedDirsPanel.vue'
// SettingsImportExport 已移除(价值低,占用 UI 空间;跨设备同步建议直接复制 SQLite)
import { useAppSettingsStore } from '@/stores/appSettings'
import { aiApi } from '@/api'
import type { AiProviderConfig } from '@/api/types'
import { SETTINGS_INDEX } from '@/components/settings/searchIndex'
import { t as ti18n } from '@/i18n/i18n-helpers'
// ============================================================
// Settings 壳 — toast/confirm 共享 UI + master-detail 布局组合各 Section/Panel
// (阶段3 UX 重构:左导航 + 右按 activeCategory 渲染,各功能域 state/methods 已拆到子组件)
// ============================================================
// 顶部轻量提示(useToast composable,消除 4 处重复;默认 3000ms)
const { toast, showToast } = useToast()
// 确认弹层状态机抽至 composables/useConfirm(原 4 视图重复:Projects/ProjectDetail/Ideas/Settings)
// 模板 confirmState.visible/msg 在 <script setup> 中自动解包 ref,沿用既有内联确认弹层
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
// 子面板 ref(挂载时触发各自 load,卸载时清子面板 timer)
const providerPanelRef = shallowRef<InstanceType<typeof ProviderPanel> | null>(null)
const connectionPanelRef = shallowRef<InstanceType<typeof ConnectionPanel> | null>(null)
// ============================================================
// master-detail:activeCategory 持久化(appSettings,默认 appearance)
// 用 computed 包 useSetting,双向绑 SettingsNav v-model
// ============================================================
const appSettings = useAppSettingsStore()
const activeCategory = computed<SettingsCategory>({
get: () => appSettings.get<SettingsCategory>('df-settings-active-category', 'appearance'),
set: (v) => { void appSettings.set('df-settings-active-category', v) },
})
// 窄屏响应式:监听窗口宽度,nav 折叠为横向 tab(避免依赖仅 CSS 的 :has,
// 并让 SettingsNav 按横向模式渲染 list)
const isNarrowScreen = ref(false)
function syncNarrow() {
isNarrowScreen.value = window.innerWidth <= 768
}
let _resizeTimer: ReturnType<typeof setTimeout> | null = null
function onResize() {
if (_resizeTimer) clearTimeout(_resizeTimer)
_resizeTimer = setTimeout(syncNarrow, 100)
}
// ============================================================
// 设置搜索滚动定位(阶段4)
// ------------------------------------------------------------
// SettingsNav 搜索命中首匹配项时上抛 scroll-target(key)。
// 此处据 key 查 SETTINGS_INDEX 取 labelKey → t() 得当前 locale 文本,
// 在右侧内容 DOM 中找匹配的 .setting-label(表单型 Section)或
// .panel-header h2(列表型面板如 ProviderPanel),scrollIntoView 平滑定位。
//
// 匹配策略(2026-08-02 加固):
// 旧实现用 textContent.trim() === labelText 严格相等,SettingRow 的 .setting-label
// 常带 next-round-badge 子 span(「下次对话生效」) → textContent 拼接出
// 「主题 下次对话生效」≠「主题」,严格相等失效,搜索定位静默无命中。
// 现改读 .setting-label 的"首子文本节点"(纯 label,排除 badge/desc 等子元素),
// 与 labelText trim 后 includes 匹配(双向 includes 兜底首尾空白/标点)。
// h2 同理改 includes 提升容错。理想方案是 .setting-label 加 data-setting-key,
// 但那需改 SettingRow.vue + 6 个 Section(越"仅改指定文件"范围),故此处加固文本匹配。
// ============================================================
const contentRef = ref<HTMLElement | null>(null)
/** 取元素"首文本节点"内容(跳过子元素,排除 SettingRow 的 next-round-badge 拼接干扰) */
function firstTextNodeText(el: HTMLElement | null): string {
if (!el) return ''
// 优先 childNodes 中的首个 text node;退而 textContent(无子元素场景)
for (const node of Array.from(el.childNodes)) {
if (node.nodeType === Node.TEXT_NODE) {
return node.textContent || ''
}
}
return el.textContent || ''
}
/** 双向 includes 匹配(容错首尾空白/标点差异;空串不命中) */
function fuzzyMatch(a: string, b: string): boolean {
const sa = a.trim()
const sb = b.trim()
if (!sa || !sb) return false
return sa.includes(sb) || sb.includes(sa)
}
function scrollToSettingItem(itemKey: string) {
const entry = SETTINGS_INDEX.find((e) => e.key === itemKey)
if (!entry) return
// 用 i18n-helpers 的 t(规避 vue-i18n 动态 key 的 TS2589,见 i18n-helpers.ts 注释)
const labelText = ti18n(entry.labelKey)
// 等右侧对应 Section v-if 切换后挂载(nextTick);再用二次 rAF 确保布局就绪
void nextTick(() => {
requestAnimationFrame(() => {
const root = contentRef.value
if (!root) return
// 优先 .setting-label 文本(表单型);退而求 .panel-header h2(面板标题)
const labels = root.querySelectorAll<HTMLElement>('.setting-label')
let target: HTMLElement | null = null
for (const el of labels) {
if (fuzzyMatch(firstTextNodeText(el), labelText)) {
target = el.closest<HTMLElement>('.setting-row') ?? el
break
}
}
if (!target) {
const headers = root.querySelectorAll<HTMLElement>('.panel-header h2')
for (const el of headers) {
if (fuzzyMatch(firstTextNodeText(el), labelText)) {
target = el
break
}
}
}
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
})
})
}
// ============================================================
// KnowledgePanel embedding provider 下拉依赖 provider 列表。
// 阶段3 改 master-detail 后 ProviderPanel 仅在 ai-model 类挂载,
// knowledge 类切到时 ProviderPanel 可能未挂载 → 此处独立拉一次 providers
// 保证 KnowledgePanel 始终拿到 embedding provider 候选(与 ProviderPanel 自身
// onMounted loadProviders 互补,后者仅刷新 UI 列表 + aiStore)。
// ============================================================
const aiProviders = ref<AiProviderConfig[]>([])
async function loadAiProviders() {
try {
aiProviders.value = await aiApi.listProviders()
} catch (e) {
console.error('加载 AI 提供商失败:', e)
}
}
function onProvidersChanged() {
// ProviderPanel 自己 reload 后上抛,优先取其内存副本(最新),失败回退此处独立拉
aiProviders.value = providerPanelRef.value?.aiProviders ?? aiProviders.value
}
onMounted(() => {
syncNarrow()
window.addEventListener('resize', onResize)
// provider 列表预拉(knowledge 类依赖);connection 列表 advanced 类挂载时自载
void loadAiProviders()
})
// 组件卸载清 resize timer;toast 清理由 useToast 自动管理;各子面板自管自身 debounce timer
onUnmounted(() => {
if (_resizeTimer) clearTimeout(_resizeTimer)
window.removeEventListener('resize', onResize)
providerPanelRef.value?.clearPoolTimer()
})
</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); }
/* P0-2: 保存成功明确反馈(绿色),与 info(中性强调)区分 */
.toast-success { background: var(--df-success-bg); color: var(--df-success); border: 0.5px solid var(--df-success); }
.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); }
/* 确认弹层已替换为 ConfirmDialog 组件(不再需要自建 CSS) */
/* page-header / btn 等已提取到 global.css 全局 */
/* ===== master-detail 布局(阶段3 UX 重构) =====
左 SettingsNav(~200px 固定)+ 右 content(flex 占满 + 纵向滚动)。
nav 宽度/折叠由 SettingsNav scoped 控制;此处仅布局骨架。 */
.settings-body {
display: flex;
gap: 20px;
align-items: flex-start;
}
.settings-content {
flex: 1;
min-width: 0; /* 允许收缩,防子面板溢出撑破 flex */
display: flex;
flex-direction: column;
gap: var(--df-gap-grid);
}
/* .btn / .btn-primary / .btn-ghost / .btn-sm 已迁移到 settings.css 全局,
本壳的 toast/confirm 内联按钮沿用全局基础样式;此处仅保留全局未定义的 .btn-danger。 */
/* ===== 窄屏响应式:nav 折叠为顶部横向 tab(由 SettingsNav is-horizontal 渲染) ===== */
@media (max-width: 768px) {
.settings-body {
flex-direction: column;
gap: 12px;
}
}
</style>