优化: AI授权目录改进(误弹窗根因+安全加固)

strip_verbatim比对侧收口/黑名单统一/symlink/bind双路径reload/toast接线;cargo test 18
This commit is contained in:
2026-06-20 15:54:51 +08:00
parent e5a89165e6
commit e2ece2d898
8 changed files with 251 additions and 79 deletions

View File

@@ -13,7 +13,7 @@
<template v-else>
<div v-for="(dir, idx) in dirs" :key="dir + idx" class="dir-row">
<span class="dir-path" :title="dir">{{ dir }}</span>
<button class="btn btn-ghost btn-sm" @click="removeDir(idx)">
<button class="btn btn-ghost btn-sm" :disabled="saving" @click="removeDir(idx)">
{{ $t('common.delete') }}
</button>
</div>
@@ -26,19 +26,16 @@
v-model="newDir"
class="setting-select dir-input"
:placeholder="$t('settings.allowedDirsInputPlaceholder')"
@keyup.enter="addDir"
@keyup.enter="onAddClick"
/>
<button class="btn btn-primary btn-sm" :disabled="!newDir.trim()" @click="addDir">
{{ $t('settings.allowedDirsAdd') }}
<button class="btn btn-primary btn-sm" :disabled="saving" @click="onAddClick">
{{ newDir.trim() ? $t('settings.allowedDirsAdd') : $t('settings.allowedDirsSelectDir') }}
</button>
</div>
<!-- 操作 -->
<div class="actions">
<button class="btn btn-primary btn-sm" :disabled="saving" @click="save">
{{ $t('settings.allowedDirsSave') }}
</button>
<button class="btn btn-ghost btn-sm" :disabled="saving" @click="reload">
<button class="btn btn-ghost btn-sm" :disabled="loading" @click="reload">
{{ $t('settings.allowedDirsReload') }}
</button>
</div>
@@ -48,10 +45,19 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { open } from '@tauri-apps/plugin-dialog'
import { useI18n } from 'vue-i18n'
import { aiApi } from '@/api'
const { t } = useI18n()
// F-260620: 接共享 toast 通道(Settings.vue showToast),替代 console.error/warn 用户零反馈
const emit = defineEmits<{ (e: 'toast', msg: string, type: 'error' | 'warning' | 'info'): void }>()
// F-260619-03 Phase A: AI 工具文件访问授权目录白名单 UI
// 后端 workspace_root 始终隐式在白名单(is_authorized 内置),前端只管理用户额外授权目录。
// 即时持久化:添加/删除立即写后端 KV + 内存白名单,无需显式"保存"(符合"操作即生效"心智,
// 避免暂存+刷新丢失的认知错配)。dirs 始终由后端 setAllowedDirs 返回值回填(canonicalize +
// strip_verbatim 规范化后的真实路径),保证前后端读写一致。workspace_root 由后端 get 返回时
// 过滤(内部隐式授权,不进用户管理列表),前端原样展示不再猜测 root 形态。
const dirs = ref<string[]>([])
const newDir = ref('')
const loading = ref(true)
@@ -60,47 +66,69 @@ const saving = ref(false)
async function load() {
loading.value = true
try {
const all = await aiApi.getAllowedDirs()
// 后端返回的列表含 workspace_root(隐式授权),前端展示时过滤掉以减少混淆
// (workspace_root 始终授权无需用户管理)。保留其它用户授权目录。
dirs.value = all.filter(d => !isWorkspaceRoot(d))
dirs.value = await aiApi.getAllowedDirs()
} catch (e) {
console.error('加载授权目录失败:', e)
emit('toast', t('settings.allowedDirsLoadFail'), 'error')
dirs.value = []
} finally {
loading.value = false
}
}
// 粗略判定 workspace_root(src-tauri 上两级,即项目根):canonicalize 后路径以
// src-tauri 结尾。Windows canonicalize 带 \\?\ 前缀,此处宽松匹配防大小写/分隔符差异。
function isWorkspaceRoot(p: string): boolean {
const lower = p.toLowerCase().replace(/\\/g, '/')
return lower.endsWith('/src-tauri')
// 单按钮双态:newDir 空 → 选目录(Tauri dialog::open 填入 newDir);有值 → 提交白名单
async function onAddClick() {
if (!newDir.value.trim()) {
await pickDir()
return
}
await addDir()
}
function addDir() {
async function pickDir() {
try {
const selected = await open({ directory: true, multiple: false })
if (!selected || Array.isArray(selected)) return
newDir.value = selected as string
// F-260620: 选完即加(消除选目录后需二次点"添加"的摩擦)
await addDir()
} catch (e) {
console.error('选择目录失败:', e)
}
}
// 添加:本地大小写不敏感去重预检(减少无效 IPC)+ 即时持久化。
// dirs 由 setAllowedDirs 返回值回填,保证与后端规范化形态一致(读写不再漂移)。
async function addDir() {
const d = newDir.value.trim()
if (!d) return
if (dirs.value.some(x => x.toLowerCase() === d.toLowerCase())) {
emit('toast', t('settings.allowedDirsDup'), 'warning')
newDir.value = ''
return
}
dirs.value.push(d)
newDir.value = ''
}
function removeDir(idx: number) {
dirs.value.splice(idx, 1)
}
async function save() {
saving.value = true
try {
const normalized = await aiApi.setAllowedDirs(dirs.value)
dirs.value = normalized.filter(d => !isWorkspaceRoot(d))
const next = [...dirs.value, d]
dirs.value = await aiApi.setAllowedDirs(next)
newDir.value = ''
} catch (e) {
console.error('保存授权目录失败:', e)
console.error('添加授权目录失败:', e)
emit('toast', t('settings.allowedDirsAddFail'), 'error')
} finally {
saving.value = false
}
}
// 删除:即时持久化,dirs 由后端返回值回填(与添加对称,删除也立即生效)。
async function removeDir(idx: number) {
const next = dirs.value.filter((_, i) => i !== idx)
saving.value = true
try {
dirs.value = await aiApi.setAllowedDirs(next)
} catch (e) {
console.error('删除授权目录失败:', e)
emit('toast', t('settings.allowedDirsRemoveFail'), 'error')
} finally {
saving.value = false
}
@@ -158,6 +186,7 @@ onMounted(load)
.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 {