Files
DevFlow/src/views/Settings.vue
T
lxy 25ec9cf69a 优化: 前端u-agent令牌对齐与组件拆分续批
- 全局样式类(df-sec-h/df-toast族)替换散装实现,主题token去硬编码兜底
- 拆分子组件:ai-chat/queue-panel(队列时钟与编辑随迁)、
  conversation-sidebar/ConversationList、settings/provider-panel
- 父组件死副本清理(队列编辑/时钟/键位表,TS6133清零)并补
  fill-draft父侧转发(重构半成品断链修复)
- 验收:vue-tsc EXIT 0
2026-10-18 23:15:57 +08:00

349 lines
15 KiB
Vue

<template>
<div class="settings">
<Transition name="df-toast">
<div v-if="toast.visible" class="df-toast" :class="toast.type === 'error' ? 'df-toast--danger' : 'df-toast--' + toast.type">{{ toast.msg }}</div>
</Transition>
<ConfirmDialog :visible="confirmState.visible" :msg="confirmState.msg" :danger-label="$t('common.delete')" @result="answerConfirm" />
<!-- 页面头部 -->
<header class="page-header">
<h1>{{ $t('settings.title') }}</h1>
</header>
<!-- ═══ master-detail 布局(阶段3 UX 重构)═══
左 SettingsNav(~200px 固定):6 类导航,activeCategory 受控 + appSettings 持久化
右 content(scroll):按 activeCategory 渲染对应 Section/Panel -->
<div class="settings-body">
<SettingsNav
v-model="activeCategory"
:horizontal="isNarrowScreen"
@scroll-target="scrollToSettingItem"
/>
<!-- 导入/导出已移除:实测价值低且占用 tab 底部空间,如需跨设备同步建议直接复制 SQLite 文件 -->
<div ref="contentRef" class="settings-content">
<!-- 外观:theme/language/aiLanguage/showTokenUsage -->
<AppearanceSection v-if="activeCategory === 'appearance'" />
<!-- AI 模型:provider 列表 + 负载均衡池 + 模型拉取 -->
<ProviderPanel
v-else-if="activeCategory === 'ai-model'"
ref="providerPanelRef"
:confirm-dialog="confirmDialog"
@toast="showToast"
@providers-changed="onProvidersChanged"
/>
<!-- AI Prompt 缓存总开关(KV df-ai-anthropic-cache,默认开;后端每请求读 settings,改后即时生效)
开:Anthropic 协议端点 system+历史段显式缓存断点;openai_compat 前缀稳定拆分(易变段挪消息流末尾);
后端按 provider_type 自动 gate,不支持的端点零影响。关 = 用户显式回退旧行为 -->
<section v-if="activeCategory === 'ai-model'" class="panel">
<div class="panel-header">
<h2>{{ $t('settings.panelAnthropicCache') }}</h2>
</div>
<SettingRow :label="$t('settings.labelAnthropicCache')" :desc="$t('settings.descAnthropicCache')">
<label class="toggle">
<input
type="checkbox"
:checked="anthropicCache"
@change="onAnthropicCacheChange(($event.target as HTMLInputElement).checked)"
/>
<span class="toggle-slider"></span>
</label>
</SettingRow>
</section>
<!-- 知识库:auto_extract/trigger_mode/embedding_* -->
<KnowledgePanel
v-else-if="activeCategory === 'knowledge'"
:ai-providers="aiProviders"
/>
<!-- 性能:global/per-conv concurrency/max-iterations/max-retries(@toast 接走查 P3-14 越界修正提示) -->
<PerformanceSection
v-else-if="activeCategory === 'performance'"
@toast="showToast"
/>
<!-- 安全:allowed_dirs -->
<AllowedDirsPanel
v-else-if="activeCategory === 'security'"
@toast="showToast"
/>
<!-- 集成:挂机模式(引擎自动认领看板任务,KV 配置热读)
+ 飞书渠道凭证(WS 直连飞书开放平台,整体保存到 KV feishu-config)
+ u-claw(ask_uclaw 工具,u-relay commander 协议三键配置) -->
<template v-else-if="activeCategory === 'integrations'">
<UnattendedPanel @toast="showToast" />
<FeishuPanel @toast="showToast" />
<UclawPanel @toast="showToast" />
</template>
<!-- 高级:autoExecuteMode/logLevel + 连接(暂留待定) -->
<template v-else-if="activeCategory === 'advanced'">
<AdvancedSection />
<ConnectionPanel
ref="connectionPanelRef"
:confirm-dialog="confirmDialog"
@toast="showToast"
/>
</template>
<!-- 关于:版本号展示 + 更新检查入口(B1/L0 版本治理) -->
<AboutPanel v-else-if="activeCategory === 'about'" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, shallowRef, onMounted, onUnmounted, nextTick } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
import { useConfirm } from '@/composables/useConfirm'
import { useToast } from '@/composables/useToast'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import SettingsNav, { type SettingsCategory } from '@/components/settings/SettingsNav.vue'
import SettingRow from '@/components/settings/SettingRow.vue'
import ProviderPanel from '@/components/settings/ProviderPanel.vue'
import ConnectionPanel from '@/components/settings/ConnectionPanel.vue'
import AppearanceSection from '@/components/settings/AppearanceSection.vue'
import PerformanceSection from '@/components/settings/PerformanceSection.vue'
import AdvancedSection from '@/components/settings/AdvancedSection.vue'
import KnowledgePanel from '@/components/settings/KnowledgePanel.vue'
import AllowedDirsPanel from '@/components/settings/AllowedDirsPanel.vue'
import FeishuPanel from '@/components/settings/FeishuPanel.vue'
import UclawPanel from '@/components/settings/UclawPanel.vue'
import UnattendedPanel from '@/components/settings/UnattendedPanel.vue'
import AboutPanel from '@/components/settings/AboutPanel.vue'
// SettingsImportExport 已移除(价值低,占用 UI 空间;跨设备同步建议直接复制 SQLite)
import { useAppSettingsStore } from '@/stores/appSettings'
import { aiApi } from '@/api'
import type { AiProviderConfig } from '@/api/types'
import { SETTINGS_INDEX } from '@/components/settings/searchIndex'
import { t as ti18n } from '@/i18n/i18n-helpers'
// ============================================================
// Settings 壳 — toast/confirm 共享 UI + master-detail 布局组合各 Section/Panel
// (阶段3 UX 重构:左导航 + 右按 activeCategory 渲染,各功能域 state/methods 已拆到子组件)
// ============================================================
// 顶部轻量提示(useToast composable,消除 4 处重复;默认 3000ms)
const { toast, showToast } = useToast()
// 确认弹层状态机抽至 composables/useConfirm(原 4 视图重复:Projects/ProjectDetail/Ideas/Settings)
// 模板 confirmState.visible/msg 在 <script setup> 中自动解包 ref,沿用既有内联确认弹层
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
// 子面板 ref(挂载时触发各自 load,卸载时清子面板 timer)
const providerPanelRef = shallowRef<InstanceType<typeof ProviderPanel> | null>(null)
const connectionPanelRef = shallowRef<InstanceType<typeof ConnectionPanel> | null>(null)
// ============================================================
// master-detail:activeCategory 持久化(appSettings,默认 appearance)
// 用 computed 包 useSetting,双向绑 SettingsNav v-model
// ============================================================
const appSettings = useAppSettingsStore()
// 走查 P2-7:ProviderPanel 已暴露 isFormDirty 但壳未接入,点左栏任一分类/离开路由
// 都会 v-else-if 卸载面板,未保存改动无确认直接销毁。两条出口(切分类/路由离开)
// 统一走本守卫:脏则二次确认,拒绝即原地不动(SettingsNav 是受控组件,不落值高亮自动回弹)。
// 复用 ProviderPanel 自身的 confirmDiscardUnsaved 文案,语义与表单「取消」一致。
async function confirmLeaveIfProviderDirty(): Promise<boolean> {
if (!providerPanelRef.value?.isFormDirty) return true
return await confirmDialog(ti18n('settings.confirmDiscardUnsaved'))
}
const activeCategory = computed<SettingsCategory>({
get: () => appSettings.get<SettingsCategory>('df-settings-active-category', 'appearance'),
set: (v) => {
// 点当前分类是无效切换,不弹确认(脏表单下误点自身不应被质询)
if (v === activeCategory.value) return
void confirmLeaveIfProviderDirty().then((ok) => {
if (ok) void appSettings.set('df-settings-active-category', v)
})
},
})
// 路由离开(回主页等)同样拦截:Settings 是路由组件,返回 false 阻断本次导航
onBeforeRouteLeave(async () => {
return await confirmLeaveIfProviderDirty()
})
// AI Prompt 缓存总开关(KV df-ai-anthropic-cache,默认开,与后端 prompt.rs parse_cache_flag 同键同默认;
// 值落库为 JSON 布尔,后端 "true"/"1" 判定兼容,显式 "false" 为用户回退)
const anthropicCache = appSettings.useSetting<boolean>('df-ai-anthropic-cache', true)
// 走查 P3-11:原开关切换零反馈,用户无从得知是否生效。改为 set 完成后 toast 反馈成败。
// 注:appSettings.set 当前落库失败仅 console 内吞(降级内存态),catch 是 store 上抛错误后
// 的失败通道,今日实际不触发(登记项见批次报告),成功反馈先补齐。
async function onAnthropicCacheChange(checked: boolean) {
anthropicCache.value = checked
try {
await appSettings.set('df-ai-anthropic-cache', checked)
showToast(ti18n('settings.toastAnthropicCacheSaved'), 'success')
} catch (e) {
console.error('保存 Prompt 缓存开关失败:', e)
showToast(ti18n('settings.toastAnthropicCacheFail'), 'error')
}
}
// 窄屏响应式:监听窗口宽度,nav 折叠为横向 tab(避免依赖仅 CSS 的 :has,
// 并让 SettingsNav 按横向模式渲染 list)
const isNarrowScreen = ref(false)
function syncNarrow() {
isNarrowScreen.value = window.innerWidth <= 768
}
let _resizeTimer: ReturnType<typeof setTimeout> | null = null
function onResize() {
if (_resizeTimer) clearTimeout(_resizeTimer)
_resizeTimer = setTimeout(syncNarrow, 100)
}
// ============================================================
// 设置搜索滚动定位(阶段4)
// ------------------------------------------------------------
// SettingsNav 搜索命中首匹配项时上抛 scroll-target(key)。
// 此处据 key 查 SETTINGS_INDEX 取 labelKey → t() 得当前 locale 文本,
// 在右侧内容 DOM 中找匹配的 .setting-label(表单型 Section)或
// .panel-header h2(列表型面板如 ProviderPanel),scrollIntoView 平滑定位。
//
// 匹配策略(2026-08-02 加固):
// 旧实现用 textContent.trim() === labelText 严格相等,SettingRow 的 .setting-label
// 常带 next-round-badge 子 span(「下次对话生效」) → textContent 拼接出
// 「主题 下次对话生效」≠「主题」,严格相等失效,搜索定位静默无命中。
// 现改读 .setting-label 的"首子文本节点"(纯 label,排除 badge/desc 等子元素),
// 与 labelText trim 后 includes 匹配(双向 includes 兜底首尾空白/标点)。
// h2 同理改 includes 提升容错。理想方案是 .setting-label 加 data-setting-key,
// 但那需改 SettingRow.vue + 6 个 Section(越"仅改指定文件"范围),故此处加固文本匹配。
// ============================================================
const contentRef = ref<HTMLElement | null>(null)
/** 取元素"首文本节点"内容(跳过子元素,排除 SettingRow 的 next-round-badge 拼接干扰) */
function firstTextNodeText(el: HTMLElement | null): string {
if (!el) return ''
// 优先 childNodes 中的首个 text node;退而 textContent(无子元素场景)
for (const node of Array.from(el.childNodes)) {
if (node.nodeType === Node.TEXT_NODE) {
return node.textContent || ''
}
}
return el.textContent || ''
}
/** 双向 includes 匹配(容错首尾空白/标点差异;空串不命中) */
function fuzzyMatch(a: string, b: string): boolean {
const sa = a.trim()
const sb = b.trim()
if (!sa || !sb) return false
return sa.includes(sb) || sb.includes(sa)
}
function scrollToSettingItem(itemKey: string) {
const entry = SETTINGS_INDEX.find((e) => e.key === itemKey)
if (!entry) return
// 用 i18n-helpers 的 t(规避 vue-i18n 动态 key 的 TS2589,见 i18n-helpers.ts 注释)
const labelText = ti18n(entry.labelKey)
// 等右侧对应 Section v-if 切换后挂载(nextTick);再用二次 rAF 确保布局就绪
void nextTick(() => {
requestAnimationFrame(() => {
const root = contentRef.value
if (!root) return
// 优先 .setting-label 文本(表单型);退而求 .panel-header h2(面板标题)
const labels = root.querySelectorAll<HTMLElement>('.setting-label')
let target: HTMLElement | null = null
for (const el of labels) {
if (fuzzyMatch(firstTextNodeText(el), labelText)) {
target = el.closest<HTMLElement>('.setting-row') ?? el
break
}
}
if (!target) {
const headers = root.querySelectorAll<HTMLElement>('.panel-header h2')
for (const el of headers) {
if (fuzzyMatch(firstTextNodeText(el), labelText)) {
target = el
break
}
}
}
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
})
})
}
// ============================================================
// KnowledgePanel embedding provider 下拉依赖 provider 列表。
// 阶段3 改 master-detail 后 ProviderPanel 仅在 ai-model 类挂载,
// knowledge 类切到时 ProviderPanel 可能未挂载 → 此处独立拉一次 providers
// 保证 KnowledgePanel 始终拿到 embedding provider 候选(与 ProviderPanel 自身
// onMounted loadProviders 互补,后者仅刷新 UI 列表 + aiStore)。
// ============================================================
const aiProviders = ref<AiProviderConfig[]>([])
async function loadAiProviders() {
try {
aiProviders.value = await aiApi.listProviders()
} catch (e) {
console.error('加载 AI 提供商失败:', e)
}
}
function onProvidersChanged() {
// ProviderPanel 自己 reload 后上抛,优先取其内存副本(最新),失败回退此处独立拉
aiProviders.value = providerPanelRef.value?.aiProviders ?? aiProviders.value
}
onMounted(() => {
syncNarrow()
window.addEventListener('resize', onResize)
// provider 列表预拉(knowledge 类依赖);connection 列表 advanced 类挂载时自载
void loadAiProviders()
})
// 组件卸载清 resize timer;toast 清理由 useToast 自动管理;各子面板自管自身 debounce timer
onUnmounted(() => {
if (_resizeTimer) clearTimeout(_resizeTimer)
window.removeEventListener('resize', onResize)
providerPanelRef.value?.clearPoolTimer()
})
</script>
<style scoped>
.settings { padding: 16px 20px 20px; }
/* 顶部 Toast 提示:已消费 global.css .df-toast 四色族+过渡(2026-10-18 整洁波并轨) */
/* 确认弹层已替换为 ConfirmDialog 组件(不再需要自建 CSS) */
/* page-header / btn 等已提取到 global.css 全局 */
/* ===== master-detail 布局(阶段3 UX 重构) =====
左 SettingsNav(~200px 固定)+ 右 content(flex 占满 + 纵向滚动)。
nav 宽度/折叠由 SettingsNav scoped 控制;此处仅布局骨架。 */
.settings-body {
display: flex;
gap: 20px;
align-items: flex-start;
}
.settings-content {
flex: 1;
min-width: 0; /* 允许收缩,防子面板溢出撑破 flex */
display: flex;
flex-direction: column;
gap: var(--df-gap-grid);
}
/* .btn / .btn-primary / .btn-ghost / .btn-sm 已迁移到 settings.css 全局,
本壳无特有按钮样式(.btn-danger 由 Projects/AdvancedSection 等使用处各自定义)。 */
/* ===== 窄屏响应式:nav 折叠为顶部横向 tab(由 SettingsNav is-horizontal 渲染) ===== */
@media (max-width: 768px) {
.settings-body {
flex-direction: column;
gap: 12px;
}
}
</style>