|
|
@@ -75,7 +75,7 @@
|
|
|
<el-table-column align="left" label="接入标识" prop="iden" min-width="140" />
|
|
|
<el-table-column align="left" label="密钥标识" prop="keyIden" min-width="140" />
|
|
|
<el-table-column align="left" label="密钥算法" prop="keyAlgo" width="120" />
|
|
|
- <el-table-column align="left" label="服务状态" width="110">
|
|
|
+ <el-table-column align="left" label="服务状态" width="130">
|
|
|
<template #default="scope">
|
|
|
<el-switch
|
|
|
v-model="scope.row.status"
|
|
|
@@ -178,8 +178,26 @@
|
|
|
<el-form-item label="端口" prop="port">
|
|
|
<el-input v-model="form.port" clearable placeholder="请输入外挂服务端口" maxlength="5" style="width: 140px" />
|
|
|
</el-form-item>
|
|
|
- <el-form-item label="接入标识">
|
|
|
- <el-input v-model="form.iden" disabled placeholder="由系统自动生成" style="width: 400px" />
|
|
|
+ <el-form-item label="接入标识" prop="iden">
|
|
|
+ <!-- 新增/编辑(非挂载成功):下拉选接入应用管理里"启用"状态的应用,显示的是 key(apiKey),
|
|
|
+ 实际存进 form.iden 的是密钥值(apiSecret),提交后保存到服务表 iden 字段 -->
|
|
|
+ <el-select
|
|
|
+ v-if="type === 'create' || form.mountStatus !== 'mounted'"
|
|
|
+ v-model="form.iden"
|
|
|
+ clearable
|
|
|
+ filterable
|
|
|
+ placeholder="请选择接入应用"
|
|
|
+ style="width: 400px"
|
|
|
+ >
|
|
|
+ <el-option
|
|
|
+ v-for="a in appOptions"
|
|
|
+ :key="a.label"
|
|
|
+ :label="a.label"
|
|
|
+ :value="a.label"
|
|
|
+ />
|
|
|
+ </el-select>
|
|
|
+ <!-- 编辑且挂载成功:实际挂载正在用该 iden 作 X-TOKEN,改了会造成账实分离,锁定回显 -->
|
|
|
+ <el-input v-else v-model="form.iden" disabled style="width: 400px" />
|
|
|
</el-form-item>
|
|
|
<el-form-item label="密钥标识" prop="keyIden">
|
|
|
<el-select
|
|
|
@@ -191,7 +209,7 @@
|
|
|
style="width: 320px"
|
|
|
>
|
|
|
<el-option
|
|
|
- v-for="k in keyOptions"
|
|
|
+ v-for="k in (type === 'create' ? keyOptionsForCreate : keyOptions)"
|
|
|
:key="k.value"
|
|
|
:label="k.label"
|
|
|
:value="k.value"
|
|
|
@@ -208,7 +226,9 @@
|
|
|
<el-form-item label="密钥算法">
|
|
|
<el-input v-model="form.keyAlgo" disabled placeholder="请输入密钥算法" style="width: 220px" />
|
|
|
</el-form-item>
|
|
|
- <el-form-item label="服务状态" prop="status">
|
|
|
+ <!-- 服务状态开关仅新增弹窗显示:新增时选择初始状态;
|
|
|
+ 编辑页不需要(状态由列表开关管理,挂载/卸载联动) -->
|
|
|
+ <el-form-item v-if="type === 'create'" label="服务状态" prop="status">
|
|
|
<el-switch
|
|
|
v-model="form.status"
|
|
|
active-value="启用"
|
|
|
@@ -278,9 +298,16 @@
|
|
|
width="560px"
|
|
|
:before-close="closeLocalTree"
|
|
|
>
|
|
|
+ <!-- 刷新:换 key 销毁重建树,从根目录重新懒加载(服务器目录有变更时使用),勾选状态一并重置 -->
|
|
|
+ <div class="tree-toolbar">
|
|
|
+ <el-button v-preReClick icon="refresh" size="small" @click="refreshLocalTree"
|
|
|
+ >刷新整个目录</el-button
|
|
|
+ >
|
|
|
+ </div>
|
|
|
<!-- v-if: 关闭弹窗即销毁树,重开时按当前表单 IP/port 重新懒加载,避免 lazy 缓存串机器 -->
|
|
|
<el-tree
|
|
|
v-if="localTreeVisible"
|
|
|
+ :key="localTreeKey"
|
|
|
ref="localTreeRef"
|
|
|
lazy
|
|
|
show-checkbox
|
|
|
@@ -317,9 +344,12 @@ import {
|
|
|
createDirectory,
|
|
|
deleteDirectory,
|
|
|
brushService,
|
|
|
+ mountService,
|
|
|
+ unmountService,
|
|
|
getDirChildren
|
|
|
} from '@/api/ude'
|
|
|
-import { ref, computed, watch } from 'vue'
|
|
|
+import { getSysApplicationList } from '@/api/sys/sysApplication'
|
|
|
+import { ref, computed, watch, nextTick } from 'vue'
|
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
|
import { formatDate } from '@/utils/format'
|
|
|
|
|
|
@@ -382,9 +412,35 @@ const getKeyData = async () => {
|
|
|
const keys = await getAllKeys()
|
|
|
keyOptions.value = keys.map(k => ({
|
|
|
value: k.iden,
|
|
|
- label: k.name ? `${k.iden}(${k.name})` : k.iden
|
|
|
+ label: k.name ? `${k.iden}(${k.name})` : k.iden,
|
|
|
+ status: String(k.status) // 密钥状态:'1'=启用,'2'=停用;转字符串保证类型一致
|
|
|
}))
|
|
|
}
|
|
|
+// 新增弹窗:密钥标识只能从"启用"状态的密钥里选;编辑弹窗保持全量列表不动
|
|
|
+const keyOptionsForCreate = computed(() =>
|
|
|
+ keyOptions.value.filter(k => k.status === '1')
|
|
|
+)
|
|
|
+
|
|
|
+// ---------- 接入标识下拉(接入应用管理列表,仅启用状态的应用) ----------
|
|
|
+const appOptions = ref([])
|
|
|
+const getAppData = async () => {
|
|
|
+ const all = []
|
|
|
+ let page = 1
|
|
|
+ for (;;) {
|
|
|
+ // POST 分页拉取应用管理列表(gva 侧接口:响应 code=100000 才算成功,
|
|
|
+ // 数据在 data.applicationLists,结构 {name, apiKey, apiSecret, status})
|
|
|
+ const res = await getSysApplicationList({ page, pageSize: 100 })
|
|
|
+ if (res.code !== 100000) break
|
|
|
+ const list = res.data.applicationLists || []
|
|
|
+ all.push(...list)
|
|
|
+ if (all.length >= res.data.total || list.length === 0) break
|
|
|
+ page++
|
|
|
+ }
|
|
|
+ // status=1 启用、2 停用;下拉显示 apiKey(key),选中的 value 是 apiSecret(密钥值)
|
|
|
+ appOptions.value = all
|
|
|
+ .filter(a => a.status === 1)
|
|
|
+ .map(a => ({ label: a.apiKey, value: a.apiSecret }))
|
|
|
+}
|
|
|
|
|
|
// ---------- 数据加载:合并服务 + 保护目录 ----------
|
|
|
const allServicesCache = ref([]) // 全量服务(用于客户端校验查重)
|
|
|
@@ -406,6 +462,7 @@ const getTableData = async () => {
|
|
|
|
|
|
getTableData()
|
|
|
getKeyData()
|
|
|
+getAppData()
|
|
|
|
|
|
// ---------- 服务状态开关 ----------
|
|
|
// 挂载状态:后端字段 mountStatus(mounted/unmounted/failed) → 中文文案
|
|
|
@@ -417,31 +474,71 @@ const mountTagType = s =>
|
|
|
({ mounted: 'success', unmounted: 'info', failed: 'danger' }[s] || 'info')
|
|
|
|
|
|
const changeStatus = async row => {
|
|
|
- // 已挂载的服务禁止停用:挂载中目录仍被 FUSE 占用,此时停用会造成
|
|
|
- // "库里状态是停用、机器上实际还在挂载"的账实分离,必须先卸载再停用。
|
|
|
- // 语法:el-switch 的 @change 触发时,v-model 已把 row.status 改成新值;
|
|
|
- // 拦截时手动改回 '启用',开关就会弹回原位,且不发任何请求。
|
|
|
- if (row.mountStatus === 'mounted' && row.status === '停用') {
|
|
|
- row.status = '启用'
|
|
|
- ElMessage({ type: 'warning', message: '当前服务已被挂载,卸掉挂载才可停用' })
|
|
|
- return
|
|
|
+ // 服务启用状态开关联动,按开关方向 + 当前挂载状态分流:
|
|
|
+ // 拨向"启用" → 调挂载接口(外挂把该服务全部保护目录挂上)
|
|
|
+ // 拨向"停用"且已挂载 → 先弹框确认,再调卸载接口(外挂真实卸载+后端同步改库)
|
|
|
+ // 拨向"停用"且未挂载 → 不调卸载接口,只调更新接口改库里这条数据的 status
|
|
|
+ // 语法:el-switch 的 @change 触发时,v-model 已把 row.status 改成新值,按它判断方向。
|
|
|
+ // 启用(会真实挂载)和"已挂载状态停用"(会真实卸载)两个方向都先弹框确认。
|
|
|
+ if (row.status === '启用' || (row.status === '停用' && row.mountStatus === 'mounted')) {
|
|
|
+ try {
|
|
|
+ if (row.status === '启用') {
|
|
|
+ await ElMessageBox.confirm(
|
|
|
+ `确认启用服务「${row.name}」吗?启用后将挂载其全部保护目录。`,
|
|
|
+ '启用服务',
|
|
|
+ { type: 'warning', confirmButtonText: '确认启用', cancelButtonText: '取消' }
|
|
|
+ )
|
|
|
+ } else {
|
|
|
+ await ElMessageBox.confirm(
|
|
|
+ `确认停用服务「${row.name}」吗?停用后将卸载其全部保护目录的挂载。`,
|
|
|
+ '停用服务',
|
|
|
+ { type: 'warning', confirmButtonText: '确认停用', cancelButtonText: '取消' }
|
|
|
+ )
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ // 用户点了取消,开关弹回原位,不发任何请求
|
|
|
+ row.status = row.status === '启用' ? '停用' : '启用'
|
|
|
+ return
|
|
|
+ }
|
|
|
}
|
|
|
try {
|
|
|
- await updateService(row.id, {
|
|
|
- name: row.name,
|
|
|
- ip: row.ip,
|
|
|
- port: row.port,
|
|
|
- iden: row.iden,
|
|
|
- keyIden: row.keyIden,
|
|
|
- keyAlgo: row.keyAlgo,
|
|
|
- status: row.status,
|
|
|
- // 状态开关是"全字段覆盖式"更新,必须带上 brush,否则会把一键刷库配置覆盖成 0
|
|
|
- brush: row.brush || 0
|
|
|
- })
|
|
|
- ElMessage({ type: 'success', message: '状态更新成功' })
|
|
|
+ if (row.status === '停用' && row.mountStatus !== 'mounted') {
|
|
|
+ // 未挂载停用:全字段覆盖式更新(必须带 brush,否则零值会覆盖一键刷库配置);
|
|
|
+ // 不传 directories → 后端不做挂载联动,mount_status 保持原值(不会把 failed 冲掉)
|
|
|
+ await updateService(row.id, {
|
|
|
+ name: row.name,
|
|
|
+ ip: row.ip,
|
|
|
+ port: row.port,
|
|
|
+ iden: row.iden,
|
|
|
+ keyIden: row.keyIden,
|
|
|
+ keyAlgo: row.keyAlgo,
|
|
|
+ status: '停用',
|
|
|
+ brush: row.brush || 0
|
|
|
+ })
|
|
|
+ ElMessage({ type: 'success', message: '服务已停用' })
|
|
|
+ } else {
|
|
|
+ const res = row.status === '启用'
|
|
|
+ ? await mountService(row.id)
|
|
|
+ : await unmountService(row.id)
|
|
|
+ // 启用方向:挂载失败(整体 failed)→ 后端已把状态落库为停用,
|
|
|
+ // 开关同步弹回"停用"位,启用状态与挂载成败挂钩
|
|
|
+ if (row.status === '启用' && res?.mountStatus === 'failed') {
|
|
|
+ row.status = '停用'
|
|
|
+ ElMessage({ type: 'warning', message: res.mountWarning || '挂载失败,服务保持停用', duration: 6000 })
|
|
|
+ } else if (res?.mountWarning) {
|
|
|
+ // 挂/卸载部分目录失败:后端仍返回 200 + mountWarning(黄色警告),
|
|
|
+ // status/mount_status 已按实际结果落库,刷新表格即可看到最新状态
|
|
|
+ ElMessage({ type: 'warning', message: res.mountWarning, duration: 6000 })
|
|
|
+ } else {
|
|
|
+ ElMessage({
|
|
|
+ type: 'success',
|
|
|
+ message: row.status === '启用' ? '服务已启用,保护目录挂载完成' : '服务已停用,挂载已全部卸载'
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }
|
|
|
getTableData()
|
|
|
} catch (e) {
|
|
|
- // 失败回滚开关
|
|
|
+ // 请求级失败(网络/参数错误):回滚开关到原状态,错误提示已由 ude.js 拦截器弹出
|
|
|
row.status = row.status === '启用' ? '停用' : '启用'
|
|
|
}
|
|
|
}
|
|
|
@@ -482,6 +579,12 @@ const ipv4Regex =
|
|
|
// ---------- 服务器本地目录懒加载树(从根目录 / 开始,只列目录) ----------
|
|
|
const localTreeVisible = ref(false)
|
|
|
const localTreeRef = ref(null)
|
|
|
+// 树刷新 key:懒加载树把「已加载」标记记在内部 node 上,直接清数据数组不会重新触发 load。
|
|
|
+// 每次刷新 key+1 强制 Vue 销毁重建整个 el-tree,从根目录重新懒加载,勾选状态一并重置
|
|
|
+const localTreeKey = ref(0)
|
|
|
+const refreshLocalTree = () => {
|
|
|
+ localTreeKey.value++
|
|
|
+}
|
|
|
|
|
|
const openLocalTree = async () => {
|
|
|
// 目录树由 udeServer 转发到外挂程序(/path/lists)获取,依赖表单里填的外挂服务地址:
|
|
|
@@ -511,15 +614,39 @@ const loadLocalDirNode = (node, resolve) => {
|
|
|
.catch(() => resolve([])) // 失败降级为空,不卡树
|
|
|
}
|
|
|
|
|
|
-// 确认勾选:只取「完全勾选」的节点(不含半选父节点,避免父子重复回填),与已有行合并去重
|
|
|
+// 确认勾选:取所有「完全勾选」的节点,父子同时勾选时只保留最上层(被父级
|
|
|
+// 级联带勾的后代丢弃,父目录已覆盖子目录),与已有行合并去重。
|
|
|
+// 注意不能用旧写法 getCheckedNodes(true)(只取叶子):懒加载树里勾选一个还没
|
|
|
+// 展开的非叶目录(如 /home)时它不算叶子,会回填为空或只回填它已加载的子孙。
|
|
|
const confirmLocalTree = () => {
|
|
|
const tree = localTreeRef.value
|
|
|
if (!tree) return
|
|
|
- const nodes = tree.getCheckedNodes(true, false)
|
|
|
- if (!nodes.length) {
|
|
|
+ // getCheckedNodes(false, false):返回所有完全勾选的节点(含父节点,不含半选)
|
|
|
+ const checked = tree.getCheckedNodes(false, false)
|
|
|
+ if (!checked.length) {
|
|
|
ElMessage({ type: 'warning', message: '请至少勾选一个目录' })
|
|
|
return
|
|
|
}
|
|
|
+ // 1. normalize(解决尾斜杠/多斜杠)
|
|
|
+ const norm = p => p.replace(/\/+/g, '/').replace(/\/$/, '') || '/'
|
|
|
+ // 父路径推导:'/a/b' → '/a','/a' → '/'(根)
|
|
|
+ const seen = new Set()
|
|
|
+ const list = []
|
|
|
+ for (const n of checked) {
|
|
|
+ if (!n || typeof n.path !== 'string') continue
|
|
|
+ const p = norm(n.path)
|
|
|
+ if (seen.has(p)) continue
|
|
|
+ seen.add(p)
|
|
|
+ list.push({ ...n, path: p })
|
|
|
+ }
|
|
|
+ // 3. 叶子过滤
|
|
|
+ const nodes = list.filter(n =>
|
|
|
+ !list.some(m =>
|
|
|
+ m.path !== n.path &&
|
|
|
+ m.path.startsWith(n.path === '/' ? '/' : n.path + '/')
|
|
|
+ )
|
|
|
+ )
|
|
|
+
|
|
|
const existing = new Set(
|
|
|
form.value.directories.map(d => (d.directory || '').trim()).filter(Boolean)
|
|
|
)
|
|
|
@@ -631,12 +758,10 @@ const rules = ref({
|
|
|
{ required: true, message: '请输入外挂服务端口', trigger: 'blur' },
|
|
|
{ validator: validatePort, trigger: 'blur' }
|
|
|
],
|
|
|
- keyIden: [{ required: true, message: '请选择密钥标识', trigger: 'change' }]
|
|
|
+ keyIden: [{ required: true, message: '请选择密钥标识', trigger: 'change' }],
|
|
|
+ iden: [{ required: true, message: '请选择接入标识', trigger: 'change' }]
|
|
|
})
|
|
|
|
|
|
-// 接入标识自动生成
|
|
|
-const genIden = () => `SVC${Date.now()}${Math.floor(Math.random() * 10000)}`
|
|
|
-
|
|
|
const addDirectory = () => {
|
|
|
form.value.directories.push({ directory: '' })
|
|
|
}
|
|
|
@@ -651,7 +776,7 @@ const openDialog = () => {
|
|
|
name: '',
|
|
|
ip: '',
|
|
|
port: '',
|
|
|
- iden: genIden(),
|
|
|
+ iden: '', // 接入标识改为下拉选择(保存的是所选应用的密钥值 apiSecret)
|
|
|
keyIden: '',
|
|
|
keyAlgo: 'SM4-CTR',
|
|
|
status: '启用',
|
|
|
@@ -659,6 +784,8 @@ const openDialog = () => {
|
|
|
directories: [{ directory: '' }]
|
|
|
}
|
|
|
dialogFormVisible.value = true
|
|
|
+ // el-dialog 内容关闭后不销毁,上一次弹窗的校验红框会残留到本次打开,主动清一次
|
|
|
+ nextTick(() => dialogForm.value?.clearValidate())
|
|
|
}
|
|
|
|
|
|
const updateServiceInfo = row => {
|
|
|
@@ -682,6 +809,8 @@ const updateServiceInfo = row => {
|
|
|
: [{ directory: '' }]
|
|
|
}
|
|
|
dialogFormVisible.value = true
|
|
|
+ // 同 openDialog:清掉上一次弹窗残留的校验红框(编辑已挂载服务时 iden 是禁用回显,更不该带红框)
|
|
|
+ nextTick(() => dialogForm.value?.clearValidate())
|
|
|
}
|
|
|
|
|
|
const closeDialog = () => {
|
|
|
@@ -834,6 +963,11 @@ const brushServiceInfo = async row => {
|
|
|
</script>
|
|
|
|
|
|
<style scoped>
|
|
|
+/* 目录树弹窗顶部工具条(刷新按钮所在行) */
|
|
|
+.tree-toolbar {
|
|
|
+ margin-bottom: 8px;
|
|
|
+}
|
|
|
+
|
|
|
.directory-text {
|
|
|
display: inline-block;
|
|
|
max-width: 180px;
|