新增: 设置页 UX 重构(master-detail 左导航 + 搜索 + 即时反馈 + 导入导出)

- SettingsNav 左导航 7 类 + 顶部搜索(索引匹配 label/desc 高亮定位)
- GeneralPanel 拆分 Appearance/Performance/Advanced Section
- SettingRow 统一行 + 即时反馈(已保存)+ 下轮生效 badge
- 统一组件库 settings.css(scoped 重复 class 抽全局)
- 导入导出(默认排除 keyring 密钥)
- activeCategory 持久化 + 窄屏折叠 tab
This commit is contained in:
2026-06-21 20:50:44 +08:00
parent 4b5da38e28
commit 330bb7f505
17 changed files with 1539 additions and 606 deletions

View File

@@ -19,47 +19,84 @@
<h1>{{ $t('settings.title') }}</h1>
</header>
<div class="settings-grid">
<!-- AI 提供商(列表 + 表单)+ F-04c 负载均衡池 -->
<ProviderPanel
ref="providerPanelRef"
:confirm-dialog="confirmDialog"
@toast="showToast"
@providers-changed="onProvidersChanged"
/>
<!-- 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"
>
<!-- 底部导入/导出(阶段6):窄屏横向模式不显,避免横向 tab 拥挤 -->
<template v-if="!isNarrowScreen" #footer>
<SettingsImportExport :confirm-dialog="confirmDialog" :toast="showToast" />
</template>
</SettingsNav>
<!-- 连接管理(localStorage CRUD) -->
<ConnectionPanel
ref="connectionPanelRef"
:confirm-dialog="confirmDialog"
@toast="showToast"
/>
<div ref="contentRef" class="settings-content">
<!-- 外观:theme/language/aiLanguage/showTokenUsage -->
<AppearanceSection v-if="activeCategory === 'appearance'" />
<!-- 通用设置(主题/语言/并发/Agent 轮次 即时调 IPC) -->
<GeneralPanel />
<!-- AI 模型:provider 列表 + 负载均衡池 + 模型拉取 -->
<ProviderPanel
v-else-if="activeCategory === 'ai-model'"
ref="providerPanelRef"
:confirm-dialog="confirmDialog"
@toast="showToast"
@providers-changed="onProvidersChanged"
/>
<!-- 知识库配置(后端 IPC,embedding provider 依赖 ProviderPanel 列表) -->
<KnowledgePanel :ai-providers="aiProviders" />
<!-- 知识库:auto_extract/trigger_mode/embedding_* -->
<KnowledgePanel
v-else-if="activeCategory === 'knowledge'"
:ai-providers="aiProviders"
/>
<!-- F-260619-03 Phase A: AI 工具授权目录(白名单持久化) -->
<AllowedDirsPanel @toast="showToast" />
<!-- 性能:global/per-conv concurrency/max-iterations/max-retries -->
<PerformanceSection v-else-if="activeCategory === 'performance'" />
<!-- 安全:allowed_dirs -->
<AllowedDirsPanel
v-else-if="activeCategory === 'security'"
@toast="showToast"
/>
<!-- 高级:autoExecute/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 { reactive, ref, shallowRef, onMounted, onUnmounted } from 'vue'
import { computed, reactive, ref, shallowRef, onMounted, onUnmounted, nextTick } from 'vue'
import { useConfirm } from '@/composables/useConfirm'
import SettingsNav, { type SettingsCategory } from '@/components/settings/SettingsNav.vue'
import ProviderPanel from '@/components/settings/ProviderPanel.vue'
import ConnectionPanel from '@/components/settings/ConnectionPanel.vue'
import GeneralPanel from '@/components/settings/GeneralPanel.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'
import SettingsImportExport from '@/components/settings/SettingsImportExport.vue'
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 + 组合 4 子面板
// (纯重构零功能:各功能域 state/methods/CSS 已拆到子组件)
// Settings 壳 — toast/confirm 共享 UI + master-detail 布局组合各 Section/Panel
// (阶段3 UX 重构:左导航 + 右按 activeCategory 渲染,各功能域 state/methods 已拆到子组件)
// ============================================================
// 顶部轻量提示(错误/警告/完成3s 自动消失;零依赖,替代未接入的 Arco Message
@@ -82,22 +119,108 @@ const { confirmState, confirmDialog, answerConfirm } = useConfirm()
const providerPanelRef = shallowRef<InstanceType<typeof ProviderPanel> | null>(null)
const connectionPanelRef = shallowRef<InstanceType<typeof ConnectionPanel> | null>(null)
// KnowledgePanel embedding provider 下拉依赖 ProviderPanel 的 aiProviders
// ProviderPanel loadProviders 后上抛 providers-changed,此处同步本地副本
// ============================================================
// 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 平滑定位。
// 用文本匹配而非 data-* 属性:避免改动各 Section(严守"仅改 plan 列出文件")。
// ============================================================
const contentRef = ref<HTMLElement | null>(null)
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 (el.textContent?.trim() === labelText.trim()) {
target = el.closest<HTMLElement>('.setting-row') ?? el
break
}
}
if (!target) {
const headers = root.querySelectorAll<HTMLElement>('.panel-header h2')
for (const el of headers) {
if (el.textContent?.trim() === labelText.trim()) {
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() {
aiProviders.value = providerPanelRef.value?.aiProviders ?? []
// ProviderPanel 自己 reload 后上抛,优先取其内存副本(最新),失败回退此处独立拉
aiProviders.value = providerPanelRef.value?.aiProviders ?? aiProviders.value
}
onMounted(() => {
providerPanelRef.value?.loadProviders()
connectionPanelRef.value?.loadConnections()
syncNarrow()
window.addEventListener('resize', onResize)
// provider 列表预拉(knowledge 类依赖);connection 列表 advanced 类挂载时自载
void loadAiProviders()
})
// 组件卸载清 toast timer;各子面板自管自身 debounce timer(各自 onUnmounted)
// 组件卸载清 toast/resize timer;各子面板自管自身 debounce timer(各自 onUnmounted)
onUnmounted(() => {
if (_toastTimer) clearTimeout(_toastTimer)
if (_resizeTimer) clearTimeout(_resizeTimer)
window.removeEventListener('resize', onResize)
providerPanelRef.value?.clearPoolTimer()
})
</script>
@@ -157,30 +280,30 @@ onUnmounted(() => {
}
.page-header h1 { font-size: 24px; font-weight: 500; color: var(--df-text); }
/* ===== 按钮 ===== */
.btn {
padding: 8px 16px;
border: none;
border-radius: var(--df-radius-sm);
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
/* ===== master-detail 布局(阶段3 UX 重构) =====
左 SettingsNav(~200px 固定)+ 右 content(flex 占满 + 纵向滚动)。
nav 宽度/折叠由 SettingsNav scoped 控制;此处仅布局骨架。 */
.settings-body {
display: flex;
gap: 20px;
align-items: flex-start;
}
.btn-primary { background: var(--df-accent); color: #fff; }
.btn-primary:hover { background: var(--df-accent-hover); }
.btn-primary:disabled { opacity: 0.5; 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); }
.btn-sm { padding: 4px 12px; font-size: 12px; }
/* ===== 设置网格 ===== */
.settings-grid {
.settings-content {
flex: 1;
min-width: 0; /* 允许收缩,防子面板溢出撑破 flex */
display: flex;
flex-direction: column;
gap: var(--df-gap-grid);
}
/* 注:.panel / .panel-header / .empty-hint 等子组件内元素的样式
已随功能域拆到各子组件 scoped(父 scoped 无法穿透命中子组件根 section),
各子组件自带面板容器 + 按钮基础样式副本,确保零视觉变化。 */
/* .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>