- state.rs: AllowedDirs(persistent HashSet + is_authorized workspace_root+starts_with + canonicalize) + AppState allowed_dirs Arc + reload/set/get Settings KV - tool_registry.rs: resolve_workspace_path 多目录白名单双校验(词法+canonicalize) + handler闭包引入AllowedDirs Arc(9文件工具) + run_command不走白名单 - commands/config.rs: ai_get/set_allowed_dirs IPC + lib.rs注册 - 前端: api/ai.ts + AllowedDirsPanel.vue(Settings授权目录UI) + i18n - 测试: 5 AllowedDirs单测 + 基线适配(29工具不变) workspace_root始终授权(零回归); Phase A仅持久化(不含B弹窗/C写约束) 主代兜底: cargo check devflow 0 + test 129 + vue-tsc 0
187 lines
7.0 KiB
Vue
187 lines
7.0 KiB
Vue
<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 提供商(列表 + 表单)+ F-04c 负载均衡池 ═══ -->
|
||
<ProviderPanel
|
||
ref="providerPanelRef"
|
||
:confirm-dialog="confirmDialog"
|
||
@toast="showToast"
|
||
@providers-changed="onProvidersChanged"
|
||
/>
|
||
|
||
<!-- ═══ 连接管理(localStorage CRUD)═══ -->
|
||
<ConnectionPanel
|
||
ref="connectionPanelRef"
|
||
:confirm-dialog="confirmDialog"
|
||
@toast="showToast"
|
||
/>
|
||
|
||
<!-- ═══ 通用设置(主题/语言/并发/Agent 轮次 — 即时调 IPC)═══ -->
|
||
<GeneralPanel />
|
||
|
||
<!-- ═══ 知识库配置(后端 IPC,embedding provider 依赖 ProviderPanel 列表)═══ -->
|
||
<KnowledgePanel :ai-providers="aiProviders" />
|
||
|
||
<!-- ═══ F-260619-03 Phase A: AI 工具授权目录(白名单持久化)═══ -->
|
||
<AllowedDirsPanel />
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { reactive, ref, shallowRef, onMounted, onUnmounted } from 'vue'
|
||
import { useConfirm } from '@/composables/useConfirm'
|
||
import ProviderPanel from '@/components/settings/ProviderPanel.vue'
|
||
import ConnectionPanel from '@/components/settings/ConnectionPanel.vue'
|
||
import GeneralPanel from '@/components/settings/GeneralPanel.vue'
|
||
import KnowledgePanel from '@/components/settings/KnowledgePanel.vue'
|
||
import AllowedDirsPanel from '@/components/settings/AllowedDirsPanel.vue'
|
||
import type { AiProviderConfig } from '@/api/types'
|
||
|
||
// ============================================================
|
||
// Settings 壳 — toast/confirm 共享 UI + 组合 4 子面板
|
||
// (纯重构零功能:各功能域 state/methods/CSS 已拆到子组件)
|
||
// ============================================================
|
||
|
||
// 顶部轻量提示(错误/警告/完成),3s 自动消失;零依赖,替代未接入的 Arco Message
|
||
const toast = reactive({ visible: false, msg: '', type: 'info' as 'error' | 'warning' | 'info' })
|
||
let _toastTimer: ReturnType<typeof setTimeout> | null = null
|
||
|
||
function showToast(msg: string, type: 'error' | 'warning' | 'info' = 'info') {
|
||
toast.msg = msg
|
||
toast.type = type
|
||
toast.visible = true
|
||
if (_toastTimer) clearTimeout(_toastTimer)
|
||
_toastTimer = setTimeout(() => { toast.visible = false }, 3000)
|
||
}
|
||
|
||
// 确认弹层状态机抽至 composables/useConfirm(原 4 视图重复:Projects/ProjectDetail/Ideas/Settings)
|
||
// 模板 confirmState.visible/msg 在 <script setup> 中自动解包 ref,沿用既有内联确认弹层
|
||
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
|
||
|
||
// 子面板 ref(挂载时触发各自 load,卸载时清子面板 timer)
|
||
const providerPanelRef = shallowRef<InstanceType<typeof ProviderPanel> | null>(null)
|
||
const connectionPanelRef = shallowRef<InstanceType<typeof ConnectionPanel> | null>(null)
|
||
|
||
// KnowledgePanel embedding provider 下拉依赖 ProviderPanel 的 aiProviders
|
||
// ProviderPanel loadProviders 后上抛 providers-changed,此处同步本地副本
|
||
const aiProviders = ref<AiProviderConfig[]>([])
|
||
|
||
function onProvidersChanged() {
|
||
aiProviders.value = providerPanelRef.value?.aiProviders ?? []
|
||
}
|
||
|
||
onMounted(() => {
|
||
providerPanelRef.value?.loadProviders()
|
||
connectionPanelRef.value?.loadConnections()
|
||
})
|
||
|
||
// 组件卸载清 toast timer;各子面板自管自身 debounce timer(各自 onUnmounted)
|
||
onUnmounted(() => {
|
||
if (_toastTimer) clearTimeout(_toastTimer)
|
||
providerPanelRef.value?.clearPoolTimer()
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.settings { padding: 16px 20px 20px; }
|
||
|
||
/* ===== 顶部 Toast 提示 ===== */
|
||
.toast {
|
||
position: fixed;
|
||
top: 16px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
z-index: 1000;
|
||
padding: 8px 16px;
|
||
border-radius: var(--df-radius-sm);
|
||
font-size: 13px;
|
||
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
|
||
}
|
||
.toast-error { background: var(--df-danger-bg); color: var(--df-danger); border: 0.5px solid var(--df-danger); }
|
||
.toast-warning { background: var(--df-warning-bg); color: var(--df-warning); border: 0.5px solid var(--df-warning); }
|
||
.toast-info { background: var(--df-accent-bg); color: var(--df-accent); border: 0.5px solid var(--df-accent); }
|
||
.toast-enter-active, .toast-leave-active { transition: opacity 0.2s, transform 0.2s; }
|
||
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translate(-50%, -8px); }
|
||
|
||
/* ===== 确认弹层 ===== */
|
||
.confirm-mask {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 1100;
|
||
background: rgba(0,0,0,0.5);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.confirm-box {
|
||
background: var(--df-bg-card);
|
||
border: 0.5px solid var(--df-border);
|
||
border-radius: var(--df-radius-lg);
|
||
padding: 20px;
|
||
min-width: 280px;
|
||
max-width: 360px;
|
||
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
|
||
}
|
||
.confirm-msg { font-size: 13px; color: var(--df-text); line-height: 1.5; margin-bottom: 16px; }
|
||
.confirm-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||
.btn-danger { background: var(--df-danger); color: #fff; }
|
||
.btn-danger:hover { filter: brightness(1.1); }
|
||
.confirm-enter-active, .confirm-leave-active { transition: opacity 0.15s; }
|
||
.confirm-enter-from, .confirm-leave-to { opacity: 0; }
|
||
|
||
.page-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: var(--df-gap-page);
|
||
}
|
||
.page-header h1 { font-size: 24px; font-weight: 500; color: var(--df-text); }
|
||
|
||
/* ===== 按钮 ===== */
|
||
.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; }
|
||
|
||
/* ===== 设置网格 ===== */
|
||
.settings-grid {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--df-gap-grid);
|
||
}
|
||
|
||
/* 注:.panel / .panel-header / .empty-hint 等子组件内元素的样式
|
||
已随功能域拆到各子组件 scoped(父 scoped 无法穿透命中子组件根 section),
|
||
各子组件自带面板容器 + 按钮基础样式副本,确保零视觉变化。 */
|
||
</style>
|