新增: 设置页 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

@@ -0,0 +1,54 @@
<template>
<!-- 高级设置(AI 自动执行 / 日志级别) -->
<section class="panel">
<div class="panel-header">
<h2>{{ $t('settings.sectionAdvanced') }}</h2>
</div>
<div class="general-settings">
<SettingRow ref="rowAutoExecute" :label="$t('settings.labelAutoExecute')" :desc="$t('settings.descAutoExecute')">
<label class="toggle">
<input type="checkbox" v-model="settings.autoExecute" @change="markSaved('autoExecute')" />
<span class="toggle-slider"></span>
</label>
</SettingRow>
<SettingRow ref="rowLogLevel" :label="$t('settings.labelLogLevel')" :desc="$t('settings.descLogLevel')">
<select v-model="settings.logLevel" class="setting-select" @change="markSaved('logLevel')">
<option value="error">Error</option>
<option value="warn">Warning</option>
<option value="info">Info</option>
<option value="debug">Debug</option>
</select>
</SettingRow>
</div>
</section>
</template>
<script setup lang="ts">
import { reactive, useTemplateRef } from 'vue'
import SettingRow from './SettingRow.vue'
// ============================================================
// 高级域 — AI 自动执行开关 / 日志级别
// 拆自 GeneralPanel,忠实迁移原逻辑:原 GeneralPanel 这两项为 session 内 reactive
// (无 appSettings 持久化/watch),此处保持一致(无 watch/IPC),避免阶段2 引入行为变更。
// 阶段5:SettingRow 封装行布局,控件变更后调 markSaved 显"已保存"
// 后续若需持久化,单独建改进项。
// ============================================================
const settings = reactive({
autoExecute: false,
logLevel: 'info',
})
const rowAutoExecute = useTemplateRef<InstanceType<typeof SettingRow>>('rowAutoExecute')
const rowLogLevel = useTemplateRef<InstanceType<typeof SettingRow>>('rowLogLevel')
function markSaved(key: 'autoExecute' | 'logLevel') {
const map = { autoExecute: rowAutoExecute.value, logLevel: rowLogLevel.value }
map[key]?.markSaved()
}
</script>
<style scoped>
/* 阶段5 UX 重构:复用 settings.css 全局 .panel/.panel-header/.general-settings/
.setting-select/.toggle/.toggle-slider + SettingRow(封装 .setting-row/.setting-info/
.setting-label/.setting-desc/.setting-control)。本 Section 无特有 scoped 样式。 */
</style>

View File

@@ -142,20 +142,10 @@ onMounted(load)
</script>
<style scoped>
.panel {
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg);
padding: var(--df-pad-panel);
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--df-gap-head);
}
.panel-header h2 { font-size: 15px; font-weight: 500; }
/* 阶段1/3 UX 重构:panel/panel-header/empty-hint/btn 系列/setting-select/
已迁移到 settings.css 全局。
本 scoped 仅保留 AllowedDirsPanel 特有样式:allowed-dirs/dir-row/dir-path/
add-row/dir-input/actions。 */
.allowed-dirs { display: flex; flex-direction: column; gap: 8px; }
.allowed-dirs-desc {
font-size: 12px; color: var(--df-text-dim); margin: 0 0 4px; line-height: 1.5;
@@ -172,26 +162,7 @@ onMounted(load)
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
flex: 1; margin-right: 12px;
}
.empty-hint { font-size: 12px; color: var(--df-text-dim); padding: 8px 0; }
.add-row { display: flex; gap: 8px; margin-top: 4px; }
.dir-input { flex: 1; min-width: 0; }
.actions { display: flex; gap: 8px; margin-top: 8px; }
.btn {
padding: 8px 16px; border: none; border-radius: var(--df-radius-sm);
font-size: 13px; cursor: pointer; transition: all 0.15s;
}
.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-ghost:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-sm { padding: 4px 12px; font-size: 12px; }
.setting-select {
padding: 6px 12px; background: var(--df-bg); border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm); color: var(--df-text); font-size: 13px; outline: none;
}
.setting-select:focus { border-color: var(--df-accent); }
</style>

View File

@@ -0,0 +1,122 @@
<template>
<!-- 外观设置(主题 / 语言 / AI 语言 / Token 用量) -->
<section class="panel">
<div class="panel-header">
<h2>{{ $t('settings.sectionAppearance') }}</h2>
</div>
<div class="general-settings">
<SettingRow ref="rowTheme" :label="$t('settings.labelTheme')" :desc="$t('settings.descTheme')">
<select v-model="settings.theme" class="setting-select" @change="markSaved('theme')">
<option value="dark">{{ $t('settings.themeDark') }}</option>
<option value="light">{{ $t('settings.themeLight') }}</option>
<option value="system">{{ $t('settings.themeSystem') }}</option>
</select>
</SettingRow>
<SettingRow ref="rowLanguage" :label="$t('settings.labelLanguage')" :desc="$t('settings.descLanguage')">
<select v-model="settings.language" class="setting-select" @change="markSaved('language')">
<option value="zh-CN">{{ $t('settings.aiLanguageZh') }}</option>
<option value="en">{{ $t('settings.aiLanguageEn') }}</option>
</select>
</SettingRow>
<SettingRow ref="rowAiLanguage" :label="$t('settings.labelAiLanguage')" :desc="$t('settings.descAiLanguage')">
<select v-model="settings.aiLanguage" class="setting-select" @change="markSaved('aiLanguage')">
<option value="auto">{{ $t('settings.aiLanguageAuto') }}</option>
<option value="zh-CN">{{ $t('settings.aiLanguageZh') }}</option>
<option value="en">{{ $t('settings.aiLanguageEn') }}</option>
</select>
</SettingRow>
<SettingRow ref="rowToken" :label="$t('settings.labelShowTokenUsage')" :desc="$t('settings.descShowTokenUsage')">
<label class="toggle">
<input type="checkbox" v-model="settings.showTokenUsage" @change="markSaved('token')" />
<span class="toggle-slider"></span>
</label>
</SettingRow>
<SettingRow ref="rowStreamingMd" :label="$t('settings.labelStreamingMd')" :desc="$t('settings.descStreamingMd')">
<label class="toggle">
<input type="checkbox" v-model="settings.streamingMd" @change="markSaved('streamingMd')" />
<span class="toggle-slider"></span>
</label>
</SettingRow>
</div>
</section>
</template>
<script setup lang="ts">
import { reactive, watch, onMounted, useTemplateRef } from 'vue'
import { useAppSettingsStore } from '@/stores/appSettings'
import i18n from '@/i18n'
import SettingRow from './SettingRow.vue'
// ============================================================
// 外观域 — 本地设置(localStorage):主题 / 界面语言 / AI 语言 / Token 用量显隐
// 拆自 GeneralPanel,本 Section 仅持有外观相关 key
// 阶段5:SettingRow 封装行布局,控件变更后调 markSaved 显"已保存"
// ============================================================
const appSettings = useAppSettingsStore()
const settings = reactive({
theme: appSettings.get<string>('df-theme', 'dark'),
language: appSettings.get<string>('df-language', 'zh-CN'),
aiLanguage: appSettings.get<string>('df-ai-language', 'auto'),
showTokenUsage: appSettings.get<boolean>('df-show-token-usage', false),
// AR-1 流式 Markdown 渲染开关:默认 true(流式过程渲染代码块/列表/标题格式);
// 关 → 回退纯文本(防掉帧/性能敏感场景降级)
streamingMd: appSettings.get<boolean>('df-ai-streaming-md', true),
})
function applyTheme(theme: string) {
if (theme === 'system') {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
document.documentElement.removeAttribute('data-theme')
if (!prefersDark) document.documentElement.setAttribute('data-theme', 'light')
} else if (theme === 'light') {
document.documentElement.setAttribute('data-theme', 'light')
} else {
document.documentElement.removeAttribute('data-theme')
}
}
// 即时生效反馈:SettingRow refs,key 映射对应 row
const rowTheme = useTemplateRef<InstanceType<typeof SettingRow>>('rowTheme')
const rowLanguage = useTemplateRef<InstanceType<typeof SettingRow>>('rowLanguage')
const rowAiLanguage = useTemplateRef<InstanceType<typeof SettingRow>>('rowAiLanguage')
const rowToken = useTemplateRef<InstanceType<typeof SettingRow>>('rowToken')
const rowStreamingMd = useTemplateRef<InstanceType<typeof SettingRow>>('rowStreamingMd')
function markSaved(key: 'theme' | 'language' | 'aiLanguage' | 'token' | 'streamingMd') {
const map = { theme: rowTheme.value, language: rowLanguage.value, aiLanguage: rowAiLanguage.value, token: rowToken.value, streamingMd: rowStreamingMd.value }
map[key]?.markSaved()
}
watch(() => settings.theme, (val) => {
applyTheme(val)
void appSettings.set('df-theme', val)
})
watch(() => settings.aiLanguage, (val) => {
void appSettings.set('df-ai-language', val)
})
watch(() => settings.showTokenUsage, (val) => {
void appSettings.set('df-show-token-usage', val)
})
watch(() => settings.streamingMd, (val) => {
void appSettings.set('df-ai-streaming-md', val)
})
watch(() => settings.language, (val) => {
void appSettings.set('df-language', val)
// 同步 i18n 实例,让界面立即跟随语言切换
i18n.global.locale.value = val as 'zh-CN' | 'en'
})
onMounted(() => {
applyTheme(settings.theme)
})
</script>
<style scoped>
/* 阶段5 UX 重构:复用 settings.css 全局 .panel/.panel-header/.general-settings/
.setting-select/.toggle/.toggle-slider + SettingRow(封装 .setting-row/.setting-info/
.setting-label/.setting-desc/.setting-control)。本 Section 无特有 scoped 样式。 */
</style>

View File

@@ -2,7 +2,10 @@
<!-- 连接管理 -->
<section class="panel">
<div class="panel-header">
<h2>{{ $t('settings.panelConnection') }}</h2>
<div class="panel-title-group">
<h2>{{ $t('settings.panelConnection') }}</h2>
<span class="experimental-badge">{{ $t('settings.experimentalBadge') }}</span>
</div>
<button class="btn btn-ghost btn-sm" @click="openConnForm()">{{ $t('settings.addConnection') }}</button>
</div>
<div class="connection-list" v-if="connections.length > 0">
@@ -67,7 +70,7 @@
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { reactive, ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppSettingsStore } from '@/stores/appSettings'
@@ -185,39 +188,39 @@ async function removeConn(id: string) {
emit('toast', t('settings.toastDeleted'), 'info')
}
/** 暴露给 shell onMounted 调用 */
/** 暴露给 shell(loadConnections 仍保留供外部主动刷新) */
defineExpose({ loadConnections })
// 阶段3 UX 重构:本面板改为 advanced 类懒挂载,不再由 Settings.vue onMounted 调用,
// 改为自身挂载时载入(localStorage 读,同步无网络)。
onMounted(loadConnections)
</script>
<style scoped>
/* ===== 面板容器(原 Settings.vue 抽出,各子组件自持 scoped 副本) ===== */
.panel {
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg);
padding: var(--df-pad-panel);
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--df-gap-head);
}
.panel-header h2 { font-size: 15px; font-weight: 500; }
.empty-hint { font-size: 13px; color: var(--df-text-dim); padding: 12px 0; }
/* 阶段1/3 UX 重构:panel/panel-header/empty-hint/btn 系列/setting-select/
已迁移到 settings.css 全局。
本 scoped 仅保留 ConnectionPanel 特有样式:connection-card/conn 系列/type 系列/form 系列/
setting-input/btn-link 等。 */
/* ===== 按钮 ===== */
.btn { padding: 8px 16px; border: none; border-radius: var(--df-radius-sm); font-size: 13px; cursor: pointer; transition: all 0.15s; }
.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; }
/* ===== 链接按钮(本面板特有,未进全局) ===== */
.btn-link { background: none; border: none; color: var(--df-accent); font-size: 12px; cursor: pointer; padding: 2px 6px; }
.btn-link:hover { text-decoration: underline; }
.btn-link-danger { color: var(--df-danger); }
/* ===== 标题组(panel-header 内 标题+badge 一组,与右侧按钮 space-between) ===== */
.panel-title-group { display: flex; align-items: center; gap: 8px; }
/* badge"实验功能·暂未接入 AI"(阶段7:warning 色,沿用 default-badge/pool-badge 模式) */
.experimental-badge {
font-size: 10px;
padding: 1px 6px;
border-radius: var(--df-radius-xs);
background: var(--df-warning-bg);
color: var(--df-warning);
font-weight: 500;
white-space: nowrap;
}
/* ===== 连接卡片 ===== */
.connection-list { display: flex; flex-direction: column; gap: 8px; }
.connection-card {
@@ -257,7 +260,7 @@ defineExpose({ loadConnections })
}
.conn-actions { display: flex; gap: 4px; }
/* 表单 */
/* 表单(本面板特有输入控件样式;.setting-select 走全局) */
.form-grid { display: flex; flex-direction: column; gap: 14px; }
.form-field { display: flex; flex-direction: column; gap: 4px; }
.form-label { font-size: 12px; font-weight: 500; color: var(--df-text-secondary); }
@@ -265,6 +268,4 @@ defineExpose({ loadConnections })
.setting-input { padding: 6px 12px; background: var(--df-bg); border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm); color: var(--df-text); font-size: 13px; outline: none; min-width: 260px; }
.setting-input:focus { border-color: var(--df-accent); }
.setting-select { padding: 6px 12px; background: var(--df-bg); border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm); color: var(--df-text); font-size: 13px; outline: none; cursor: pointer; min-width: 160px; }
.setting-select:focus { border-color: var(--df-accent); }
</style>

View File

@@ -1,325 +1,20 @@
<template>
<!-- 通用设置 -->
<section class="panel">
<div class="panel-header">
<h2>{{ $t('settings.panelGeneral') }}</h2>
</div>
<div class="general-settings">
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">{{ $t('settings.labelTheme') }}</span>
<span class="setting-desc">{{ $t('settings.descTheme') }}</span>
</div>
<div class="setting-control">
<select v-model="settings.theme" class="setting-select">
<option value="dark">{{ $t('settings.themeDark') }}</option>
<option value="light">{{ $t('settings.themeLight') }}</option>
<option value="system">{{ $t('settings.themeSystem') }}</option>
</select>
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">{{ $t('settings.labelLanguage') }}</span>
<span class="setting-desc">{{ $t('settings.descLanguage') }}</span>
</div>
<div class="setting-control">
<select v-model="settings.language" class="setting-select">
<option value="zh-CN">{{ $t('settings.aiLanguageZh') }}</option>
<option value="en">{{ $t('settings.aiLanguageEn') }}</option>
</select>
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">{{ $t('settings.labelAiLanguage') }}</span>
<span class="setting-desc">{{ $t('settings.descAiLanguage') }}</span>
</div>
<div class="setting-control">
<select v-model="settings.aiLanguage" class="setting-select">
<option value="auto">{{ $t('settings.aiLanguageAuto') }}</option>
<option value="zh-CN">{{ $t('settings.aiLanguageZh') }}</option>
<option value="en">{{ $t('settings.aiLanguageEn') }}</option>
</select>
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">{{ $t('settings.labelAutoExecute') }}</span>
<span class="setting-desc">{{ $t('settings.descAutoExecute') }}</span>
</div>
<div class="setting-control">
<label class="toggle">
<input type="checkbox" v-model="settings.autoExecute" />
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">{{ $t('settings.labelLogLevel') }}</span>
<span class="setting-desc">{{ $t('settings.descLogLevel') }}</span>
</div>
<div class="setting-control">
<select v-model="settings.logLevel" class="setting-select">
<option value="error">Error</option>
<option value="warn">Warning</option>
<option value="info">Info</option>
<option value="debug">Debug</option>
</select>
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">{{ $t('settings.labelShowTokenUsage') }}</span>
<span class="setting-desc">{{ $t('settings.descShowTokenUsage') }}</span>
</div>
<div class="setting-control">
<label class="toggle">
<input type="checkbox" v-model="settings.showTokenUsage" />
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">{{ $t('settings.labelGlobalConcurrency') }}</span>
<span class="setting-desc">{{ $t('settings.descGlobalConcurrency') }}</span>
</div>
<div class="setting-control">
<input type="number" class="setting-select" min="1" style="width:80px"
v-model.number="settings.llmGlobalConcurrency"
@change="syncConcurrencyConfig" />
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">{{ $t('settings.labelPerConvConcurrency') }}</span>
<span class="setting-desc">{{ $t('settings.descPerConvConcurrency') }}</span>
</div>
<div class="setting-control">
<input type="number" class="setting-select" min="1" style="width:80px"
v-model.number="settings.llmPerConvConcurrency"
@change="syncConcurrencyConfig" />
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">{{ $t('settings.labelAgentMaxIterations') }}</span>
<span class="setting-desc">{{ $t('settings.descAgentMaxIterations') }}</span>
</div>
<div class="setting-control">
<input type="number" class="setting-select" min="1" max="50" style="width:80px"
v-model.number="settings.agentMaxIterations"
@change="syncAgentMaxIterations" />
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">{{ $t('settings.labelAgentMaxRetries') }}</span>
<span class="setting-desc">{{ $t('settings.descAgentMaxRetries') }}</span>
</div>
<div class="setting-control">
<input type="number" class="setting-select" min="0" max="10" style="width:80px"
v-model.number="settings.agentMaxRetries"
@change="syncAgentMaxRetries" />
</div>
</div>
</div>
</section>
<!-- 通用设置(聚合壳:外观 + 性能 + 高级)
阶段2 UX 重构: GeneralPanel 内联内容拆为 3 Section
本阶段(阶段2)Settings.vue 仍引 GeneralPanel,故此处作聚合壳组合 3 Section 保持不破;
下一阶段(阶段3 master-detail)Settings.vue 改为直接引各 Section ,本聚合壳即可弃用 -->
<AppearanceSection />
<PerformanceSection />
<AdvancedSection />
</template>
<script setup lang="ts">
import { reactive, watch, onMounted, onUnmounted } from 'vue'
import { aiApi } from '@/api'
import { useAppSettingsStore } from '@/stores/appSettings'
import i18n from '@/i18n'
// ============================================================
// 通用设置域 — 本地设置(localStorage):主题/语言/AI语言/并发/Agent 轮次等
// ============================================================
const appSettings = useAppSettingsStore()
const settings = reactive({
theme: appSettings.get<string>('df-theme', 'dark'),
language: appSettings.get<string>('df-language', 'zh-CN'),
aiLanguage: appSettings.get<string>('df-ai-language', 'auto'),
autoExecute: false,
logLevel: 'info',
showTokenUsage: appSettings.get<boolean>('df-show-token-usage', false),
llmGlobalConcurrency: appSettings.get<number>('df-llm-global-concurrency', 3),
llmPerConvConcurrency: appSettings.get<number>('df-llm-per-conv-concurrency', 2),
// F-260616-01: Agentic 循环最大轮次,默认 10与后端 DEFAULT_MAX_AGENT_ITERATIONS 对齐)
agentMaxIterations: appSettings.get<number>('df-ai-agent-max-iterations', 10),
// F-260616-07: 流式失败重试次数,默认 3与后端 DEFAULT_MAX_AGENT_RETRIES 对齐)
agentMaxRetries: appSettings.get<number>('df-ai-agent-max-retries', 3),
})
function applyTheme(theme: string) {
if (theme === 'system') {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
document.documentElement.removeAttribute('data-theme')
if (!prefersDark) document.documentElement.setAttribute('data-theme', 'light')
} else if (theme === 'light') {
document.documentElement.setAttribute('data-theme', 'light')
} else {
document.documentElement.removeAttribute('data-theme')
}
}
watch(() => settings.theme, (val) => {
applyTheme(val)
void appSettings.set('df-theme', val)
})
watch(() => settings.aiLanguage, (val) => {
void appSettings.set('df-ai-language', val)
})
watch(() => settings.showTokenUsage, (val) => {
void appSettings.set('df-show-token-usage', val)
})
watch(() => settings.llmGlobalConcurrency, (v) => {
void appSettings.set('df-llm-global-concurrency', v)
})
watch(() => settings.llmPerConvConcurrency, (v) => {
void appSettings.set('df-llm-per-conv-concurrency', v)
})
watch(() => settings.agentMaxIterations, (v) => {
void appSettings.set('df-ai-agent-max-iterations', v)
})
watch(() => settings.agentMaxRetries, (v) => {
void appSettings.set('df-ai-agent-max-retries', v)
})
// 同步 LLM 并发上限到后端(debounce 300ms,防快速连续调整时频繁 IPC)
// 持久化已由上方 watch 写 appSettings(SQLite) 完成;此处只负责同步内存限流器
let _concurrencyTimer: ReturnType<typeof setTimeout> | null = null
function syncConcurrencyConfig() {
// clamp 到合理区间,防越界输入(缓存被篡改 / 手动输超限)致限流失效;
// 修正后值同步回 v-model(input 显示) + watch 持久化
// 下限 1,无上限;约束单对话并发不超过全局并发
settings.llmGlobalConcurrency = Math.max(1, settings.llmGlobalConcurrency || 3)
settings.llmPerConvConcurrency = Math.max(1, settings.llmPerConvConcurrency || 2)
if (settings.llmPerConvConcurrency > settings.llmGlobalConcurrency) {
settings.llmPerConvConcurrency = settings.llmGlobalConcurrency
}
if (_concurrencyTimer) clearTimeout(_concurrencyTimer)
_concurrencyTimer = setTimeout(async () => {
try {
await aiApi.setConcurrencyConfig(settings.llmGlobalConcurrency, settings.llmPerConvConcurrency)
} catch (e) {
console.error('同步 LLM 并发配置失败:', e)
}
}, 300)
}
// 同步 Agentic 最大轮次到后端(debounce 300ms)
// F-260616-01: 后端 loop 入口 load 快照锁定边界——热改后当前 loop 不受影响,
// 下次发消息才生效(与 llm_concurrency 传 Arc 实时反映的区别)。
// 双 clamp此处 1-50 + 后端 command 再 clamp 1-50防越界输入致 loop 失效/失控
let _agentIterTimer: ReturnType<typeof setTimeout> | null = null
function syncAgentMaxIterations() {
// clamp 1-50防越界(过小致 agent 失能、过大致烧 token)
settings.agentMaxIterations = Math.min(50, Math.max(1, settings.agentMaxIterations || 10))
if (_agentIterTimer) clearTimeout(_agentIterTimer)
_agentIterTimer = setTimeout(async () => {
try {
await aiApi.setAgentMaxIterations(settings.agentMaxIterations)
} catch (e) {
console.error('同步 Agent 最大轮次失败:', e)
}
}, 300)
}
// 同步流式失败重试次数到后端(debounce 300ms)
// F-260616-07: 后端 loop 入口 load 快照锁定边界——热改后当前 loop 不受影响,
// 下次发消息才生效。双 clamp此处 0-10 + 后端 command 再 clamp 0-10
let _agentRetryTimer: ReturnType<typeof setTimeout> | null = null
function syncAgentMaxRetries() {
// clamp 0-10防越界
settings.agentMaxRetries = Math.min(10, Math.max(0, settings.agentMaxRetries ?? 3))
if (_agentRetryTimer) clearTimeout(_agentRetryTimer)
_agentRetryTimer = setTimeout(async () => {
try {
await aiApi.setAgentMaxRetries(settings.agentMaxRetries)
} catch (e) {
console.error('同步流式重试次数失败:', e)
}
}, 300)
}
watch(() => settings.language, (val) => {
void appSettings.set('df-language', val)
// 同步 i18n 实例,让界面立即跟随语言切换
i18n.global.locale.value = val as 'zh-CN' | 'en'
})
onMounted(() => {
applyTheme(settings.theme)
// 启动同步一次 LLM 并发配置(防刷新页面后后端未恢复用户设定值)
syncConcurrencyConfig()
// F-260616-01: 启动同步一次 Agentic 最大轮次(防刷新页面后后端未恢复用户设定值)
// F-260618-23: App.vue 根 onMounted 已在应用启动时恢复(覆盖用户没先进 Settings 直接用 AI Chat 的场景),
// 此处保留双保险IPC 幂等值相同无害)
syncAgentMaxIterations()
// F-260616-07: 启动同步一次流式失败重试次数(防刷新页面后后端未恢复用户设定值)
// F-260618-23: 同上App.vue 根 onMounted 已恢复,此处双保险保留
syncAgentMaxRetries()
})
// 组件卸载清全部 debounce timer,防 unmount 后旧 timer 仍 fire 写已销毁 reactive(timer 泄漏 FR-C3)
onUnmounted(() => {
if (_concurrencyTimer) clearTimeout(_concurrencyTimer)
if (_agentIterTimer) clearTimeout(_agentIterTimer)
if (_agentRetryTimer) clearTimeout(_agentRetryTimer)
})
import AppearanceSection from '@/components/settings/AppearanceSection.vue'
import PerformanceSection from '@/components/settings/PerformanceSection.vue'
import AdvancedSection from '@/components/settings/AdvancedSection.vue'
</script>
<style scoped>
/* ===== 面板容器(原 Settings.vue 抽出,各子组件自持 scoped 副本) ===== */
.panel {
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg);
padding: var(--df-pad-panel);
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--df-gap-head);
}
.panel-header h2 { font-size: 15px; font-weight: 500; }
/* ===== 通用设置 ===== */
.general-settings { display: flex; flex-direction: column; }
.setting-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 0;
border-bottom: 0.5px solid var(--df-border);
}
.setting-row:last-child { border-bottom: none; }
.setting-info { display: flex; flex-direction: column; gap: 2px; }
.setting-label { font-size: 14px; font-weight: 500; color: var(--df-text); }
.setting-desc { font-size: 12px; color: var(--df-text-dim); }
.setting-control { display: flex; align-items: center; }
.setting-select { padding: 6px 12px; background: var(--df-bg); border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm); color: var(--df-text); font-size: 13px; outline: none; cursor: pointer; min-width: 160px; }
.setting-select:focus { border-color: var(--df-accent); }
/* Toggle */
.toggle { position: relative; display: inline-block; width: 40px; height: 22px; cursor: pointer; }
.toggle input { opacity: 0; width: 0; height: 0; }
.toggle-slider { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: var(--df-border); border-radius: var(--df-radius-lg); transition: background 0.2s; }
.toggle-slider::before { content: ''; position: absolute; width: 16px; height: 16px; left: 3px; bottom: 3px; background: var(--df-text-dim); border-radius: 50%; transition: transform 0.2s, background 0.2s; }
.toggle input:checked + .toggle-slider { background: var(--df-accent); }
.toggle input:checked + .toggle-slider::before { transform: translateX(18px); background: #fff; }
/* 阶段2 UX 重构:聚合壳仅组合 3 Section,无自身样式。
各 Section 复用 settings.css 全局 .panel 等 class,Section 之间间距由父 .settings-grid 控制。 */
</style>

View File

@@ -4,117 +4,67 @@
<h2>{{ $t('settings.panelKnowledge') }}</h2>
</div>
<div class="general-settings">
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">{{ $t('settings.labelAutoExtract') }}</span>
<span class="setting-desc">{{ $t('settings.descAutoExtract') }}</span>
</div>
<div class="setting-control">
<label class="toggle">
<input type="checkbox" v-model="knowledgeConfig.auto_extract" />
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">{{ $t('settings.labelTriggerMode') }}</span>
<span class="setting-desc">{{ $t('settings.descTriggerMode') }}</span>
</div>
<div class="setting-control">
<select v-model="knowledgeConfig.trigger_mode" class="setting-select">
<option value="on_complete">{{ $t('settings.triggerOnComplete') }}</option>
<option value="on_idle">{{ $t('settings.triggerOnIdle') }}</option>
<option value="manual_only">{{ $t('settings.triggerManualOnly') }}</option>
</select>
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">{{ $t('settings.labelMinMessages') }}</span>
<span class="setting-desc">{{ $t('settings.descMinMessages') }}</span>
</div>
<div class="setting-control">
<input type="number" min="2" max="50" v-model.number="knowledgeConfig.min_messages" class="setting-select" style="width:80px" />
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">{{ $t('settings.labelIdleTimeout') }}</span>
<span class="setting-desc">{{ $t('settings.descIdleTimeout') }}</span>
</div>
<div class="setting-control">
<input type="number" min="5" max="600" :value="Math.round(knowledgeConfig.idle_timeout_ms / 1000)" @input="knowledgeConfig.idle_timeout_ms = ($event.target as HTMLInputElement).valueAsNumber * 1000 || 30000" class="setting-select" style="width:80px" />
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">{{ $t('settings.labelAutoInject') }}</span>
<span class="setting-desc">{{ $t('settings.descAutoInject') }}</span>
</div>
<div class="setting-control">
<label class="toggle">
<input type="checkbox" v-model="knowledgeConfig.auto_inject" />
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">{{ $t('settings.labelVectorEnabled') }}</span>
<span class="setting-desc">{{ $t('settings.descVectorEnabled') }}</span>
</div>
<div class="setting-control">
<label class="toggle">
<input type="checkbox" v-model="knowledgeConfig.vector_enabled" />
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="setting-row" v-if="knowledgeConfig.vector_enabled">
<div class="setting-info">
<span class="setting-label">{{ $t('settings.labelEmbeddingProvider') }}</span>
<span class="setting-desc">{{ $t('settings.descEmbeddingProvider') }}</span>
</div>
<div class="setting-control">
<select v-model="knowledgeConfig.embedding_provider_id" class="setting-select">
<option :value="null">{{ $t('settings.embeddingProviderNone') }}</option>
<option v-for="p in embeddingProviders" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</div>
</div>
<div class="setting-row" v-if="knowledgeConfig.vector_enabled">
<div class="setting-info">
<span class="setting-label">{{ $t('settings.labelEmbeddingModel') }}</span>
<span class="setting-desc">{{ $t('settings.descEmbeddingModel') }}</span>
</div>
<div class="setting-control">
<input type="text" :value="knowledgeConfig.embedding_model ?? ''" @input="knowledgeConfig.embedding_model = ($event.target as HTMLInputElement).value || null" class="setting-select" :placeholder="$t('settings.phEmbeddingModel')" style="width:220px" />
</div>
</div>
<SettingRow ref="rowAutoExtract" :label="$t('settings.labelAutoExtract')" :desc="$t('settings.descAutoExtract')">
<label class="toggle">
<input type="checkbox" v-model="knowledgeConfig.auto_extract" @change="markSaved('autoExtract')" />
<span class="toggle-slider"></span>
</label>
</SettingRow>
<SettingRow ref="rowTriggerMode" :label="$t('settings.labelTriggerMode')" :desc="$t('settings.descTriggerMode')">
<select v-model="knowledgeConfig.trigger_mode" class="setting-select" @change="markSaved('triggerMode')">
<option value="on_complete">{{ $t('settings.triggerOnComplete') }}</option>
<option value="manual_only">{{ $t('settings.triggerManualOnly') }}</option>
</select>
</SettingRow>
<SettingRow ref="rowMinMessages" :label="$t('settings.labelMinMessages')" :desc="$t('settings.descMinMessages')">
<input type="number" min="2" max="50" v-model.number="knowledgeConfig.min_messages" class="setting-select" style="width:80px" @change="markSaved('minMessages')" />
</SettingRow>
<SettingRow ref="rowAutoInject" :label="$t('settings.labelAutoInject')" :desc="$t('settings.descAutoInject')">
<label class="toggle">
<input type="checkbox" v-model="knowledgeConfig.auto_inject" @change="markSaved('autoInject')" />
<span class="toggle-slider"></span>
</label>
</SettingRow>
<SettingRow ref="rowVectorEnabled" :label="$t('settings.labelVectorEnabled')" :desc="$t('settings.descVectorEnabled')">
<label class="toggle">
<input type="checkbox" v-model="knowledgeConfig.vector_enabled" @change="markSaved('vectorEnabled')" />
<span class="toggle-slider"></span>
</label>
</SettingRow>
<SettingRow v-if="knowledgeConfig.vector_enabled" ref="rowEmbeddingProvider" :label="$t('settings.labelEmbeddingProvider')" :desc="$t('settings.descEmbeddingProvider')">
<select v-model="knowledgeConfig.embedding_provider_id" class="setting-select" @change="markSaved('embeddingProvider')">
<option :value="null">{{ $t('settings.embeddingProviderNone') }}</option>
<option v-for="p in embeddingProviders" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</SettingRow>
<SettingRow v-if="knowledgeConfig.vector_enabled" ref="rowEmbeddingModel" :label="$t('settings.labelEmbeddingModel')" :desc="$t('settings.descEmbeddingModel')">
<input type="text" :value="knowledgeConfig.embedding_model ?? ''" @input="knowledgeConfig.embedding_model = ($event.target as HTMLInputElement).value || null" @change="markSaved('embeddingModel')" class="setting-select" :placeholder="$t('settings.phEmbeddingModel')" style="width:220px" />
</SettingRow>
</div>
</section>
</template>
<script setup lang="ts">
import { reactive, computed, watch, onMounted, onUnmounted } from 'vue'
import { reactive, computed, watch, onMounted, onUnmounted, useTemplateRef } from 'vue'
import { knowledgeApi } from '@/api'
import type { AiProviderConfig } from '@/api/types'
import SettingRow from './SettingRow.vue'
// ============================================================
// 知识库配置域(提取 + 注入) — 走后端 IPC,非 localStorage
// 阶段5:SettingRow 封装行布局,控件变更后调 markSaved 显"已保存"(用户 @change 触发,
// 与 watch 深响应 debounce 保存解耦,启动 load 不显)
// ============================================================
const props = defineProps<{
/** Provider 列表(embedding provider 下拉依赖,由 ProviderPanel 上同步) */
/** Provider 列表(embedding provider 下拉依赖,由 ProviderPanel 上同步) */
aiProviders: AiProviderConfig[]
}>()
const knowledgeConfig = reactive({
loaded: false,
auto_extract: true,
trigger_mode: 'on_complete' as 'on_complete' | 'on_idle' | 'manual_only',
trigger_mode: 'on_complete' as 'on_complete' | 'manual_only',
min_messages: 4,
idle_timeout_ms: 30000,
auto_inject: true,
vector_enabled: false,
embedding_provider_id: null as string | null,
@@ -143,7 +93,9 @@ function saveKnowledgeConfigDebounced() {
try {
const { loaded, ...cfg } = knowledgeConfig
void loaded
await knowledgeApi.saveConfig(cfg as any)
// cfg 形态与后端 KnowledgeConfig 对齐(auto_extract/trigger_mode/min_messages/
// auto_inject/vector_enabled/embedding_provider_id/embedding_model),无需 as any
await knowledgeApi.saveConfig(cfg)
} catch (e) {
console.error('保存知识库配置失败:', e)
}
@@ -158,6 +110,27 @@ watch(
{ deep: true },
)
// 即时生效反馈:SettingRow refs(embedding 两项 v-if 条件渲染,可能未挂载,?. 安全)
const rowAutoExtract = useTemplateRef<InstanceType<typeof SettingRow>>('rowAutoExtract')
const rowTriggerMode = useTemplateRef<InstanceType<typeof SettingRow>>('rowTriggerMode')
const rowMinMessages = useTemplateRef<InstanceType<typeof SettingRow>>('rowMinMessages')
const rowAutoInject = useTemplateRef<InstanceType<typeof SettingRow>>('rowAutoInject')
const rowVectorEnabled = useTemplateRef<InstanceType<typeof SettingRow>>('rowVectorEnabled')
const rowEmbeddingProvider = useTemplateRef<InstanceType<typeof SettingRow>>('rowEmbeddingProvider')
const rowEmbeddingModel = useTemplateRef<InstanceType<typeof SettingRow>>('rowEmbeddingModel')
function markSaved(key: 'autoExtract' | 'triggerMode' | 'minMessages' | 'autoInject' | 'vectorEnabled' | 'embeddingProvider' | 'embeddingModel') {
const map = {
autoExtract: rowAutoExtract.value,
triggerMode: rowTriggerMode.value,
minMessages: rowMinMessages.value,
autoInject: rowAutoInject.value,
vectorEnabled: rowVectorEnabled.value,
embeddingProvider: rowEmbeddingProvider.value,
embeddingModel: rowEmbeddingModel.value,
}
map[key]?.markSaved()
}
onMounted(() => {
loadKnowledgeConfig()
})
@@ -169,45 +142,6 @@ onUnmounted(() => {
</script>
<style scoped>
/* ===== 面板容器(原 Settings.vue 抽出,各子组件自持 scoped 副本) ===== */
.panel {
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg);
padding: var(--df-pad-panel);
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--df-gap-head);
}
.panel-header h2 { font-size: 15px; font-weight: 500; }
/* ===== 通用设置行(Knowledge 复用 setting-row 布局) ===== */
.general-settings { display: flex; flex-direction: column; }
.setting-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 0;
border-bottom: 0.5px solid var(--df-border);
}
.setting-row:last-child { border-bottom: none; }
.setting-info { display: flex; flex-direction: column; gap: 2px; }
.setting-label { font-size: 14px; font-weight: 500; color: var(--df-text); }
.setting-desc { font-size: 12px; color: var(--df-text-dim); }
.setting-control { display: flex; align-items: center; }
.setting-select { padding: 6px 12px; background: var(--df-bg); border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm); color: var(--df-text); font-size: 13px; outline: none; cursor: pointer; min-width: 160px; }
.setting-select:focus { border-color: var(--df-accent); }
/* Toggle */
.toggle { position: relative; display: inline-block; width: 40px; height: 22px; cursor: pointer; }
.toggle input { opacity: 0; width: 0; height: 0; }
.toggle-slider { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: var(--df-border); border-radius: var(--df-radius-lg); transition: background 0.2s; }
.toggle-slider::before { content: ''; position: absolute; width: 16px; height: 16px; left: 3px; bottom: 3px; background: var(--df-text-dim); border-radius: 50%; transition: transform 0.2s, background 0.2s; }
.toggle input:checked + .toggle-slider { background: var(--df-accent); }
.toggle input:checked + .toggle-slider::before { transform: translateX(18px); background: #fff; }
/* 阶段1 UX 重构:共用样式抽至 src/components/settings/settings.css 全局。
阶段5:行布局封装进 SettingRow(本 Panel 无特有 scoped 样式)。 */
</style>

View File

@@ -0,0 +1,231 @@
<template>
<!-- 性能设置(全局并发 / 单对话并发 / Agent 最大轮次 / 流式重试) -->
<section class="panel">
<div class="panel-header">
<h2>{{ $t('settings.sectionPerformance') }}</h2>
</div>
<div class="general-settings">
<SettingRow
ref="rowGlobalConcurrency"
next-round
:label="$t('settings.labelGlobalConcurrency')"
:desc="$t('settings.descGlobalConcurrency')">
<input type="number" class="setting-select" min="1" style="width:80px"
v-model.number="settings.llmGlobalConcurrency"
@change="onConcurrencyChange" />
</SettingRow>
<SettingRow
ref="rowPerConvConcurrency"
next-round
:label="$t('settings.labelPerConvConcurrency')"
:desc="$t('settings.descPerConvConcurrency')">
<input type="number" class="setting-select" min="1" style="width:80px"
v-model.number="settings.llmPerConvConcurrency"
@change="onConcurrencyChange" />
</SettingRow>
<SettingRow
ref="rowMaxIterations"
next-round
:label="$t('settings.labelAgentMaxIterations')"
:desc="$t('settings.descAgentMaxIterations')">
<label class="unlimited-toggle">
<input type="checkbox" v-model="unlimited" @change="markSaved('iter')" />
<span>{{ $t('settings.labelUnlimited') }}</span>
</label>
<input type="number" class="setting-select" min="1" max="50" style="width:80px"
v-model.number="settings.agentMaxIterations"
:disabled="unlimited"
@change="onIterChange" />
</SettingRow>
<SettingRow
ref="rowMaxRetries"
next-round
:label="$t('settings.labelAgentMaxRetries')"
:desc="$t('settings.descAgentMaxRetries')">
<input type="number" class="setting-select" min="0" max="10" style="width:80px"
v-model.number="settings.agentMaxRetries"
@change="onRetryChange" />
</SettingRow>
</div>
</section>
</template>
<script setup lang="ts">
import { reactive, watch, computed, onMounted, onUnmounted, useTemplateRef } from 'vue'
import { aiApi } from '@/api'
import { useAppSettingsStore } from '@/stores/appSettings'
import SettingRow from './SettingRow.vue'
// ============================================================
// 性能域 — 本地设置(localStorage):LLM 并发 / 单对话并发 / Agent 轮次 / 流式重试
// 拆自 GeneralPanel,本 Section 仅持有性能相关 key + 各自 debounce IPC 同步
// 阶段5:SettingRow 封装行布局 + nextRound badge(后端快照锁定边界,下次对话生效);
// 4 项变更后调 markSaved 显"已保存"
// ============================================================
const appSettings = useAppSettingsStore()
const settings = reactive({
llmGlobalConcurrency: appSettings.get<number>('df-llm-global-concurrency', 3),
llmPerConvConcurrency: appSettings.get<number>('df-llm-per-conv-concurrency', 2),
// F-260616-01: Agentic 循环最大轮次,默认 10与后端 DEFAULT_MAX_AGENT_ITERATIONS 对齐)
agentMaxIterations: appSettings.get<number>('df-ai-agent-max-iterations', 10),
// F-260616-07: 流式失败重试次数,默认 3与后端 DEFAULT_MAX_AGENT_RETRIES 对齐)
agentMaxRetries: appSettings.get<number>('df-ai-agent-max-retries', 3),
})
// 即时生效反馈:SettingRow refs
const rowGlobalConcurrency = useTemplateRef<InstanceType<typeof SettingRow>>('rowGlobalConcurrency')
const rowPerConvConcurrency = useTemplateRef<InstanceType<typeof SettingRow>>('rowPerConvConcurrency')
const rowMaxIterations = useTemplateRef<InstanceType<typeof SettingRow>>('rowMaxIterations')
const rowMaxRetries = useTemplateRef<InstanceType<typeof SettingRow>>('rowMaxRetries')
function markSaved(key: 'global' | 'perConv' | 'iter' | 'retry') {
const map = { global: rowGlobalConcurrency.value, perConv: rowPerConvConcurrency.value, iter: rowMaxIterations.value, retry: rowMaxRetries.value }
map[key]?.markSaved()
}
watch(() => settings.llmGlobalConcurrency, (v) => {
void appSettings.set('df-llm-global-concurrency', v)
})
watch(() => settings.llmPerConvConcurrency, (v) => {
void appSettings.set('df-llm-per-conv-concurrency', v)
})
watch(() => settings.agentMaxIterations, (v) => {
void appSettings.set('df-ai-agent-max-iterations', v)
})
watch(() => settings.agentMaxRetries, (v) => {
void appSettings.set('df-ai-agent-max-retries', v)
})
// onMounted 期间调 sync 不应触发"已保存"反馈(那是启动恢复,非用户改动)
let _userInteracting = false
// 同步 LLM 并发上限到后端(debounce 300ms,防快速连续调整时频繁 IPC)
// 持久化已由上方 watch 写 appSettings(SQLite) 完成;此处只负责同步内存限流器
let _concurrencyTimer: ReturnType<typeof setTimeout> | null = null
function syncConcurrencyConfig() {
// clamp 到合理区间,防越界输入(缓存被篡改 / 手动输超限)致限流失效;
// 修正后值同步回 v-model(input 显示) + watch 持久化
// 下限 1,无上限;约束单对话并发不超过全局并发
settings.llmGlobalConcurrency = Math.max(1, settings.llmGlobalConcurrency || 3)
settings.llmPerConvConcurrency = Math.max(1, settings.llmPerConvConcurrency || 2)
if (settings.llmPerConvConcurrency > settings.llmGlobalConcurrency) {
settings.llmPerConvConcurrency = settings.llmGlobalConcurrency
}
if (_concurrencyTimer) clearTimeout(_concurrencyTimer)
_concurrencyTimer = setTimeout(async () => {
try {
await aiApi.setConcurrencyConfig(settings.llmGlobalConcurrency, settings.llmPerConvConcurrency)
} catch (e) {
console.error('同步 LLM 并发配置失败:', e)
}
}, 300)
if (_userInteracting) {
markSaved('global')
markSaved('perConv')
}
}
// wrapper:并发变更(@change 用户触发)同时反馈两项(global/perConv 任一变动都同步两项)
function onConcurrencyChange() {
_userInteracting = true
syncConcurrencyConfig()
_userInteracting = false
}
// wrapper:轮次 @change 用户触发,走 _userInteracting 守卫显反馈(onMounted 启动同步不显)
function onIterChange() {
_userInteracting = true
syncAgentMaxIterations()
_userInteracting = false
}
// wrapper:重试 @change 用户触发,走 _userInteracting 守卫显反馈
function onRetryChange() {
_userInteracting = true
syncAgentMaxRetries()
_userInteracting = false
}
// 同步 Agentic 最大轮次到后端(debounce 300ms)
// F-260616-01: 后端 loop 入口 load 快照锁定边界——热改后当前 loop 不受影响,
// 下次发消息才生效(与 llm_concurrency 传 Arc 实时反映的区别)。
// 双 clamp此处 1-50 + 后端 command 再 clamp 1-50防越界输入致 loop 失效/失控
let _agentIterTimer: ReturnType<typeof setTimeout> | null = null
function syncAgentMaxIterations() {
// 0=不限透传;否则 clamp 1-50 防越界(过小致 agent 失能、过大致烧 token)
if (settings.agentMaxIterations !== 0) {
settings.agentMaxIterations = Math.min(50, Math.max(1, settings.agentMaxIterations || 10))
}
if (_agentIterTimer) clearTimeout(_agentIterTimer)
_agentIterTimer = setTimeout(async () => {
try {
await aiApi.setAgentMaxIterations(settings.agentMaxIterations)
} catch (e) {
console.error('同步 Agent 最大轮次失败:', e)
}
}, 300)
if (_userInteracting) {
markSaved('iter')
}
}
// 「不限」勾选:agentMaxIterations===0 即不限(后端 0→usize::MAX,loop 靠 stop/收敛/审批退出)
const unlimited = computed({
get: () => settings.agentMaxIterations === 0,
set(v: boolean) {
if (v) {
settings.agentMaxIterations = 0
} else if (settings.agentMaxIterations === 0) {
settings.agentMaxIterations = 10
}
syncAgentMaxIterations()
},
})
// 同步流式失败重试次数到后端(debounce 300ms)
// F-260616-07: 后端 loop 入口 load 快照锁定边界——热改后当前 loop 不受影响,
// 下次发消息才生效。双 clamp此处 0-10 + 后端 command 再 clamp 0-10
let _agentRetryTimer: ReturnType<typeof setTimeout> | null = null
function syncAgentMaxRetries() {
// clamp 0-10防越界
settings.agentMaxRetries = Math.min(10, Math.max(0, settings.agentMaxRetries ?? 3))
if (_agentRetryTimer) clearTimeout(_agentRetryTimer)
_agentRetryTimer = setTimeout(async () => {
try {
await aiApi.setAgentMaxRetries(settings.agentMaxRetries)
} catch (e) {
console.error('同步流式重试次数失败:', e)
}
}, 300)
if (_userInteracting) {
markSaved('retry')
}
}
onMounted(() => {
// 启动同步一次 LLM 并发配置(防刷新页面后后端未恢复用户设定值)
syncConcurrencyConfig()
// F-260616-01: 启动同步一次 Agentic 最大轮次(防刷新页面后后端未恢复用户设定值)
// F-260618-23: App.vue 根 onMounted 已在应用启动时恢复(覆盖用户没先进 Settings 直接用 AI Chat 的场景),
// 此处保留双保险IPC 幂等值相同无害)
syncAgentMaxIterations()
// F-260616-07: 启动同步一次流式失败重试次数(防刷新页面后后端未恢复用户设定值)
// F-260618-23: 同上App.vue 根 onMounted 已恢复,此处双保险保留
syncAgentMaxRetries()
})
// 组件卸载清全部 debounce timer,防 unmount 后旧 timer 仍 fire 写已销毁 reactive(timer 泄漏 FR-C3)
onUnmounted(() => {
if (_concurrencyTimer) clearTimeout(_concurrencyTimer)
if (_agentIterTimer) clearTimeout(_agentIterTimer)
if (_agentRetryTimer) clearTimeout(_agentRetryTimer)
})
</script>
<style scoped>
/* 阶段5 UX 重构:复用 settings.css 全局 .panel/.panel-header/.general-settings/
.setting-select + SettingRow(封装 .setting-row/.setting-info/.setting-label/
.setting-desc/.setting-control + nextRound badge)。本 Section 无特有 scoped 样式
(.unlimited-toggle 沿用默认 label)。 */
</style>

View File

@@ -127,7 +127,7 @@
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { reactive, ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { aiApi } from '@/api'
import { useAiStore } from '@/stores/ai'
@@ -337,39 +337,26 @@ function maskKey(key: string): string {
return key.slice(0, 4) + '••••••••' + key.slice(-4)
}
/** 暴露给 shell:onMounted 拉取 / Knowledge 读 embeddingProviders / 卸载清 timer */
/** 暴露给 shell:loadProviders 供外部主动刷新 / Knowledge 读 aiProviders / 卸载清 timer */
defineExpose({
aiProviders,
loadProviders,
clearPoolTimer() { if (_poolTimer) clearTimeout(_poolTimer) },
})
// 阶段3 UX 重构:本面板改为 ai-model 类懒挂载,不再由 Settings.vue onMounted 调用,
// 改为自身挂载时拉取 provider 列表 + 同步 aiStore。
onMounted(loadProviders)
</script>
<style scoped>
/* ===== 面板容器(原 Settings.vue 抽出,各子组件自持 scoped 副本) ===== */
.panel {
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg);
padding: var(--df-pad-panel);
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--df-gap-head);
}
.panel-header h2 { font-size: 15px; font-weight: 500; }
.empty-hint { font-size: 13px; color: var(--df-text-dim); padding: 12px 0; }
/* 阶段1/3 UX 重构:panel/panel-header/empty-hint/btn 系列/setting-select/
toggle/toggle-slider 已迁移到 settings.css 全局。
本 scoped 仅保留 ProviderPanel 特有样式:provider-card/default-badge/
pool-badge/detail 系列/form 系列/setting-input/btn-link/toggle--sm 等。
注:注释内勿用斜杠分隔类名列表(会误闭合 CSS 注释)。
/* ===== 按钮 ===== */
.btn { padding: 8px 16px; border: none; border-radius: var(--df-radius-sm); font-size: 13px; cursor: pointer; transition: all 0.15s; }
.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; }
/* ===== 链接按钮(本面板特有,未进全局) ===== */
.btn-link { background: none; border: none; color: var(--df-accent); font-size: 12px; cursor: pointer; padding: 2px 6px; }
.btn-link:hover { text-decoration: underline; }
.btn-link-danger { color: var(--df-danger); }
@@ -434,7 +421,7 @@ defineExpose({
.detail-value--control { display: flex; align-items: center; gap: 8px; font-family: var(--df-font-mono); }
.pool-weight-hint { font-size: 11px; color: var(--df-text-dim); }
/* 表单 */
/* 表单(本面板特有输入控件样式;.setting-select 走全局) */
.form-grid { display: flex; flex-direction: column; gap: 14px; }
.form-field { display: flex; flex-direction: column; gap: 4px; }
.form-label { font-size: 12px; font-weight: 500; color: var(--df-text-secondary); }
@@ -443,16 +430,8 @@ defineExpose({
.setting-input { padding: 6px 12px; background: var(--df-bg); border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm); color: var(--df-text); font-size: 13px; outline: none; min-width: 260px; }
.setting-input:focus { border-color: var(--df-accent); }
.setting-input--sm { min-width: 0; width: 70px; padding: 4px 8px; font-size: 12px; }
.setting-select { padding: 6px 12px; background: var(--df-bg); border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm); color: var(--df-text); font-size: 13px; outline: none; cursor: pointer; min-width: 160px; }
.setting-select:focus { border-color: var(--df-accent); }
/* Toggle */
.toggle { position: relative; display: inline-block; width: 40px; height: 22px; cursor: pointer; }
.toggle input { opacity: 0; width: 0; height: 0; }
.toggle-slider { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: var(--df-border); border-radius: var(--df-radius-lg); transition: background 0.2s; }
.toggle-slider::before { content: ''; position: absolute; width: 16px; height: 16px; left: 3px; bottom: 3px; background: var(--df-text-dim); border-radius: 50%; transition: transform 0.2s, background 0.2s; }
.toggle input:checked + .toggle-slider { background: var(--df-accent); }
.toggle input:checked + .toggle-slider::before { transform: translateX(18px); background: #fff; }
/* Toggle 小号变体(本面板负载均衡池用;.toggle 基础走全局) */
.toggle--sm { width: 32px; height: 18px; }
.toggle--sm .toggle-slider::before { width: 12px; height: 12px; left: 3px; bottom: 3px; }
.toggle--sm input:checked + .toggle-slider::before { transform: translateX(14px); }

View File

@@ -0,0 +1,84 @@
<template>
<!-- 统一设置行(UX 重构阶段5):封装 label/desc + control slot + 即时生效反馈 -->
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">
{{ label }}
<span v-if="nextRound" class="next-round-badge">{{ $t('settings.nextRoundBadge') }}</span>
</span>
<span v-if="desc" class="setting-desc">{{ desc }}</span>
</div>
<div class="setting-control">
<slot />
<Transition name="saved-fade">
<span v-if="savedVisible" class="saved-hint">{{ $t('settings.savedHint') }}</span>
</Transition>
</div>
</div>
</template>
<script setup lang="ts">
import { shallowRef } from 'vue'
// ============================================================
// 统一设置行:label/desc 入 props,控件走默认 slot,变更后父级调 markSaved() 显反馈。
// nextRound=true 显"下次对话生效" badge(性能域并发/轮次/重试白名单项)。
// savedVisible 用 shallowRef(布尔翻转无深响应需求);timer ref 持有供连续触发时清旧。
// ============================================================
defineProps<{
/** 设置项标题 */
label: string
/** 设置项描述(可空,如 toggle 无需描述) */
desc?: string
/** 是否显示"下次对话生效" badge(后端快照锁定边界的设置项,如并发/轮次/重试) */
nextRound?: boolean
}>()
const savedVisible = shallowRef(false)
const timer = shallowRef<ReturnType<typeof setTimeout> | null>(null)
/** 显"✓已保存"行内提示,2s 后消失。连续触发清旧 timer 防提前消失。 */
function markSaved() {
savedVisible.value = true
if (timer.value) clearTimeout(timer.value)
timer.value = setTimeout(() => {
savedVisible.value = false
timer.value = null
}, 2000)
}
defineExpose({ markSaved })
</script>
<style scoped>
/* 反馈位"✓已保存" — 行内紧贴控件右侧,2s 淡出 */
.saved-hint {
margin-left: 10px;
font-size: 12px;
color: var(--df-accent);
white-space: nowrap;
}
/* badge"下次对话生效" — label 右侧小标 */
.next-round-badge {
display: inline-block;
margin-left: 8px;
padding: 1px 6px;
font-size: 11px;
font-weight: 400;
color: var(--df-text-dim);
background: var(--df-bg);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
vertical-align: middle;
}
/* Transition:淡入淡出 */
.saved-fade-enter-active,
.saved-fade-leave-active {
transition: opacity 0.25s ease;
}
.saved-fade-enter-from,
.saved-fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,233 @@
<template>
<!-- 设置导入 / 导出(阶段6 UX 重构)
导出:聚合 appSettings KV(已解析)+ 知识库配置 JSON 文件下载
(默认不含 provider keyring 密钥,scope 标注)
导入:input[type=file] JSON 解析校验 useConfirm 覆盖确认
KV settings.set + knowledgeApi.saveConfig toast 提示
复用 settings.css 全局 .btn/.btn-primary/.btn-ghost/.btn-sm;
confirm/toast 由父 Settings.vue props 注入,共用壳层弹层与 toast -->
<div class="settings-io">
<button class="btn btn-ghost btn-sm" :disabled="busy" @click="onExport">
{{ $t('settings.export') }}
</button>
<button class="btn btn-ghost btn-sm" :disabled="busy" @click="triggerImport">
{{ $t('settings.import') }}
</button>
<!-- 隐藏的文件选择器(点击导入触发) -->
<input
ref="fileInputRef"
type="file"
accept="application/json,.json"
class="file-input"
@change="onFilePicked"
/>
</div>
</template>
<script setup lang="ts">
// ============================================================
// 阶段6 导入 / 导出 — 纯前端实现
// ------------------------------------------------------------
// 导出:从 appSettings.cache 取已解析的全 KV(类型正确,非后端原始 JSON 字符串),
// 叠加 knowledgeApi.getConfig(),组成 {metadata, appSettings, knowledge} JSON。
// 用 Blob + a[download] 下载(不依赖 Tauri fs/dialog 权限)。
// 导入:<input type=file> + FileReader.readAsText → 解析 → metadata.scope 校验
// → confirmDialog 覆盖确认 → appSettings.set 逐 KV + knowledgeApi.saveConfig
// → toast 反馈(并发/轮次需重启或下次对话生效)。
// 安全:scope 仅含 appSettings + 知识库配置,默认不含 provider keyring 密钥
// (密钥在独立 store,本流程不触及)。
// ============================================================
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppSettingsStore } from '@/stores/appSettings'
import { knowledgeApi } from '@/api/knowledge'
import type { KnowledgeConfig } from '@/api/types'
const props = defineProps<{
/** 覆盖确认弹层(复用 Settings.vue 壳层 confirmState) */
confirmDialog: (msg: string, dangerLabel?: string) => Promise<boolean>
/** toast 反馈通道(复用 Settings.vue 壳层 showToast) */
toast: (msg: string, type?: 'error' | 'warning' | 'info') => void
}>()
const { t } = useI18n()
const appSettings = useAppSettingsStore()
const busy = ref(false)
const fileInputRef = ref<HTMLInputElement | null>(null)
/** 导出的 JSON 备份载荷结构 */
interface SettingsBackup {
metadata: {
version: number
date: string
scope: 'appSettings+knowledge'
app: string
}
appSettings: Record<string, unknown>
knowledge: KnowledgeConfig | null
}
/** 当前日期戳(用于导出文件名:devflow-settings-YYYYMMDD.json) */
function dateStamp(): string {
const d = new Date()
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}${m}${day}`
}
// ============================================================
// 导出
// ============================================================
async function onExport() {
if (busy.value) return
busy.value = true
try {
// appSettings.cache 为已解析(反序列化)值的响应式缓存 —— 取其快照,
// 保证导出的 JSON 值类型与运行时一致(非后端原始 JSON 字符串)
const allKv: Record<string, unknown> = { ...appSettings.cache }
// 知识库配置独立 IPC 读取(不在 app_settings 表)
let knowledge: KnowledgeConfig | null = null
try {
knowledge = await knowledgeApi.getConfig()
} catch (e) {
// 知识库读取失败不阻断导出(appSettings 仍可导),仅 console 记录
console.error('[设置导出] 读取知识库配置失败:', e)
}
const payload: SettingsBackup = {
metadata: {
version: 1,
date: new Date().toISOString(),
scope: 'appSettings+knowledge',
app: 'devflow',
},
appSettings: allKv,
knowledge,
}
const json = JSON.stringify(payload, null, 2)
// Blob + a[download] 下载:webview 内原生支持,无需 Tauri fs 权限
const blob = new Blob([json], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `devflow-settings-${dateStamp()}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
} catch (e) {
props.toast(t('settings.exportFail', { msg: errMsg(e) }), 'error')
} finally {
busy.value = false
}
}
// ============================================================
// 导入
// ============================================================
function triggerImport() {
// 重置 value 以便「选同一文件」也能再次触发 change(否则 onChange 不再回调)
if (fileInputRef.value) fileInputRef.value.value = ''
fileInputRef.value?.click()
}
async function onFilePicked(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
if (busy.value) return
busy.value = true
try {
const text = await readFileAsText(file)
const parsed = parseJsonLoose(text)
if (!parsed || !isSettingsBackup(parsed)) {
props.toast(t('settings.importInvalidJson'), 'error')
return
}
// 覆盖确认(复用壳层 confirmState 弹层;用户取消则放弃)
const ok = await props.confirmDialog(t('settings.importConfirm'), '')
if (!ok) return
// 逐 KV 写回 appSettings(经 store.set 同时更新缓存与 debounced 落库)
const kv = parsed.appSettings ?? {}
for (const [k, v] of Object.entries(kv)) {
await appSettings.set(k, v)
}
// 知识库配置写回(独立 IPC)
if (parsed.knowledge) {
await knowledgeApi.saveConfig(parsed.knowledge)
}
props.toast(t('settings.importSuccess'), 'info')
} catch (e) {
props.toast(t('settings.importFail', { msg: errMsg(e) }), 'error')
} finally {
busy.value = false
}
}
// ============================================================
// 工具
// ============================================================
/** FileReader 读文本(替代 file.text() 以兼容更早 webview) */
function readFileAsText(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result ?? ''))
reader.onerror = () => reject(reader.error ?? new Error('read error'))
reader.readAsText(file)
})
}
/** 宽松 JSON.parse:失败返回 null(调用方据此报「格式不符」) */
function parseJsonLoose(text: string): unknown {
try {
return JSON.parse(text)
} catch {
return null
}
}
/** 结构校验:必须含 metadata.scope === 'appSettings+knowledge' 且 version 为数 */
function isSettingsBackup(obj: unknown): obj is SettingsBackup {
if (typeof obj !== 'object' || obj === null) return false
const o = obj as Record<string, unknown>
const meta = o.metadata as Record<string, unknown> | undefined
if (!meta || meta.scope !== 'appSettings+knowledge') return false
if (typeof meta.version !== 'number') return false
// appSettings 必须为对象(允许空对象);knowledge 允许 null 或对象
if (typeof o.appSettings !== 'object' || o.appSettings === null) return false
return true
}
/** 提取错误信息文案(兜底 Error.message 或 String(e)) */
function errMsg(e: unknown): string {
if (e instanceof Error) return e.message
return String(e)
}
</script>
<style scoped>
/* 导入导出按钮组:横向排列,复用全局 .btn/.btn-ghost/.btn-sm */
.settings-io {
display: flex;
flex-direction: column;
gap: 6px;
width: 100%;
}
/* 隐藏文件选择器(仅以编程方式 click 触发) */
.file-input {
position: absolute;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
}
</style>

View File

@@ -0,0 +1,276 @@
<template>
<!-- 设置左导航(master-detail 左栏)
阶段3 UX 重构:6 类导航(外观/AI模型/知识库/性能/安全/高级)+ emoji 图标
阶段4 UX 重构:顶部搜索框 匹配 label/desc 高亮匹配类 + 选中首匹配类
+ emit scroll-target( Settings.vue 滚动到对应 setting-row)
activeCategory 受控(双向),v-model Settings.vue 绑定 + appSettings 持久化
底部留导入导出占位(阶段6 接入) -->
<nav class="settings-nav" :class="{ 'is-horizontal': horizontal }">
<!-- 顶部搜索框(阶段4) -->
<div v-if="!horizontal" class="nav-search">
<span class="search-icon">🔍</span>
<input
v-model="searchKeyword"
class="search-input"
type="text"
:placeholder="$t('settings.searchPlaceholder')"
/>
<!-- 无匹配时显无结果提示(非空关键词且零命中) -->
<span v-if="searchKeyword.trim() && matchedCategories.length === 0" class="search-no-result">
{{ $t('settings.searchNoResult') }}
</span>
</div>
<!-- 分类列表 -->
<ul class="nav-list">
<li
v-for="cat in categories"
:key="cat.value"
class="nav-item"
:class="{
'nav-item--active': cat.value === modelValue,
'nav-item--matched': isMatchedCategory(cat.value),
}"
@click="$emit('update:modelValue', cat.value)"
>
<span class="nav-icon">{{ cat.icon }}</span>
<span class="nav-label">{{ $t(cat.labelKey) }}</span>
</li>
</ul>
<!-- 底部导入导出占位(阶段6 实现) -->
<div class="nav-footer-slot">
<slot name="footer" />
</div>
</nav>
</template>
<script setup lang="ts">
// ============================================================
// 左导航 — 静态分类定义 + 受控 activeCategory(v-model) + 搜索(阶段4)
// 分类按用户任务域划分(对齐 plan 设置项分类映射):
// appearance 外观(theme/language/aiLanguage/showTokenUsage)
// ai-model AI 模型(provider 列表 + 负载均衡池 + 模型拉取)
// knowledge 知识库(auto_extract/trigger_mode/embedding_*)
// performance 性能(global/per-conv concurrency/max-iterations/max-retries)
// security 安全(allowed_dirs)
// advanced 高级(autoExecute/logLevel + 连接)
// ============================================================
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { SETTINGS_INDEX, type SettingsCategory, type SettingIndexEntry } from './searchIndex'
// re-export SettingsCategory 供 Settings.vue import(类型定义迁至 searchIndex.ts
// 避免组件 ↔ 索引文件循环导入)
export type { SettingsCategory }
interface NavCategory {
value: SettingsCategory
icon: string
labelKey: string
}
const categories: NavCategory[] = [
{ value: 'appearance', icon: '🎨', labelKey: 'settings.navAppearance' },
{ value: 'ai-model', icon: '🤖', labelKey: 'settings.navAiModel' },
{ value: 'knowledge', icon: '📚', labelKey: 'settings.navKnowledge' },
{ value: 'performance', icon: '⚡', labelKey: 'settings.navPerformance' },
{ value: 'security', icon: '🔒', labelKey: 'settings.navSecurity' },
{ value: 'advanced', icon: '⚙️', labelKey: 'settings.navAdvanced' },
]
const props = defineProps<{
/** 当前激活分类(v-model) */
modelValue: SettingsCategory
/** 窄屏横向 tab 模式(由父 Settings.vue 据 @media 控制) */
horizontal?: boolean
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: SettingsCategory): void
/** 搜索命中首匹配项时上抛,父 Settings.vue 据此滚动右侧 setting-row */
(e: 'scroll-target', key: string): void
}>()
// ============================================================
// 搜索(阶段4)
// ------------------------------------------------------------
// 索引项的 label/desc 用当前 locale 翻译成文本,小写 includes 模糊匹配。
// matchedCategories:命中项去重后的分类集合(左导航高亮)。
// 首匹配项变化时(输入/选中分类切换)→ emit scroll-target,父组件滚动定位。
// ============================================================
const { t } = useI18n()
const searchKeyword = ref('')
/** 判定单条索引项是否命中关键词(label + desc 文本,小写 includes) */
function matchEntry(entry: SettingIndexEntry, kw: string): boolean {
const label = t(entry.labelKey)
const desc = entry.descKey ? t(entry.descKey) : ''
const hay = `${label} ${desc}`.toLowerCase()
return hay.includes(kw)
}
/** 当前关键词命中的全部索引项(去 locale 文本匹配) */
const matchedEntries = computed<SettingIndexEntry[]>(() => {
const kw = searchKeyword.value.trim().toLowerCase()
if (!kw) return []
return SETTINGS_INDEX.filter((e) => matchEntry(e, kw))
})
/** 命中项去重后的分类集合(左导航高亮) */
const matchedCategories = computed<SettingsCategory[]>(() => {
const set = new Set<SettingsCategory>()
for (const e of matchedEntries.value) set.add(e.category)
return [...set]
})
/** 分类是否在匹配集中(模板高亮用) */
function isMatchedCategory(cat: SettingsCategory): boolean {
if (!searchKeyword.value.trim()) return false
return matchedCategories.value.includes(cat)
}
/** 关键词变化 → 选中首匹配类 + 上抛首匹配项 key(触发右侧滚动定位)。
* 空关键词时不改 activeCategory(用户清空搜索应留在当前类),也不上抛。 */
watch(
() => searchKeyword.value,
(kw) => {
const trimmed = kw.trim()
if (!trimmed) return
const first = matchedEntries.value[0]
if (!first) return
if (props.modelValue !== first.category) {
emit('update:modelValue', first.category)
}
// 分类切换后需等右侧 Section 挂载,父组件 watch scroll-target 时自带 nextTick
emit('scroll-target', first.key)
},
)
defineExpose({
/** 清空搜索框(父组件切分类时可调用,但当前保留搜索状态便于用户继续搜) */
clearSearch() {
searchKeyword.value = ''
},
})
</script>
<style scoped>
/* 左导航容器:固定宽度 ~200px,纵向 flex(search 顶 / list 中 flex 占满 / footer 底) */
.settings-nav {
display: flex;
flex-direction: column;
width: 200px;
flex-shrink: 0;
gap: 12px;
}
/* ===== 搜索框(阶段4) =====
宽度跟随 nav(200px),相对定位承载无结果提示浮层。 */
.nav-search {
position: relative;
display: flex;
align-items: center;
flex-shrink: 0;
}
.search-icon {
position: absolute;
left: 8px;
font-size: 12px;
pointer-events: none;
opacity: 0.7;
}
.search-input {
width: 100%;
padding: 6px 10px 6px 26px;
background: var(--df-bg);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
color: var(--df-text);
font-size: 12px;
outline: none;
transition: border-color 0.15s;
}
.search-input:focus { border-color: var(--df-accent); }
.search-input::placeholder { color: var(--df-text-dim); }
/* 无结果提示:浮在搜索框右下,小字 + 红色提示 */
.search-no-result {
position: absolute;
top: 100%;
right: 0;
margin-top: 2px;
font-size: 11px;
color: var(--df-danger);
white-space: nowrap;
}
.nav-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-radius: var(--df-radius-sm);
cursor: pointer;
font-size: 13px;
color: var(--df-text-secondary);
transition: background 0.15s, color 0.15s;
user-select: none;
}
.nav-item:hover {
background: var(--df-bg-card);
color: var(--df-text);
}
.nav-item--active {
background: var(--df-accent-bg);
color: var(--df-accent);
font-weight: 500;
}
.nav-item--active:hover {
background: var(--df-accent-bg);
color: var(--df-accent);
}
/* 搜索命中类高亮(左侧色条 + 强调色),即便非当前选中也提示用户该类有匹配 */
.nav-item--matched {
box-shadow: inset 2px 0 0 var(--df-accent);
color: var(--df-text);
}
.nav-item--matched.nav-item--active {
color: var(--df-accent);
}
.nav-icon { font-size: 16px; line-height: 1; }
.nav-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.nav-footer-slot { flex-shrink: 0; margin-top: auto; }
/* ===== 窄屏响应式:nav 折叠为横向 tab(@media 由父 Settings.vue 控制,
此处仅保证横向模式下 nav-item 排列正确)。窄屏不显搜索框(横向空间不足,
搜索改为后续可扩展) ===== */
.settings-nav.is-horizontal {
width: 100%;
flex-direction: column;
}
.settings-nav.is-horizontal .nav-list {
flex-direction: row;
overflow-x: auto;
gap: 4px;
}
.settings-nav.is-horizontal .nav-item {
flex-shrink: 0;
}
/* 横向模式下命中色条改下边框(左侧 inset 阴影在横向 tab 不显眼) */
.settings-nav.is-horizontal .nav-item--matched {
box-shadow: none;
border-bottom: 2px solid var(--df-accent);
}
</style>

View File

@@ -0,0 +1,88 @@
// ============================================================
// 设置项搜索索引(阶段4 UX 重构)
// ------------------------------------------------------------
// 静态表:覆盖全部设置项,每项绑定 i18n label/desc key(运行时按当前 locale
// 翻译成文本做匹配)。新增设置项时同步此索引,避免索引与设置项漂移(对齐 plan
// 阶段4 风险项)。分类 value 与 SettingsNav SettingsCategory 一致。
//
// 匹配维度:label + desc(当前 locale 翻译值),小写 includes 模糊匹配。
// 搜索命中 → 左导航高亮匹配类 + 选中首匹配类 + 右侧滚动到对应 setting-row
// (靠 setting-row 上的 data-setting-key 属性定位,见 SettingsNav + Settings.vue)。
// ============================================================
/** 分类值类型 — 与 Settings.vue 渲染分支 + SettingsNav categories 一致。
* 定义在此处(纯 .ts,无循环导入),SettingsNav.vue re-export 以兼容现有引用。 */
export type SettingsCategory =
| 'appearance'
| 'ai-model'
| 'knowledge'
| 'performance'
| 'security'
| 'advanced'
/** 搜索索引项:category 所属分类 + labelKey/descKey(i18n key) + key 唯一标识 */
export interface SettingIndexEntry {
/** 所属分类(与左导航 SettingsCategory 一致) */
category: SettingsCategory
/** 设置项 label 的 i18n key(如 'settings.labelTheme') */
labelKey: string
/** 设置项 desc 的 i18n key(可空,部分项仅有 label) */
descKey?: string
/** 唯一标识,对应右侧 setting-row 上 data-setting-key(用于 scrollIntoView 定位) */
key: string
}
/**
* 设置项静态索引 —— 覆盖全部 6 类设置项。
* 分类映射(对齐 plan):
* appearance 外观(theme/language/aiLanguage/showTokenUsage)
* ai-model AI 模型(provider 列表 / 负载均衡池 / 模型拉取)
* knowledge 知识库(auto_extract/trigger_mode/min_messages/auto_inject/
* vector_enabled/embedding_provider/embedding_model)
* performance 性能(global-concurrency/per-conv-concurrency/
* max-iterations/max-retries)
* security 安全(allowed_dirs)
* advanced 高级(autoExecute/logLevel + 连接)
*/
export const SETTINGS_INDEX: SettingIndexEntry[] = [
// ===== 外观 =====
{ category: 'appearance', key: 'theme', labelKey: 'settings.labelTheme', descKey: 'settings.descTheme' },
{ category: 'appearance', key: 'language', labelKey: 'settings.labelLanguage', descKey: 'settings.descLanguage' },
{ category: 'appearance', key: 'aiLanguage', labelKey: 'settings.labelAiLanguage', descKey: 'settings.descAiLanguage' },
{ category: 'appearance', key: 'showTokenUsage', labelKey: 'settings.labelShowTokenUsage', descKey: 'settings.descShowTokenUsage' },
// 加固(索引漂移):streamingMd 在 AppearanceSection.vue 渲染(AR-1 流式 Markdown 渲染开关),
// 此前漏入索引 → 搜索"流式/markdown/渲染"无法命中该项。补入 appearance 类与渲染处对齐。
{ category: 'appearance', key: 'streamingMd', labelKey: 'settings.labelStreamingMd', descKey: 'settings.descStreamingMd' },
// ===== AI 模型(provider 卡片级设置在 ProviderPanel 动态渲染,此处索引面板标题 +
// 负载均衡池/模型拉取等关键交互项,i18n 文案可被关键词命中) =====
{ category: 'ai-model', key: 'provider', labelKey: 'settings.panelAiProvider' },
{ category: 'ai-model', key: 'addProvider', labelKey: 'settings.addProvider' },
{ category: 'ai-model', key: 'poolEnabled', labelKey: 'settings.detailPoolEnabled', descKey: 'settings.descPoolEnabled' },
{ category: 'ai-model', key: 'poolWeight', labelKey: 'settings.detailPoolWeight', descKey: 'settings.descPoolWeight' },
{ category: 'ai-model', key: 'testConnection', labelKey: 'settings.testConnection' },
{ category: 'ai-model', key: 'modelList', labelKey: 'settings.modelListTitle' },
// ===== 知识库 =====
{ category: 'knowledge', key: 'autoExtract', labelKey: 'settings.labelAutoExtract', descKey: 'settings.descAutoExtract' },
{ category: 'knowledge', key: 'triggerMode', labelKey: 'settings.labelTriggerMode', descKey: 'settings.descTriggerMode' },
{ category: 'knowledge', key: 'minMessages', labelKey: 'settings.labelMinMessages', descKey: 'settings.descMinMessages' },
{ category: 'knowledge', key: 'autoInject', labelKey: 'settings.labelAutoInject', descKey: 'settings.descAutoInject' },
{ category: 'knowledge', key: 'vectorEnabled', labelKey: 'settings.labelVectorEnabled', descKey: 'settings.descVectorEnabled' },
{ category: 'knowledge', key: 'embeddingProvider', labelKey: 'settings.labelEmbeddingProvider', descKey: 'settings.descEmbeddingProvider' },
{ category: 'knowledge', key: 'embeddingModel', labelKey: 'settings.labelEmbeddingModel', descKey: 'settings.descEmbeddingModel' },
// ===== 性能 =====
{ category: 'performance', key: 'globalConcurrency', labelKey: 'settings.labelGlobalConcurrency', descKey: 'settings.descGlobalConcurrency' },
{ category: 'performance', key: 'perConvConcurrency', labelKey: 'settings.labelPerConvConcurrency', descKey: 'settings.descPerConvConcurrency' },
{ category: 'performance', key: 'agentMaxIterations', labelKey: 'settings.labelAgentMaxIterations', descKey: 'settings.descAgentMaxIterations' },
{ category: 'performance', key: 'agentMaxRetries', labelKey: 'settings.labelAgentMaxRetries', descKey: 'settings.descAgentMaxRetries' },
// ===== 安全 =====
{ category: 'security', key: 'allowedDirs', labelKey: 'settings.panelAllowedDirs', descKey: 'settings.descAllowedDirs' },
// ===== 高级 =====
{ category: 'advanced', key: 'autoExecute', labelKey: 'settings.labelAutoExecute', descKey: 'settings.descAutoExecute' },
{ category: 'advanced', key: 'logLevel', labelKey: 'settings.labelLogLevel', descKey: 'settings.descLogLevel' },
{ category: 'advanced', key: 'connection', labelKey: 'settings.panelConnection', descKey: 'settings.emptyConnection' },
]

View File

@@ -0,0 +1,69 @@
/* DevFlow 设置统一组件库(UX 重构阶段1)
各 Panel scoped 副本抽取到全局,消除 panel/setting 系列/toggle/btn 5/5+3/5 重复。
来源基准:GeneralPanel/KnowledgePanel scoped(已确认 CSS 一致)。
各 Panel scoped 仅保留特有样式:provider-card/dir-row/connection-card 等。 */
/* ===== 面板容器(5/5 Panel 重复) ===== */
.panel {
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg);
padding: var(--df-pad-panel);
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--df-gap-head);
}
.panel-header h2 { font-size: 15px; font-weight: 500; }
/* ===== 设置行容器(表单型 Panel 共用) ===== */
.general-settings { display: flex; flex-direction: column; }
.setting-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 0;
border-bottom: 0.5px solid var(--df-border);
}
.setting-row:last-child { border-bottom: none; }
.setting-info { display: flex; flex-direction: column; gap: 2px; }
.setting-label { font-size: 14px; font-weight: 500; color: var(--df-text); }
.setting-desc { font-size: 12px; color: var(--df-text-dim); }
.setting-control { display: flex; align-items: center; }
/* ===== 控件(5/5 Panel 重复) ===== */
.setting-select {
padding: 6px 12px;
background: var(--df-bg);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
color: var(--df-text);
font-size: 13px;
outline: none;
cursor: pointer;
min-width: 160px;
}
.setting-select:focus { border-color: var(--df-accent); }
/* Toggle(3/5 Panel 重复) */
.toggle { position: relative; display: inline-block; width: 40px; height: 22px; cursor: pointer; }
.toggle input { opacity: 0; width: 0; height: 0; }
.toggle-slider { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: var(--df-border); border-radius: var(--df-radius-lg); transition: background 0.2s; }
.toggle-slider::before { content: ''; position: absolute; width: 16px; height: 16px; left: 3px; bottom: 3px; background: var(--df-text-dim); border-radius: 50%; transition: transform 0.2s, background 0.2s; }
.toggle input:checked + .toggle-slider { background: var(--df-accent); }
.toggle input:checked + .toggle-slider::before { transform: translateX(18px); background: #fff; }
/* ===== 按钮(原 Settings.vue,列表型 Panel 共用) ===== */
.btn { padding: 8px 16px; border: none; border-radius: var(--df-radius-sm); font-size: 13px; cursor: pointer; transition: all 0.15s; }
.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-ghost:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-sm { padding: 4px 12px; font-size: 12px; }
/* ===== 空提示(列表型 Panel 共用) ===== */
.empty-hint { color: var(--df-text-dim); font-size: 13px; padding: 16px 0; text-align: center; }

View File

@@ -3,6 +3,25 @@ export default {
// ===== Page title =====
title: '⚙️ Settings',
// ===== Left nav categories (phase 3 master-detail) =====
navAppearance: 'Appearance',
navAiModel: 'AI Models',
navKnowledge: 'Knowledge',
navPerformance: 'Performance',
navSecurity: 'Security',
navAdvanced: 'Advanced',
// ===== Settings search (phase 4) =====
searchPlaceholder: 'Search settings…',
searchNoResult: 'No results',
// ===== Instant feedback (phase 5 SettingRow) =====
savedHint: '✓ Saved',
nextRoundBadge: 'Next message',
// ===== Connection mgmt (phase 7: experimental badge, not wired to AI yet) =====
experimentalBadge: 'Experimental · not wired to AI tools',
// ===== AI Provider panel =====
panelAiProvider: '🤖 AI Model Config',
addProvider: '+ Add Provider',
@@ -73,6 +92,10 @@ export default {
// ===== General settings panel =====
panelGeneral: '🎨 General',
// 阶段2 UX 重构:GeneralPanel 拆 3 Section 后各 Section 标题
sectionAppearance: '🎨 Appearance',
sectionPerformance: '⚡ Performance',
sectionAdvanced: '⚙️ Advanced',
labelTheme: 'Theme',
descTheme: 'Dark / light',
themeDark: '🌙 Dark',
@@ -97,6 +120,9 @@ export default {
labelShowTokenUsage: 'Show token usage',
descShowTokenUsage: 'Show token consumption of each reply in conversations',
labelStreamingMd: 'Streaming Markdown rendering',
descStreamingMd: 'Render code blocks/lists/headings during streaming (unclosed code blocks degrade to plain text to avoid flicker); when off, only plain text is shown during streaming and full formatting applies after generation completes',
labelGlobalConcurrency: 'Global max concurrency',
descGlobalConcurrency: 'Shared cap on simultaneous LLM requests across all conversations',
@@ -104,7 +130,8 @@ export default {
descPerConvConcurrency: 'Concurrent LLM calls within one conversation (main loop / title / distill shared)',
labelAgentMaxIterations: 'Agent max iterations',
descAgentMaxIterations: 'Max rounds of the agentic loop (stream → tool → feedback) (1-50, default 10); changes take effect on the next message',
descAgentMaxIterations: 'Max rounds of the agentic loop (stream → tool → feedback) (0=unlimited, 1-50, default 10); changes take effect on the next message',
labelUnlimited: 'Unlimited',
labelAgentMaxRetries: 'Stream failure retries',
descAgentMaxRetries: 'Auto-retry count on stream failure (0-10, default 3); only retries pre-stream failures (no token output yet); mid-stream failures are abandoned',
@@ -131,12 +158,9 @@ export default {
labelTriggerMode: 'Distill trigger',
descTriggerMode: 'When auto-distillation is triggered',
triggerOnComplete: 'On conversation complete',
triggerOnIdle: 'On conversation idle',
triggerManualOnly: 'Manual only',
labelMinMessages: 'Min message count',
descMinMessages: 'Distillation triggers only after this many messages (filters out small talk noise)',
labelIdleTimeout: 'Idle timeout (s)',
descIdleTimeout: 'Seconds to wait in idle-trigger mode',
labelAutoInject: 'Auto-inject related knowledge',
descAutoInject: 'Retrieve related knowledge and inject it into the conversation context during chat',
labelVectorEnabled: 'Semantic retrieval (vector)',
@@ -159,6 +183,18 @@ export default {
toastSetDefaultFail: 'Failed to set default: {msg}',
toastConnIncomplete: 'Please fill in Name and Host',
// ===== Import / Export (phase 6) =====
export: 'Export',
import: 'Import',
exportHint: 'Export all preferences (appSettings + knowledge config) as a JSON file',
importHint: 'Import from a JSON file and overwrite current preferences',
importConfirm: 'Importing will overwrite all current preferences (appearance / performance / knowledge, etc.). Continue?',
importSuccess: 'Imported successfully (some items like concurrency / iterations take effect after restart or next message)',
importFail: 'Import failed: {msg}',
exportFail: 'Export failed: {msg}',
importInvalidJson: 'The file is not a valid DevFlow settings backup (wrong format)',
importScopeNote: 'Scope: appSettings + knowledge config (no provider keys)',
// ===== Confirm dialogs =====
confirmDeleteProvider: 'Delete provider "{name}"? This cannot be undone.',
confirmDeleteConn: 'Delete connection "{name}"?',

View File

@@ -3,6 +3,25 @@ export default {
// ===== 页面标题 =====
title: '⚙️ 设置',
// ===== 左导航分类(阶段3 master-detail) =====
navAppearance: '外观',
navAiModel: 'AI 模型',
navKnowledge: '知识库',
navPerformance: '性能',
navSecurity: '安全',
navAdvanced: '高级',
// ===== 设置搜索(阶段4) =====
searchPlaceholder: '搜索设置项…',
searchNoResult: '无结果',
// ===== 即时生效反馈(阶段5 SettingRow) =====
savedHint: '✓ 已保存',
nextRoundBadge: '下次对话生效',
// ===== 连接管理(阶段7:实验功能 badge 明示暂未接入 AI) =====
experimentalBadge: '实验功能·暂未接入 AI 工具',
// ===== AI 模型配置面板 =====
panelAiProvider: '🤖 AI 模型配置',
addProvider: '+ 添加 Provider',
@@ -73,6 +92,10 @@ export default {
// ===== 通用设置面板 =====
panelGeneral: '🎨 通用设置',
// 阶段2 UX 重构:GeneralPanel 拆 3 Section 后各 Section 标题
sectionAppearance: '🎨 外观',
sectionPerformance: '⚡ 性能',
sectionAdvanced: '⚙️ 高级',
labelTheme: '主题',
descTheme: '深色 / 浅色模式',
themeDark: '🌙 深色模式',
@@ -97,6 +120,9 @@ export default {
labelShowTokenUsage: '显示 Token 用量',
descShowTokenUsage: '在对话中展示每次回复的 token 消耗',
labelStreamingMd: '流式 Markdown 渲染',
descStreamingMd: '流式生成过程中渲染代码块/列表/标题格式(未闭合代码块降级纯文本防闪烁);关闭后流式期间仅显纯文本,生成完成后才渲染完整格式',
labelGlobalConcurrency: '全局最大并发',
descGlobalConcurrency: '所有对话共享的 LLM 同时请求数上限',
@@ -104,7 +130,8 @@ export default {
descPerConvConcurrency: '单个对话内同时进行的 LLM 调用数(主循环 / 标题 / 提炼共享)',
labelAgentMaxIterations: 'Agent 最大轮次',
descAgentMaxIterations: 'Agentic 循环流式→工具→回传的最大轮次1-50默认 10热改下次发消息生效',
descAgentMaxIterations: 'Agentic 循环(流式→工具→回传)的最大轮次(0=不限,1-50默认 10热改下次发消息生效',
labelUnlimited: '不限',
labelAgentMaxRetries: '流式失败重试次数',
descAgentMaxRetries: '流式对话失败时自动重试次数0-10默认 3只重试未输出任何 token 的流前失败,已输出文本的失败直接放弃',
@@ -131,12 +158,9 @@ export default {
labelTriggerMode: '提炼触发方式',
descTriggerMode: '自动提炼的触发时机',
triggerOnComplete: '对话完成后',
triggerOnIdle: '对话闲置后',
triggerManualOnly: '仅手动',
labelMinMessages: '最少消息数',
descMinMessages: '对话达到此消息数才触发提炼(防闲聊噪音)',
labelIdleTimeout: '闲置超时(秒)',
descIdleTimeout: '闲置触发模式下等待的秒数',
labelAutoInject: '自动注入相关知识',
descAutoInject: '聊天时自动检索相关知识注入对话上下文',
labelVectorEnabled: '语义检索(向量)',
@@ -159,6 +183,18 @@ export default {
toastSetDefaultFail: '设置默认失败:{msg}',
toastConnIncomplete: '请填写名称和 Host',
// ===== 导入 / 导出(阶段6) =====
export: '导出',
import: '导入',
exportHint: '导出全部偏好(appSettings + 知识库配置)为 JSON 文件',
importHint: '从 JSON 文件导入并覆盖当前偏好',
importConfirm: '导入将覆盖当前的全部偏好设置(外观 / 性能 / 知识库等),确定继续?',
importSuccess: '导入成功(部分项如并发 / 轮次需重启或下次对话生效)',
importFail: '导入失败:{msg}',
exportFail: '导出失败:{msg}',
importInvalidJson: '文件不是有效的 DevFlow 设置备份(格式不符)',
importScopeNote: '范围:appSettings + 知识库配置(不含 Provider 密钥)',
// ===== 确认弹层 =====
confirmDeleteProvider: '确定删除提供商「{name}」?该操作不可恢复。',
confirmDeleteConn: '确定删除连接「{name}」?',

View File

@@ -5,6 +5,7 @@ import i18n from "./i18n";
import "./styles/global.css";
import "./styles/ai-md.css";
import "./styles/components.css";
import "./components/settings/settings.css"; // UX 重构阶段1:设置统一组件库全局
const app = createApp(App);

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>