Files
DevFlow/src/views/Settings.vue
绝尘 4a95f6a1e2 重构: confirmDialog抽composable+tool_result反向查找+search列限定
- FR-D5 抽 useConfirm 收敛 4 视图(Projects/ProjectDetail/Ideas/Settings)确认弹层重复
- FR-D4 replace_tool_result_content 正向 O(n) 改反向 rposition(审批替换命中最近,调用低频)
- FR-D2 search_vector SELECT * 改显式 14 列(消除隐式依赖,字段级精简待单独立项)
2026-06-14 22:50:59 +08:00

1033 lines
38 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>
<div class="settings-grid">
<!-- AI 模型配置 -->
<section class="panel">
<div class="panel-header">
<h2>{{ $t('settings.panelAiProvider') }}</h2>
<button class="btn btn-ghost btn-sm" @click="openProviderForm()">{{ $t('settings.addProvider') }}</button>
</div>
<div class="provider-list" v-if="aiProviders.length > 0">
<div class="provider-card" :class="{ 'provider-card--default': p.is_default }" v-for="p in aiProviders" :key="p.id">
<div class="provider-top">
<div class="provider-info">
<span class="provider-name">{{ p.name }}</span>
<span v-if="p.is_default" class="default-badge">{{ $t('settings.badgeDefault') }}</span>
</div>
<div class="provider-actions">
<button class="btn-link" @click="setDefaultProvider(p.id)" v-if="!p.is_default">{{ $t('settings.actionSetDefault') }}</button>
<button class="btn-link" @click="openProviderForm(p)">{{ $t('common.edit') }}</button>
<button class="btn-link btn-link-danger" @click="deleteProvider(p.id)">{{ $t('common.delete') }}</button>
</div>
</div>
<div class="provider-detail">
<div class="detail-row">
<span class="detail-label">{{ $t('settings.detailBaseUrl') }}</span>
<span class="detail-value">{{ p.base_url }}</span>
</div>
<div class="detail-row">
<span class="detail-label">{{ $t('settings.detailModel') }}</span>
<span class="detail-value">{{ p.default_model }}</span>
</div>
<div class="detail-row">
<span class="detail-label">{{ $t('settings.detailApiKey') }}</span>
<span class="detail-value mask">{{ maskKey(p.api_key) }}</span>
</div>
</div>
</div>
</div>
<div class="empty-hint" v-else>{{ $t('settings.emptyProvider') }}</div>
</section>
<!-- AI 提供商表单 -->
<section class="panel" v-if="providerForm.visible">
<div class="panel-header">
<h2>{{ providerForm.editId ? $t('settings.editProviderTitle') : $t('settings.addProviderTitle') }}</h2>
<button class="btn btn-ghost btn-sm" @click="providerForm.visible = false">{{ $t('common.cancel') }}</button>
</div>
<div class="form-grid">
<div class="form-field">
<label class="form-label">{{ $t('settings.labelName') }}</label>
<input v-model="providerForm.name" class="setting-input" :placeholder="$t('settings.phProviderName')" />
</div>
<div class="form-field">
<label class="form-label">{{ $t('settings.labelProviderType') }}</label>
<select v-model="providerForm.providerType" class="setting-select">
<option value="openai_compat">{{ $t('settings.providerTypeOpenaiCompat') }}</option>
<option value="anthropic">{{ $t('settings.providerTypeAnthropic') }}</option>
</select>
</div>
<div class="form-field">
<label class="form-label">{{ $t('settings.labelBaseUrl') }}</label>
<input v-model="providerForm.baseUrl" class="setting-input" :placeholder="$t('settings.phBaseUrl')" />
</div>
<div class="form-field">
<label class="form-label">{{ $t('settings.labelApiKey') }}</label>
<input v-model="providerForm.apiKey" class="setting-input" type="password" :placeholder="$t('settings.phApiKey')" />
</div>
<div class="form-field">
<label class="form-label">{{ $t('settings.labelDefaultModel') }}</label>
<input v-model="providerForm.defaultModel" class="setting-input" :placeholder="$t('settings.phDefaultModel')" />
</div>
<div class="form-actions">
<button class="btn btn-primary" @click="saveProvider" :disabled="providerForm.saving">
{{ providerForm.saving ? $t('settings.saving') : $t('common.save') }}
</button>
</div>
</div>
</section>
<!-- 连接管理 -->
<section class="panel">
<div class="panel-header">
<h2>{{ $t('settings.panelConnection') }}</h2>
<button class="btn btn-ghost btn-sm" @click="openConnForm()">{{ $t('settings.addConnection') }}</button>
</div>
<div class="connection-list" v-if="connections.length > 0">
<div class="connection-card" v-for="conn in connections" :key="conn.id">
<div class="conn-left">
<span class="conn-icon">{{ typeIcon(conn.type) }}</span>
<div class="conn-info">
<div class="conn-name-row">
<span class="conn-name">{{ conn.name }}</span>
<span class="conn-type" :class="'type-' + conn.type">{{ typeLabel(conn.type) }}</span>
</div>
<span class="conn-host">{{ conn.host }}{{ conn.port ? ':' + conn.port : '' }}</span>
<span class="conn-user" v-if="conn.user">{{ conn.user }}</span>
</div>
</div>
<div class="conn-actions">
<button class="btn-link" @click="openConnForm(conn)">{{ $t('common.edit') }}</button>
<button class="btn-link btn-link-danger" @click="removeConn(conn.id)">{{ $t('common.delete') }}</button>
</div>
</div>
</div>
<div class="empty-hint" v-else>{{ $t('settings.emptyConnection') }}</div>
</section>
<!-- 连接表单 -->
<section class="panel" v-if="connForm.visible">
<div class="panel-header">
<h2>{{ connForm.editId ? $t('settings.editConnectionTitle') : $t('settings.addConnectionTitle') }}</h2>
<button class="btn btn-ghost btn-sm" @click="connForm.visible = false">{{ $t('common.cancel') }}</button>
</div>
<div class="form-grid">
<div class="form-field">
<label class="form-label">{{ $t('settings.labelName') }}</label>
<input v-model="connForm.name" class="setting-input" :placeholder="$t('settings.phConnName')" />
</div>
<div class="form-field">
<label class="form-label">{{ $t('settings.labelType') }}</label>
<select v-model="connForm.type" class="setting-select">
<option value="mysql">MySQL</option>
<option value="ssh">SSH</option>
<option value="redis">Redis</option>
<option value="mongo">MongoDB</option>
</select>
</div>
<div class="form-field">
<label class="form-label">{{ $t('settings.labelHost') }}</label>
<input v-model="connForm.host" class="setting-input" :placeholder="$t('settings.phHost')" />
</div>
<div class="form-field">
<label class="form-label">{{ $t('settings.labelPort') }}</label>
<input v-model.number="connForm.port" class="setting-input" type="number" :placeholder="$t('settings.phPort')" />
</div>
<div class="form-field">
<label class="form-label">{{ $t('settings.labelUser') }}</label>
<input v-model="connForm.user" class="setting-input" :placeholder="$t('settings.phUser')" />
</div>
<div class="form-actions">
<button class="btn btn-primary" @click="saveConn">{{ $t('common.save') }}</button>
</div>
</div>
</section>
<!-- 通用设置 -->
<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>
</section>
<section class="panel">
<div class="panel-header">
<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>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { aiApi, knowledgeApi } from '../api'
import { useAppSettingsStore } from '../stores/appSettings'
import { useConfirm } from '../composables/useConfirm'
import type { AiProviderConfig } from '../api/types'
import i18n from '../i18n'
const { t } = useI18n()
const appSettings = useAppSettingsStore()
// ============================================================
// AI 提供商 — 真实后端 CRUD
// ============================================================
const aiProviders = ref<AiProviderConfig[]>([])
// 顶部轻量提示(错误/警告/完成3s 自动消失;零依赖,替代未接入的 Arco Message
const toast = reactive({ visible: false, msg: '', type: 'info' as 'error' | 'warning' | 'info' })
let _toastTimer: ReturnType<typeof setTimeout> | null = null
/** 安全提取错误消息——Tauri IPC 错误是字符串非 Error 对象 */
function errMsg(e: unknown): string { return e instanceof Error ? e.message : String(e) }
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()
const providerForm = reactive({
visible: false,
editId: '' as string,
name: '',
providerType: 'openai_compat',
baseUrl: '',
apiKey: '',
defaultModel: '',
saving: false,
})
async function loadProviders() {
try {
aiProviders.value = await aiApi.listProviders()
} catch (e) {
console.error(t('settings.toastLoadProviderFail'), e)
showToast(t('settings.toastLoadProviderFail'), 'error')
}
}
function openProviderForm(p?: AiProviderConfig) {
if (p) {
providerForm.editId = p.id
providerForm.name = p.name
providerForm.baseUrl = p.base_url
providerForm.apiKey = '' // 编辑不回填 api_key(list 返回 mask,留空=不改,FR-S1)
providerForm.defaultModel = p.default_model
providerForm.providerType = p.provider_type || 'openai_compat'
} else {
providerForm.editId = ''
providerForm.name = ''
providerForm.providerType = 'openai_compat'
providerForm.baseUrl = ''
providerForm.apiKey = ''
providerForm.defaultModel = ''
}
providerForm.visible = true
}
async function saveProvider() {
// apiKey:新建必填;编辑可留空(不改,FR-S1 后端保留原 DB 值)
const needKey = !providerForm.editId
if (!providerForm.name || !providerForm.baseUrl || (needKey && !providerForm.apiKey) || !providerForm.defaultModel) {
showToast(t('settings.toastSaveIncomplete'), 'warning')
return
}
providerForm.saving = true
try {
await aiApi.saveProvider({
id: providerForm.editId || undefined,
name: providerForm.name,
providerType: providerForm.providerType,
baseUrl: providerForm.baseUrl,
apiKey: providerForm.apiKey,
defaultModel: providerForm.defaultModel,
})
providerForm.visible = false
await loadProviders()
showToast(t('settings.toastSaved'), 'info')
} catch (e) {
console.error(t('settings.toastSaveFail', { msg: errMsg(e) }), e)
showToast(t('settings.toastSaveFail', { msg: errMsg(e) }), 'error')
} finally {
providerForm.saving = false
}
}
async function deleteProvider(id: string) {
const p = aiProviders.value.find(x => x.id === id)
if (!await confirmDialog(t('settings.confirmDeleteProvider', { name: p?.name || id }))) return
try {
await aiApi.deleteProvider(id)
await loadProviders()
showToast(t('settings.toastDeleted'), 'info')
} catch (e) {
console.error(t('settings.toastDeleteFail', { msg: errMsg(e) }), e)
showToast(t('settings.toastDeleteFail', { msg: errMsg(e) }), 'error')
}
}
async function setDefaultProvider(id: string) {
try {
await aiApi.setProvider(id)
await loadProviders()
showToast(t('settings.toastSetDefaultOk'), 'info')
} catch (e) {
console.error(t('settings.toastSetDefaultFail', { msg: errMsg(e) }), e)
showToast(t('settings.toastSetDefaultFail', { msg: errMsg(e) }), 'error')
}
}
function maskKey(key: string): string {
if (!key || key.length < 8) return '••••••••'
return key.slice(0, 4) + '••••••••' + key.slice(-4)
}
// ============================================================
// 连接管理 — localStorage CRUD
// ============================================================
interface ConnRecord {
id: string
name: string
type: string
host: string
port: number
user: string
}
const connections = ref<ConnRecord[]>([])
const connForm = reactive({
visible: false,
editId: '' as string,
name: '',
type: 'mysql',
host: '',
port: 3306,
user: '',
})
const CONN_STORAGE_KEY = 'df-connections'
function loadConnections() {
const stored = appSettings.get<ConnRecord[] | null>(CONN_STORAGE_KEY, null)
if (stored) connections.value = stored
}
function persistConnections() {
void appSettings.set(CONN_STORAGE_KEY, connections.value)
}
function typeIcon(type: string) {
const map: Record<string, string> = { mysql: '🗄️', ssh: '🔐', redis: '⚡', mongo: '🍃' }
return map[type] || '🔗'
}
function typeLabel(type: string) {
const map: Record<string, string> = { mysql: 'MySQL', ssh: 'SSH', redis: 'Redis', mongo: 'MongoDB' }
return map[type] || type
}
function openConnForm(conn?: ConnRecord) {
if (conn) {
connForm.editId = conn.id
connForm.name = conn.name
connForm.type = conn.type
connForm.host = conn.host
connForm.port = conn.port
connForm.user = conn.user
} else {
connForm.editId = ''
connForm.name = ''
connForm.type = 'mysql'
connForm.host = ''
connForm.port = 3306
connForm.user = ''
}
connForm.visible = true
}
function saveConn() {
if (!connForm.name || !connForm.host) {
showToast(t('settings.toastConnIncomplete'), 'warning')
return
}
const record: ConnRecord = {
id: connForm.editId || Date.now().toString(36),
name: connForm.name,
type: connForm.type,
host: connForm.host,
port: connForm.port,
user: connForm.user,
}
if (connForm.editId) {
const idx = connections.value.findIndex(c => c.id === connForm.editId)
if (idx >= 0) connections.value[idx] = record
} else {
connections.value.push(record)
}
persistConnections()
connForm.visible = false
}
async function removeConn(id: string) {
const c = connections.value.find(x => x.id === id)
if (!await confirmDialog(t('settings.confirmDeleteConn', { name: c?.name || id }))) return
connections.value = connections.value.filter(c => c.id !== id)
persistConnections()
showToast(t('settings.toastDeleted'), 'info')
}
// ============================================================
// 通用设置
// ============================================================
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),
})
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)
})
// 同步 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)
}
watch(() => settings.language, (val) => {
void appSettings.set('df-language', val)
// 同步 i18n 实例,让界面立即跟随语言切换
i18n.global.locale.value = val as 'zh-CN' | 'en'
})
// ============================================================
// 知识库配置(提取 + 注入) — 走后端 IPC,非 localStorage
// ============================================================
const knowledgeConfig = reactive({
loaded: false,
auto_extract: true,
trigger_mode: 'on_complete' as 'on_complete' | 'on_idle' | 'manual_only',
min_messages: 4,
idle_timeout_ms: 30000,
auto_inject: true,
vector_enabled: false,
embedding_provider_id: null as string | null,
embedding_model: null as string | null,
})
// 可做 embedding 的 provider(仅 openai_compat,Anthropic 无 embed API)
const embeddingProviders = computed(() =>
aiProviders.value.filter(p => p.provider_type !== 'anthropic')
)
async function loadKnowledgeConfig() {
try {
const cfg = await knowledgeApi.getConfig()
Object.assign(knowledgeConfig, cfg)
knowledgeConfig.loaded = true
} catch (e) {
console.error('加载知识库配置失败:', e)
}
}
let _knowledgeSaveTimer: ReturnType<typeof setTimeout> | null = null
function saveKnowledgeConfigDebounced() {
if (_knowledgeSaveTimer) clearTimeout(_knowledgeSaveTimer)
_knowledgeSaveTimer = setTimeout(async () => {
try {
const { loaded, ...cfg } = knowledgeConfig
void loaded
await knowledgeApi.saveConfig(cfg as any)
} catch (e) {
console.error('保存知识库配置失败:', e)
}
}, 500)
}
watch(
() => ({ ...knowledgeConfig }),
() => {
if (knowledgeConfig.loaded) saveKnowledgeConfigDebounced()
},
{ deep: true },
)
onMounted(() => {
applyTheme(settings.theme)
loadProviders()
loadConnections()
loadKnowledgeConfig()
// 启动同步一次 LLM 并发配置(防刷新页面后后端未恢复用户设定值)
syncConcurrencyConfig()
})
// 组件卸载清全部 debounce timer,防 unmount 后旧 timer 仍 fire 写已销毁 reactive(timer 泄漏 FR-C3)
onUnmounted(() => {
if (_concurrencyTimer) clearTimeout(_concurrencyTimer)
if (_knowledgeSaveTimer) clearTimeout(_knowledgeSaveTimer)
if (_toastTimer) clearTimeout(_toastTimer)
})
</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); }
/* ===== 按钮 ===== */
.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); }
/* ===== 设置网格 ===== */
.settings-grid {
display: flex;
flex-direction: column;
gap: var(--df-gap-grid);
}
/* ===== 面板 ===== */
.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;
}
/* ===== Provider 卡片 ===== */
.provider-list { display: flex; flex-direction: column; gap: 12px; }
.provider-card {
background: var(--df-bg);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
padding: 14px 16px;
}
.provider-card--default {
border-color: var(--df-accent);
box-shadow: inset 3px 0 0 var(--df-accent);
}
.provider-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.provider-info { display: flex; align-items: center; gap: 8px; }
.provider-name { font-size: 14px; font-weight: 500; color: var(--df-text); }
.default-badge {
font-size: 10px;
padding: 1px 6px;
border-radius: var(--df-radius-xs);
background: rgba(100,255,218,0.15);
color: var(--df-success);
font-weight: 500;
}
.provider-actions { display: flex; gap: 4px; }
.provider-detail { display: flex; flex-direction: column; gap: 6px; }
.detail-row { display: flex; align-items: flex-start; gap: 12px; }
.detail-label {
font-size: 12px;
color: var(--df-text-dim);
min-width: 60px;
}
.detail-value {
font-size: 12px;
color: var(--df-text-secondary);
font-family: var(--df-font-mono);
}
.detail-value.mask { color: var(--df-text-dim); letter-spacing: 1px; }
/* ===== 表单 ===== */
.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);
}
.form-actions {
display: flex;
justify-content: flex-end;
padding-top: 4px;
}
/* ===== 连接卡片 ===== */
.connection-list { display: flex; flex-direction: column; gap: 8px; }
.connection-card {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 14px;
background: var(--df-bg);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
}
.conn-left { display: flex; align-items: center; gap: 10px; }
.conn-icon { font-size: 18px; }
.conn-info { display: flex; flex-direction: column; gap: 2px; }
.conn-name-row { display: flex; align-items: center; gap: 8px; }
.conn-name { font-size: 14px; font-weight: 500; color: var(--df-text); }
.conn-type {
font-size: 10px;
padding: 2px 6px;
border-radius: var(--df-radius-sm);
font-weight: 500;
}
.type-mysql { background: rgba(100,181,246,0.15); color: var(--df-info); }
.type-ssh { background: rgba(108,99,255,0.15); color: var(--df-accent); }
.type-redis { background: rgba(255,107,107,0.15); color: var(--df-danger); }
.type-mongo { background: rgba(100,255,218,0.15); color: var(--df-success); }
.conn-host, .conn-user {
font-size: 12px;
color: var(--df-text-dim);
font-family: var(--df-font-mono);
}
.conn-user {
background: rgba(90,99,128,0.2);
padding: 1px 6px;
border-radius: var(--df-radius-sm);
}
.conn-actions { display: flex; gap: 4px; }
/* ===== 通用设置 ===== */
.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); }
.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); }
/* ===== 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;
}
</style>