Files
DevFlow/src/views/Settings.vue

310 lines
12 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 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>{{ $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"
>
<!-- 底部导入/导出(阶段6):窄屏横向模式不显,避免横向 tab 拥挤 -->
<template v-if="!isNarrowScreen" #footer>
<SettingsImportExport :confirm-dialog="confirmDialog" :toast="showToast" />
</template>
</SettingsNav>
<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, 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 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 + master-detail 布局组合各 Section/Panel
// (阶段3 UX 重构:左导航 + 右按 activeCategory 渲染,各功能域 state/methods 已拆到子组件)
// ============================================================
// 顶部轻量提示(错误/警告/完成3s 自动消失;零依赖,替代未接入的 Arco Message
const toast = reactive({ visible: false, msg: '', type: 'info' as 'error' | 'warning' | 'info' })
let _toastTimer: ReturnType<typeof setTimeout> | null = null
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)
}
// 确认弹层状态机抽至 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 平滑定位。
// 用文本匹配而非 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() {
// ProviderPanel 自己 reload 后上抛,优先取其内存副本(最新),失败回退此处独立拉
aiProviders.value = providerPanelRef.value?.aiProviders ?? aiProviders.value
}
onMounted(() => {
syncNarrow()
window.addEventListener('resize', onResize)
// provider 列表预拉(knowledge 类依赖);connection 列表 advanced 类挂载时自载
void loadAiProviders()
})
// 组件卸载清 toast/resize timer;各子面板自管自身 debounce timer(各自 onUnmounted)
onUnmounted(() => {
if (_toastTimer) clearTimeout(_toastTimer)
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); }
.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;
align-items: center;
margin-bottom: var(--df-gap-page);
}
.page-header h1 { font-size: 24px; font-weight: 500; color: var(--df-text); }
/* ===== 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>