- 前四批:暗色 UA 重置+color-scheme、知识详情窄屏双闸、启动链 6 点加固、原生 tooltip 换 CSS - 统一治理:新建 --df-pad-page/--df-fs-item/--df-on-accent token 与 .df-seg/.modal-box--sm--md/.df-badge--solid/.empty-icon--sm--xs 公共类 - 收编:status-tag 双规格、AuditLog 容器、模态自建块、空态图标四档、加载更多三实现、圆角/字号/mono 栈字面量约 60 处 - 根修:.page-header h1 加压缩保护(治标题竖排);KnowledgeDetail hover token 笔误
331 lines
13 KiB
Vue
331 lines
13 KiB
Vue
<template>
|
|
<!-- ═══ F-260619-03 Phase A: AI 工具授权目录 ═══ -->
|
|
<section class="panel">
|
|
<div class="panel-header">
|
|
<h2>{{ $t('settings.panelAllowedDirs') }}</h2>
|
|
</div>
|
|
<div class="allowed-dirs">
|
|
<!-- 说明 -->
|
|
<p class="allowed-dirs-desc">{{ $t('settings.descAllowedDirs') }}</p>
|
|
|
|
<!-- 「信任所有目录」总开关:开启后白名单外路径不再逐次弹授权(后端黑名单拦截同步放行)。
|
|
走 appSettings KV(df-ai-path-auth-bypass),后端 process_tool_calls 每轮读取,改后即时生效。
|
|
开启方向二次确认(全放敏感目录须确认+风险自担):checked 单向绑定 + @change 裁决,
|
|
确认前不写 ref。若 v-model 直写,弹窗期间 useSetting 的 debounced 落库已把 true 推给后端,
|
|
取消也收不回这段已放行窗口。 -->
|
|
<SettingRow :label="$t('settings.labelPathAuthBypass')" :desc="$t('settings.descPathAuthBypass')">
|
|
<label class="toggle">
|
|
<input type="checkbox" :checked="pathAuthBypass" @change="onPathAuthBypassChange" />
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
</SettingRow>
|
|
|
|
<!-- 列表 -->
|
|
<div v-if="loading" class="empty-hint">{{ $t('settings.allowedDirsLoading') }}</div>
|
|
<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" :disabled="saving" @click="removeDir(idx)">
|
|
{{ $t('common.delete') }}
|
|
</button>
|
|
</div>
|
|
<div v-if="dirs.length === 0" class="empty-hint">{{ $t('settings.allowedDirsEmpty') }}</div>
|
|
</template>
|
|
|
|
<!-- 新增输入 -->
|
|
<div class="add-row">
|
|
<input
|
|
v-model="newDir"
|
|
class="setting-input dir-input"
|
|
:placeholder="$t('settings.allowedDirsInputPlaceholder')"
|
|
@keyup.enter="onAddClick"
|
|
/>
|
|
<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-ghost btn-sm" :disabled="loading" @click="reload">
|
|
{{ $t('settings.allowedDirsReload') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ═══ 命令执行安全:ScriptNode 白/黑名单(前端仅 UI + 持久化,后端当前从环境变量读) ═══ -->
|
|
<section class="panel">
|
|
<div class="panel-header">
|
|
<h2>{{ $t('settings.panelScriptSafety') }}</h2>
|
|
</div>
|
|
<div class="script-safety">
|
|
<p class="script-safety-desc">{{ $t('settings.descScriptSafety') }}</p>
|
|
|
|
<SettingRow :label="$t('settings.labelScriptWhitelist')" :desc="$t('settings.descScriptWhitelist')">
|
|
<input
|
|
v-model="scriptWhitelist"
|
|
class="setting-input script-safety-input"
|
|
:placeholder="$t('settings.placeholderScriptWhitelist')"
|
|
@blur="onScriptWhitelistCommit"
|
|
@keyup.enter="onScriptWhitelistCommit"
|
|
/>
|
|
</SettingRow>
|
|
|
|
<SettingRow :label="$t('settings.labelScriptBlacklist')" :desc="$t('settings.descScriptBlacklist')">
|
|
<input
|
|
v-model="scriptBlacklist"
|
|
class="setting-input script-safety-input"
|
|
:placeholder="$t('settings.placeholderScriptBlacklist')"
|
|
@blur="onScriptBlacklistCommit"
|
|
@keyup.enter="onScriptBlacklistCommit"
|
|
/>
|
|
</SettingRow>
|
|
|
|
<p class="script-safety-note">{{ $t('settings.noteScriptSafety') }}</p>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- 「信任所有目录」二次确认弹层(同 AdvancedSection 的 all 接管确认:自渲染独立弹层,
|
|
msg 拼标题+正文,confirm-msg 用 pre-line 保留换行;
|
|
按钮行/过渡消费 global.css .df-confirm-actions + df-confirm,2026-10-18 整洁波并轨) -->
|
|
<Transition name="df-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="df-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)">{{ confirmState.dangerLabel || $t('common.confirm') }}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, onMounted } from 'vue'
|
|
import { open } from '@tauri-apps/plugin-dialog'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { invoke } from '@tauri-apps/api/core'
|
|
import { aiApi, settingsApi } from '@/api'
|
|
import { useAppSettingsStore } from '@/stores/appSettings'
|
|
import { useConfirm } from '@/composables/useConfirm'
|
|
import SettingRow from './SettingRow.vue'
|
|
|
|
const { t } = useI18n()
|
|
const appSettings = useAppSettingsStore()
|
|
// 接共享 toast 通道(Settings.vue showToast),替代 console.error/warn 用户零反馈
|
|
const emit = defineEmits<{ (e: 'toast', msg: string, type: 'error' | 'warning' | 'info' | 'success'): void }>()
|
|
|
|
// F-260619-03 Phase A: AI 工具文件访问授权目录白名单 UI
|
|
// 即时持久化:添加/删除立即写后端 KV + 内存白名单,无需显式"保存"(符合"操作即生效"心智,
|
|
// 避免暂存+刷新丢失的认知错配)。dirs 始终由后端 setAllowedDirs 返回值回填(canonicalize +
|
|
// strip_verbatim 规范化后的真实路径),保证前后端读写一致。workspace_root 由后端 get 返回时
|
|
// 过滤(内部隐式授权,不进用户管理列表),前端原样展示不再猜测 root 形态。
|
|
const dirs = ref<string[]>([])
|
|
const newDir = ref('')
|
|
const loading = ref(true)
|
|
const saving = ref(false)
|
|
|
|
// 「信任所有目录」总开关(useSetting 双向绑 KV,写走 debounced 落库,后端每轮工具执行前读)
|
|
const pathAuthBypass = appSettings.useSetting<boolean>('df-ai-path-auth-bypass', false)
|
|
|
|
// useConfirm 真实 API:返回 { confirmState, confirmDialog(msg, dangerLabel), answerConfirm(ok) },
|
|
// confirmDialog 返回 Promise<boolean>(true=确认),弹层 UI 在本组件模板尾部自渲染。
|
|
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
|
|
|
|
// 开启方向(false→true)二次确认:确认才落 true,取消仅回弹。关闭方向不拦截直接落。
|
|
async function onPathAuthBypassChange(e: Event) {
|
|
const input = e.target as HTMLInputElement
|
|
if (!input.checked) {
|
|
pathAuthBypass.value = false
|
|
return
|
|
}
|
|
input.checked = false
|
|
const msg = t('settings.pathAuthBypassConfirmTitle') + '\n\n' + t('settings.pathAuthBypassConfirmBody')
|
|
if (await confirmDialog(msg, t('common.confirm'))) {
|
|
pathAuthBypass.value = true
|
|
}
|
|
}
|
|
|
|
async function load() {
|
|
loading.value = true
|
|
try {
|
|
dirs.value = await aiApi.getAllowedDirs()
|
|
} catch (e) {
|
|
console.error('加载授权目录失败:', e)
|
|
emit('toast', t('settings.allowedDirsLoadFail'), 'error')
|
|
dirs.value = []
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
// 单按钮双态:newDir 空 → 选目录(Tauri dialog::open 填入 newDir);有值 → 提交白名单
|
|
async function onAddClick() {
|
|
if (!newDir.value.trim()) {
|
|
await pickDir()
|
|
return
|
|
}
|
|
await addDir()
|
|
}
|
|
|
|
async function pickDir() {
|
|
try {
|
|
const selected = await open({ directory: true, multiple: false })
|
|
if (!selected || Array.isArray(selected)) return
|
|
newDir.value = selected as string
|
|
// 选完即加(消除选目录后需二次点"添加"的摩擦)
|
|
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
|
|
}
|
|
saving.value = true
|
|
try {
|
|
const next = [...dirs.value, d]
|
|
dirs.value = await aiApi.setAllowedDirs(next)
|
|
newDir.value = ''
|
|
} catch (e) {
|
|
console.error('添加授权目录失败:', e)
|
|
emit('toast', t('settings.allowedDirsAddFail'), 'error')
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
// 删除:即时持久化,dirs 由后端返回值回填(与添加对称,删除也立即生效)。
|
|
// SA-28: 删除即持久化且无二次确认(误点即丢一条授权),补确认弹层——复用本组件已有的
|
|
// useConfirm/内联弹层(与「信任所有目录」开关同套)。文案受「不新增 i18n 键」约束,
|
|
// 无专用键,以 common.delete 拼路径;理想应有 allowedDirsRemoveConfirm 专用键(记 note)。
|
|
async function removeDir(idx: number) {
|
|
const dir = dirs.value[idx]
|
|
if (!await confirmDialog(`${t('common.delete')}「${dir}」?`, t('common.delete'))) return
|
|
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
|
|
}
|
|
}
|
|
|
|
async function reload() {
|
|
await load()
|
|
}
|
|
|
|
// ============================================================
|
|
// ScriptNode 命令执行安全 — 白/黑名单 UI
|
|
// ------------------------------------------------------------
|
|
// 前端持久化到 appSettings KV 表(df-script-whitelist / df-script-blacklist)。
|
|
// 后端 ScriptNode 通过 set_script_safety IPC 注入运行时白/黑名单(即时生效)。
|
|
// 本组件同时持久化到前端 KV(localStorage)和后端(app_settings 表+ OnceLock 全局态)。
|
|
// ============================================================
|
|
const scriptWhitelist = ref(appSettings.get<string>('df-script-whitelist', ''))
|
|
const scriptBlacklist = ref(appSettings.get<string>('df-script-blacklist', ''))
|
|
|
|
async function syncScriptSafety() {
|
|
try {
|
|
await settingsApi.set('df-script-whitelist', scriptWhitelist.value.trim())
|
|
await settingsApi.set('df-script-blacklist', scriptBlacklist.value.trim())
|
|
// 走新的 set_script_safety IPC 同时持久化 + 注入运行时(一次调用完成两件事)
|
|
await invoke('set_script_safety', {
|
|
whitelist: scriptWhitelist.value.trim(),
|
|
blacklist: scriptBlacklist.value.trim(),
|
|
})
|
|
emit('toast', t('settings.savedHint'), 'info')
|
|
} catch (e) {
|
|
console.error('[SCRIPT-SAFETY] 同步后端失败:', e)
|
|
emit('toast', t('settings.toastSaveFail', { msg: String(e) }), 'error')
|
|
}
|
|
}
|
|
|
|
function onScriptWhitelistCommit() {
|
|
appSettings.set('df-script-whitelist', scriptWhitelist.value.trim())
|
|
syncScriptSafety()
|
|
}
|
|
|
|
function onScriptBlacklistCommit() {
|
|
appSettings.set('df-script-blacklist', scriptBlacklist.value.trim())
|
|
syncScriptSafety()
|
|
}
|
|
|
|
onMounted(load)
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* 阶段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;
|
|
}
|
|
.dir-row {
|
|
display: flex; justify-content: space-between; align-items: center;
|
|
padding: 8px 12px;
|
|
background: var(--df-bg);
|
|
border: 0.5px solid var(--df-border);
|
|
border-radius: var(--df-radius-sm);
|
|
}
|
|
.dir-path {
|
|
font-size: 13px; color: var(--df-text);
|
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
flex: 1; margin-right: 12px;
|
|
}
|
|
.add-row { display: flex; gap: 8px; margin-top: 4px; }
|
|
.dir-input { flex: 1; min-width: 0; }
|
|
.actions { display: flex; gap: 8px; margin-top: 8px; }
|
|
|
|
/* ===== ScriptNode 命令执行安全面板 ===== */
|
|
.script-safety { display: flex; flex-direction: column; gap: 4px; }
|
|
.script-safety-desc {
|
|
font-size: 12px; color: var(--df-text-dim); margin: 0 0 4px; line-height: 1.5;
|
|
}
|
|
/* 名单输入定宽 280px + max-width 窄窗收缩(原 min-width:280px 在窄列不可收缩致横向溢出) */
|
|
.script-safety-input {
|
|
width: 280px;
|
|
max-width: 100%;
|
|
font-family: var(--df-font-mono);
|
|
}
|
|
.script-safety-note {
|
|
font-size: 11px; color: var(--df-text-dim); margin: 4px 0 0; line-height: 1.5;
|
|
}
|
|
|
|
/* 「信任所有目录」二次确认弹层(同 AdvancedSection) */
|
|
.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: 420px;
|
|
box-shadow: var(--df-shadow-md);
|
|
}
|
|
.confirm-msg { font-size: 13px; color: var(--df-text); line-height: 1.5; margin-bottom: 16px; white-space: pre-line; }
|
|
</style>
|