Files
DevFlow/src/components/settings/AllowedDirsPanel.vue
绝尘 6ffcba7e4d 新增: 审批超时可配置 + ScriptNode 命令白/黑名单
- 审批超时: Settings 下拉选择(不限时/5/15/30/60min),默认 15min
- getApprovalTimeoutMs() 从 KV 读取,startApprovalTimer 动态取值
- ScriptNode: 黑名单 > 白名单策略,从环境变量读取
- Settings 加命令执行安全面板(白/黑名单文本框)
- i18n 中英文案 + 搜索索引补全
2026-06-28 13:49:19 +08:00

238 lines
8.5 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>
<!-- 列表 -->
<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-select 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-select 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-select script-safety-input"
:placeholder="$t('settings.placeholderScriptBlacklist')"
@blur="onScriptBlacklistCommit"
@keyup.enter="onScriptBlacklistCommit"
/>
</SettingRow>
<p class="script-safety-note">{{ $t('settings.noteScriptSafety') }}</p>
</div>
</section>
</template>
<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'
import { useAppSettingsStore } from '@/stores/appSettings'
import SettingRow from './SettingRow.vue'
const { t } = useI18n()
const appSettings = useAppSettingsStore()
// 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
// 即时持久化:添加/删除立即写后端 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)
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
// 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
}
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 由后端返回值回填(与添加对称,删除也立即生效)。
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
}
}
async function reload() {
await load()
}
// ============================================================
// ScriptNode 命令执行安全 — 白/黑名单 UI
// ------------------------------------------------------------
// 前端仅做 UI + 持久化到 appSettings(df-script-whitelist / df-script-blacklist)。
// 后端 ScriptNode 当前从环境变量 DF_SCRIPT_WHITELIST / DF_SCRIPT_BLACKLIST 读取
// (同一逗号分隔语义),前后端打通留后续:前端写入需经 IPC/启动期注入环境变量。
// 输入语义:逗号分隔命令名(如 `git,npm,cargo`),空串=该项不限制。
// ============================================================
const scriptWhitelist = ref(appSettings.get<string>('df-script-whitelist', ''))
const scriptBlacklist = ref(appSettings.get<string>('df-script-blacklist', ''))
function onScriptWhitelistCommit() {
appSettings.set('df-script-whitelist', scriptWhitelist.value.trim())
emit('toast', t('settings.savedHint'), 'info')
}
function onScriptBlacklistCommit() {
appSettings.set('df-script-blacklist', scriptBlacklist.value.trim())
emit('toast', t('settings.savedHint'), 'info')
}
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;
}
.script-safety-input {
min-width: 280px;
font-family: var(--df-font-mono, monospace);
}
.script-safety-note {
font-size: 11px; color: var(--df-text-dim); margin: 4px 0 0; line-height: 1.5;
}
</style>