|
|
@@ -93,7 +93,7 @@
|
|
|
<span>{{ formatDate(scope.row.createAt) }}</span>
|
|
|
</template>
|
|
|
</el-table-column>
|
|
|
- <el-table-column align="left" label="操作" fixed="right" width="200">
|
|
|
+ <el-table-column align="left" label="操作" fixed="right" width="260">
|
|
|
<template #default="scope">
|
|
|
<el-button
|
|
|
v-preReClick
|
|
|
@@ -111,34 +111,22 @@
|
|
|
@click="updateServiceInfo(scope.row)"
|
|
|
>编辑
|
|
|
</el-button>
|
|
|
- <el-popover v-model="scope.row.visible" placement="top" width="160">
|
|
|
- <p>确定要删除吗?</p>
|
|
|
- <div style="text-align: right; margin-top: 8px">
|
|
|
- <el-button
|
|
|
- v-preReClick
|
|
|
- link
|
|
|
- type="primary"
|
|
|
- @click="scope.row.visible = false"
|
|
|
- >取消
|
|
|
- </el-button>
|
|
|
- <el-button
|
|
|
- v-preReClick
|
|
|
- type="primary"
|
|
|
- @click="deleteServiceInfo(scope.row)"
|
|
|
- >确定
|
|
|
- </el-button>
|
|
|
- </div>
|
|
|
- <template #reference>
|
|
|
- <el-button
|
|
|
- v-preReClick
|
|
|
- icon="delete"
|
|
|
- link
|
|
|
- type="primary"
|
|
|
- @click="scope.row.visible = true"
|
|
|
- >删除
|
|
|
- </el-button>
|
|
|
- </template>
|
|
|
- </el-popover>
|
|
|
+ <el-button
|
|
|
+ v-preReClick
|
|
|
+ icon="delete"
|
|
|
+ link
|
|
|
+ type="primary"
|
|
|
+ @click="deleteServiceInfo(scope.row)"
|
|
|
+ >删除
|
|
|
+ </el-button>
|
|
|
+ <el-button
|
|
|
+ v-preReClick
|
|
|
+ icon="refresh"
|
|
|
+ link
|
|
|
+ type="primary"
|
|
|
+ @click="brushServiceInfo(scope.row)"
|
|
|
+ >一键刷库
|
|
|
+ </el-button>
|
|
|
</template>
|
|
|
</el-table-column>
|
|
|
</el-table>
|
|
|
@@ -176,19 +164,20 @@
|
|
|
v-model="dialogFormVisible"
|
|
|
:before-close="closeDialog"
|
|
|
:title="type === 'create' ? '新增服务' : '编辑服务'"
|
|
|
+ width="640px"
|
|
|
>
|
|
|
<el-form ref="dialogForm" :model="form" :rules="rules" label-width="110px">
|
|
|
<el-form-item label="服务名称" prop="name">
|
|
|
- <el-input v-model="form.name" clearable placeholder="请输入服务名称" />
|
|
|
+ <el-input v-model="form.name" clearable placeholder="请输入服务名称" style="width: 320px" />
|
|
|
</el-form-item>
|
|
|
<el-form-item label="外挂服务IP" prop="ip">
|
|
|
- <el-input v-model="form.ip" clearable placeholder="请输入外挂服务IP" />
|
|
|
+ <el-input v-model="form.ip" clearable placeholder="请输入外挂服务IP" maxlength="15" style="width: 240px" />
|
|
|
</el-form-item>
|
|
|
<el-form-item label="端口" prop="port">
|
|
|
- <el-input v-model="form.port" clearable placeholder="请输入外挂服务端口" />
|
|
|
+ <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="由系统自动生成" />
|
|
|
+ <el-input v-model="form.iden" disabled placeholder="由系统自动生成" style="width: 400px" />
|
|
|
</el-form-item>
|
|
|
<el-form-item label="密钥标识" prop="keyIden">
|
|
|
<el-select
|
|
|
@@ -196,7 +185,7 @@
|
|
|
clearable
|
|
|
filterable
|
|
|
placeholder="请选择密钥标识"
|
|
|
- style="width: 100%"
|
|
|
+ style="width: 320px"
|
|
|
>
|
|
|
<el-option
|
|
|
v-for="k in keyOptions"
|
|
|
@@ -206,8 +195,8 @@
|
|
|
/>
|
|
|
</el-select>
|
|
|
</el-form-item>
|
|
|
- <el-form-item label="密钥算法" prop="keyAlgo">
|
|
|
- <el-input v-model="form.keyAlgo" clearable placeholder="请输入密钥算法" />
|
|
|
+ <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-switch
|
|
|
@@ -219,30 +208,39 @@
|
|
|
inactive-text="停用"
|
|
|
/>
|
|
|
</el-form-item>
|
|
|
- <el-form-item label="保护目录">
|
|
|
- <div style="width: 100%">
|
|
|
- <div
|
|
|
- v-for="(item, index) in formDirectories"
|
|
|
+ <el-form-item label="保护目录" required>
|
|
|
+ <div class="directory-list">
|
|
|
+ <el-form-item
|
|
|
+ v-for="(item, index) in form.directories"
|
|
|
:key="index"
|
|
|
- style="display: flex; align-items: center; margin-bottom: 8px"
|
|
|
+ :prop="`directories.${index}.directory`"
|
|
|
+ :rules="{ required: true, message: '请输入保护目录路径', trigger: 'blur' }"
|
|
|
>
|
|
|
- <el-input
|
|
|
- v-model="item.directory"
|
|
|
- clearable
|
|
|
- placeholder="请输入保护目录路径"
|
|
|
- style="margin-right: 8px"
|
|
|
- />
|
|
|
- <el-button
|
|
|
- v-preReClick
|
|
|
- icon="delete"
|
|
|
- link
|
|
|
- type="danger"
|
|
|
- @click="removeDirectory(index)"
|
|
|
- >删除
|
|
|
- </el-button>
|
|
|
- </div>
|
|
|
- <el-button v-preReClick icon="plus" link type="primary" @click="addDirectory"
|
|
|
- >添加目录
|
|
|
+ <div class="directory-row">
|
|
|
+ <el-input
|
|
|
+ v-model="item.directory"
|
|
|
+ clearable
|
|
|
+ placeholder="请输入保护目录路径"
|
|
|
+ class="directory-input"
|
|
|
+ />
|
|
|
+ <el-button
|
|
|
+ v-preReClick
|
|
|
+ icon="delete"
|
|
|
+ link
|
|
|
+ type="danger"
|
|
|
+ @click="removeDirectory(index)"
|
|
|
+ >删除
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+ </el-form-item>
|
|
|
+ <el-button
|
|
|
+ v-preReClick
|
|
|
+ icon="folder"
|
|
|
+ link
|
|
|
+ type="primary"
|
|
|
+ class="add-directory-btn"
|
|
|
+ @click="openLocalTree"
|
|
|
+ >选择目录
|
|
|
</el-button>
|
|
|
</div>
|
|
|
</el-form-item>
|
|
|
@@ -254,6 +252,71 @@
|
|
|
</div>
|
|
|
</template>
|
|
|
</el-dialog>
|
|
|
+ <!-- 自动检测目录树弹窗 -->
|
|
|
+ <el-dialog
|
|
|
+ v-model="scanTreeVisible"
|
|
|
+ title="自动检测到的目录(可多选)"
|
|
|
+ width="640px"
|
|
|
+ :before-close="closeScanTree"
|
|
|
+ >
|
|
|
+ <el-tree
|
|
|
+ v-if="scanTreeData.length"
|
|
|
+ ref="scanTreeRef"
|
|
|
+ :data="scanTreeData"
|
|
|
+ show-checkbox
|
|
|
+ node-key="localMount"
|
|
|
+ default-expand-all
|
|
|
+ :props="{ label: 'name', children: 'children' }"
|
|
|
+ empty-text="该 SMB 共享根目录下没有可保护的子目录"
|
|
|
+ >
|
|
|
+ <template #default="{ node, data }">
|
|
|
+ <el-tooltip
|
|
|
+ :content="`SMB: ${data.smbPath}\n本地挂载点: ${data.localMount}`"
|
|
|
+ placement="top"
|
|
|
+ >
|
|
|
+ <span class="directory-text">{{ data.name }}</span>
|
|
|
+ </el-tooltip>
|
|
|
+ </template>
|
|
|
+ </el-tree>
|
|
|
+ <div v-else class="scan-empty">该 SMB 共享根目录下没有可保护的子目录</div>
|
|
|
+ <template #footer>
|
|
|
+ <div class="dialog-footer">
|
|
|
+ <el-button v-preReClick @click="closeScanTree">取消</el-button>
|
|
|
+ <el-button v-preReClick type="primary" @click="confirmScanTree">确 定</el-button>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </el-dialog>
|
|
|
+
|
|
|
+ <!-- 服务器本地目录懒加载树弹窗(从根目录 / 开始,只列目录) -->
|
|
|
+ <el-dialog
|
|
|
+ v-model="localTreeVisible"
|
|
|
+ title="选择保护目录(服务器根目录层级,可多选)"
|
|
|
+ width="560px"
|
|
|
+ :before-close="closeLocalTree"
|
|
|
+ >
|
|
|
+ <el-tree
|
|
|
+ ref="localTreeRef"
|
|
|
+ lazy
|
|
|
+ show-checkbox
|
|
|
+ node-key="path"
|
|
|
+ :load="loadLocalDirNode"
|
|
|
+ :props="{ label: 'name', isLeaf: 'leaf' }"
|
|
|
+ empty-text="根目录下没有可选目录"
|
|
|
+ style="max-height: 420px; overflow-y: auto"
|
|
|
+ >
|
|
|
+ <template #default="{ data }">
|
|
|
+ <el-tooltip :content="data.path" placement="top">
|
|
|
+ <span class="directory-text">{{ data.name }}</span>
|
|
|
+ </el-tooltip>
|
|
|
+ </template>
|
|
|
+ </el-tree>
|
|
|
+ <template #footer>
|
|
|
+ <div class="dialog-footer">
|
|
|
+ <el-button v-preReClick @click="closeLocalTree">取消</el-button>
|
|
|
+ <el-button v-preReClick type="primary" @click="confirmLocalTree">确 定</el-button>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </el-dialog>
|
|
|
</div>
|
|
|
</template>
|
|
|
|
|
|
@@ -261,15 +324,18 @@
|
|
|
import {
|
|
|
createService,
|
|
|
deleteService,
|
|
|
- getServiceList,
|
|
|
+ getAllServices,
|
|
|
updateService,
|
|
|
- getKeyList,
|
|
|
- getDirectoryList,
|
|
|
+ getAllKeys,
|
|
|
+ getAllDirectories,
|
|
|
createDirectory,
|
|
|
- deleteDirectory
|
|
|
-} from '@/api/service'
|
|
|
-import { ref, computed } from 'vue'
|
|
|
-import { ElMessage } from 'element-plus'
|
|
|
+ deleteDirectory,
|
|
|
+ scanDirectories,
|
|
|
+ brushService,
|
|
|
+ getDirChildren
|
|
|
+} from '@/api/ude'
|
|
|
+import { ref, computed, watch } from 'vue'
|
|
|
+import { ElMessage, ElMessageBox } from 'element-plus'
|
|
|
import { formatDate } from '@/utils/format'
|
|
|
|
|
|
defineOptions({
|
|
|
@@ -284,7 +350,7 @@ const searchInfo = ref({
|
|
|
status: ''
|
|
|
})
|
|
|
|
|
|
-// ---------- 列表(后端列表无分页/过滤,前端做客户端过滤 + 分页) ----------
|
|
|
+// ---------- 列表(后端列表仅分页无过滤,前端拉全量后做客户端过滤 + 分页) ----------
|
|
|
const allData = ref([])
|
|
|
const page = ref(1)
|
|
|
const pageSize = ref(10)
|
|
|
@@ -328,23 +394,26 @@ const onReset = () => {
|
|
|
// ---------- 密钥下拉(GET /keys) ----------
|
|
|
const keyOptions = ref([])
|
|
|
const getKeyData = async () => {
|
|
|
- const res = await getKeyList()
|
|
|
- keyOptions.value = (res.list || []).map(k => ({
|
|
|
+ const keys = await getAllKeys()
|
|
|
+ keyOptions.value = keys.map(k => ({
|
|
|
value: k.iden,
|
|
|
label: k.name ? `${k.iden}(${k.name})` : k.iden
|
|
|
}))
|
|
|
}
|
|
|
|
|
|
// ---------- 数据加载:合并服务 + 保护目录 ----------
|
|
|
+const allServicesCache = ref([]) // 全量服务(用于客户端校验查重)
|
|
|
+const allDirsCache = ref([]) // 全量保护目录(用于客户端校验挂载关系)
|
|
|
const getTableData = async () => {
|
|
|
- const [svcRes, dirRes] = await Promise.all([getServiceList(), getDirectoryList()])
|
|
|
- const dirs = dirRes.list || []
|
|
|
+ const [services, dirs] = await Promise.all([getAllServices(), getAllDirectories()])
|
|
|
+ allServicesCache.value = services
|
|
|
+ allDirsCache.value = dirs
|
|
|
const dirMap = {}
|
|
|
dirs.forEach(d => {
|
|
|
if (!dirMap[d.serviceId]) dirMap[d.serviceId] = []
|
|
|
dirMap[d.serviceId].push({ id: d.id, directory: d.directory })
|
|
|
})
|
|
|
- allData.value = (svcRes.list || []).map(s => {
|
|
|
+ allData.value = services.map(s => {
|
|
|
const list = dirMap[s.id] || []
|
|
|
return { ...s, directories: list, directoryText: list.map(x => x.directory).join('、') }
|
|
|
})
|
|
|
@@ -397,28 +466,245 @@ const form = ref({
|
|
|
iden: '',
|
|
|
keyIden: '',
|
|
|
keyAlgo: 'SM4-CTR',
|
|
|
- status: '启用'
|
|
|
+ status: '启用',
|
|
|
+ directories: [{ directory: '' }]
|
|
|
})
|
|
|
-const formDirectories = ref([])
|
|
|
+
|
|
|
+// IPv4 地址(每段 0-255)
|
|
|
+const ipv4Regex =
|
|
|
+ /^((25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(25[0-5]|2[0-4]\d|[01]?\d\d?)$/
|
|
|
+
|
|
|
+// ---------- SMB 自动检测 ----------
|
|
|
+const scanLoading = ref(false)
|
|
|
+const scanTreeVisible = ref(false)
|
|
|
+const scanTreeData = ref([]) // DirectoryNode[]
|
|
|
+const scanTreeRef = ref(null)
|
|
|
+let scanTimer = null
|
|
|
+
|
|
|
+// 防抖触发:用户输入 IP / Port / Share 都可能触发,任一变化都重置定时器
|
|
|
+const triggerScan = async (force = false) => {
|
|
|
+ const ip = (form.value.ip || '').trim()
|
|
|
+ const port = (form.value.port || '').trim()
|
|
|
+ if (!ipv4Regex.test(ip) || !/^\d{1,5}$/.test(port)) return
|
|
|
+ if (force) {
|
|
|
+ if (scanTimer) { clearTimeout(scanTimer); scanTimer = null }
|
|
|
+ } else {
|
|
|
+ if (scanTimer) clearTimeout(scanTimer)
|
|
|
+ }
|
|
|
+ return new Promise(resolve => {
|
|
|
+ scanTimer = setTimeout(async () => {
|
|
|
+ scanTimer = null
|
|
|
+ scanLoading.value = true
|
|
|
+ try {
|
|
|
+ const res = await scanDirectories({
|
|
|
+ ip,
|
|
|
+ port
|
|
|
+ })
|
|
|
+ scanTreeData.value = res?.roots || []
|
|
|
+ if (scanTreeData.value.length === 0) {
|
|
|
+ ElMessage({ type: 'info', message: '未发现任何子目录,请检查 SMB 共享或手动添加' })
|
|
|
+ } else {
|
|
|
+ scanTreeVisible.value = true
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ // 拦截器已弹错
|
|
|
+ } finally {
|
|
|
+ scanLoading.value = false
|
|
|
+ resolve()
|
|
|
+ }
|
|
|
+ }, 500)
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+const confirmScanTree = () => {
|
|
|
+ const tree = scanTreeRef.value
|
|
|
+ if (!tree) return
|
|
|
+ const nodes = tree.getCheckedNodes(false, true) // leafOnly=false, onlyChecked=true
|
|
|
+ if (!nodes.length) {
|
|
|
+ ElMessage({ type: 'warning', message: '请至少勾选一个目录' })
|
|
|
+ return
|
|
|
+ }
|
|
|
+ // 回填到 form.directories: 本地挂载路径(udeServer 真去 mount 了,可直接用作 t_directory.directory)
|
|
|
+ const paths = nodes.map(n => n.localMount)
|
|
|
+ form.value.directories = paths.map(p => ({ directory: p }))
|
|
|
+ ElMessage({ type: 'success', message: `已回填 ${paths.length} 个保护目录(本地挂载路径)` })
|
|
|
+ closeScanTree()
|
|
|
+}
|
|
|
+
|
|
|
+const closeScanTree = () => {
|
|
|
+ scanTreeVisible.value = false
|
|
|
+}
|
|
|
+
|
|
|
+// ---------- 服务器本地目录懒加载树(从根目录 / 开始,只列目录) ----------
|
|
|
+const localTreeVisible = ref(false)
|
|
|
+const localTreeRef = ref(null)
|
|
|
+
|
|
|
+const openLocalTree = () => {
|
|
|
+ localTreeVisible.value = true
|
|
|
+}
|
|
|
+
|
|
|
+const closeLocalTree = () => {
|
|
|
+ localTreeVisible.value = false
|
|
|
+}
|
|
|
+
|
|
|
+// el-tree lazy 回调:根节点(level=0)请求 '/',其余请求该节点的 path,每次只加载一层
|
|
|
+const loadLocalDirNode = (node, resolve) => {
|
|
|
+ const path = node.level === 0 ? '/' : node.data.path
|
|
|
+ getDirChildren(path)
|
|
|
+ .then(res => {
|
|
|
+ // hasChildren=false → leaf=true,前端不再显示展开箭头
|
|
|
+ resolve((res?.nodes || []).map(n => ({ ...n, leaf: !n.hasChildren })))
|
|
|
+ })
|
|
|
+ .catch(() => resolve([])) // 失败降级为空,不卡树
|
|
|
+}
|
|
|
+
|
|
|
+// 确认勾选:只取「完全勾选」的节点(不含半选父节点,避免父子重复回填),与已有行合并去重
|
|
|
+const confirmLocalTree = () => {
|
|
|
+ const tree = localTreeRef.value
|
|
|
+ if (!tree) return
|
|
|
+ const nodes = tree.getCheckedNodes(true, false)
|
|
|
+ if (!nodes.length) {
|
|
|
+ ElMessage({ type: 'warning', message: '请至少勾选一个目录' })
|
|
|
+ return
|
|
|
+ }
|
|
|
+ const existing = new Set(
|
|
|
+ form.value.directories.map(d => (d.directory || '').trim()).filter(Boolean)
|
|
|
+ )
|
|
|
+ // 保留已填写的非空行,勾选结果去重后追加
|
|
|
+ const merged = form.value.directories.filter(d => (d.directory || '').trim())
|
|
|
+ for (const n of nodes) {
|
|
|
+ if (!existing.has(n.path)) {
|
|
|
+ merged.push({ directory: n.path })
|
|
|
+ existing.add(n.path)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ form.value.directories = merged.length ? merged : [{ directory: '' }]
|
|
|
+ ElMessage({ type: 'success', message: `已回填 ${nodes.length} 个保护目录` })
|
|
|
+ closeLocalTree()
|
|
|
+}
|
|
|
+
|
|
|
+// 打开 / 切换到新增/编辑弹窗时,如果 IP+port 已合法,自动触发检测
|
|
|
+// 注意:这两段 watch 必须放在 dialogFormVisible / form / scanTreeVisible /
|
|
|
+// scanTimer / triggerScan 全部声明之后,Vue 注册 watch 时会同步跑一次
|
|
|
+// source getter 取基线值,提前声明会触发 TDZ。
|
|
|
+watch(
|
|
|
+ () => dialogFormVisible.value,
|
|
|
+ val => {
|
|
|
+ if (val) {
|
|
|
+ // 等弹窗挂载完再触发,确保 form.value 已就位
|
|
|
+ setTimeout(() => triggerScan(true), 200)
|
|
|
+ }
|
|
|
+ }
|
|
|
+)
|
|
|
+// IP 或 Port 变化也触发自动检测
|
|
|
+watch(
|
|
|
+ [() => form.value.ip, () => form.value.port],
|
|
|
+ () => triggerScan(false)
|
|
|
+)
|
|
|
+
|
|
|
+// 服务名称:只允许中文/英文/数字/空格/中划线/下划线/点
|
|
|
+const serviceNameRegex = /^[\u4e00-\u9fa5a-zA-Z0-9 _.-]+$/
|
|
|
+const validateServiceNameFormat = (rule, value, callback) => {
|
|
|
+ const v = (value || '').trim()
|
|
|
+ if (!v) return callback() // 必填交给 required
|
|
|
+ if (!serviceNameRegex.test(v)) {
|
|
|
+ return callback(
|
|
|
+ new Error('服务名称仅支持中文/英文/数字及 - _ . 空格,不能含特殊字符')
|
|
|
+ )
|
|
|
+ }
|
|
|
+ callback()
|
|
|
+}
|
|
|
+
|
|
|
+// 服务名称查重(同一名称不能复用,编辑场景排除自身)
|
|
|
+const validateServiceNameUnique = (rule, value, callback) => {
|
|
|
+ if (!value) return callback()
|
|
|
+ const dup = allServicesCache.value.find(
|
|
|
+ s => s.name === value && s.id !== form.value.id
|
|
|
+ )
|
|
|
+ if (dup) return callback(new Error(`服务名称[${value}]已存在`))
|
|
|
+ return callback()
|
|
|
+}
|
|
|
+
|
|
|
+// IPv4 地址(每段 0-255)— ipv4Regex 已在上方声明
|
|
|
+const validateIp = (rule, value, callback) => {
|
|
|
+ const v = (value || '').trim()
|
|
|
+ if (!v) return callback() // 必填交给 required
|
|
|
+ if (!ipv4Regex.test(v)) {
|
|
|
+ return callback(new Error('请输入合法的IPv4地址(如 192.168.1.1)'))
|
|
|
+ }
|
|
|
+ callback()
|
|
|
+}
|
|
|
+
|
|
|
+// 端口:纯数字 1-65535
|
|
|
+const validatePort = (rule, value, callback) => {
|
|
|
+ const v = String(value || '').trim()
|
|
|
+ if (!v) return callback()
|
|
|
+ if (!/^\d+$/.test(v)) {
|
|
|
+ return callback(new Error('端口只能输入数字'))
|
|
|
+ }
|
|
|
+ const n = parseInt(v, 10)
|
|
|
+ if (n < 1 || n > 65535) {
|
|
|
+ return callback(new Error('端口范围 1-65535'))
|
|
|
+ }
|
|
|
+ callback()
|
|
|
+}
|
|
|
+
|
|
|
+// 校验 (ip + port + 保护目录) 在已有数据中是否被挂载
|
|
|
+// 返回首个冲突的目录名,无冲突返回 null
|
|
|
+const findDuplicateMount = () => {
|
|
|
+ const ip = (form.value.ip || '').trim()
|
|
|
+ const port = (form.value.port || '').trim()
|
|
|
+ if (!ip || !port) return null
|
|
|
+ const newDirs = (form.value.directories || [])
|
|
|
+ .map(d => (d.directory || '').trim())
|
|
|
+ .filter(Boolean)
|
|
|
+ if (newDirs.length === 0) return null
|
|
|
+
|
|
|
+ // 用 serviceId -> 目录列表 反查
|
|
|
+ const dirsBySvc = new Map()
|
|
|
+ for (const d of allDirsCache.value) {
|
|
|
+ if (!dirsBySvc.has(d.serviceId)) dirsBySvc.set(d.serviceId, [])
|
|
|
+ dirsBySvc.get(d.serviceId).push(d.directory)
|
|
|
+ }
|
|
|
+ // 收集 (ip+port) 命中的已有服务的全部目录
|
|
|
+ const existingDirs = new Set()
|
|
|
+ for (const s of allServicesCache.value) {
|
|
|
+ if (s.id === form.value.id) continue // 排除自己(编辑场景)
|
|
|
+ if ((s.ip || '').trim() === ip && (s.port || '').trim() === port) {
|
|
|
+ for (const d of dirsBySvc.get(s.id) || []) {
|
|
|
+ existingDirs.add((d || '').trim())
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (existingDirs.size === 0) return null
|
|
|
+ return newDirs.find(d => existingDirs.has(d)) || null
|
|
|
+}
|
|
|
|
|
|
const rules = ref({
|
|
|
- name: [{ required: true, message: '请输入服务名称', trigger: 'blur' }],
|
|
|
- ip: [{ required: true, message: '请输入外挂服务IP', trigger: 'blur' }],
|
|
|
- port: [{ required: true, message: '请输入外挂服务端口', trigger: 'blur' }],
|
|
|
- keyIden: [{ required: true, message: '请选择密钥标识', trigger: 'change' }],
|
|
|
- keyAlgo: [{ required: true, message: '请输入密钥算法', trigger: 'blur' }]
|
|
|
+ name: [
|
|
|
+ { required: true, message: '请输入服务名称', trigger: 'blur' },
|
|
|
+ { validator: validateServiceNameFormat, trigger: 'blur' },
|
|
|
+ { validator: validateServiceNameUnique, trigger: 'blur' }
|
|
|
+ ],
|
|
|
+ ip: [
|
|
|
+ { required: true, message: '请输入外挂服务IP', trigger: 'blur' },
|
|
|
+ { validator: validateIp, trigger: 'blur' }
|
|
|
+ ],
|
|
|
+ port: [
|
|
|
+ { required: true, message: '请输入外挂服务端口', trigger: 'blur' },
|
|
|
+ { validator: validatePort, trigger: 'blur' }
|
|
|
+ ],
|
|
|
+ keyIden: [{ required: true, message: '请选择密钥标识', trigger: 'change' }]
|
|
|
})
|
|
|
|
|
|
-// 后端主键无自增,前端生成唯一 int64 作为 id / 目录 id
|
|
|
-const genId = () => Number(`${Date.now()}${Math.floor(Math.random() * 90) + 10}`)
|
|
|
// 接入标识自动生成
|
|
|
const genIden = () => `SVC${Date.now()}${Math.floor(Math.random() * 10000)}`
|
|
|
|
|
|
const addDirectory = () => {
|
|
|
- formDirectories.value.push({ directory: '' })
|
|
|
+ form.value.directories.push({ directory: '' })
|
|
|
}
|
|
|
const removeDirectory = index => {
|
|
|
- formDirectories.value.splice(index, 1)
|
|
|
+ form.value.directories.splice(index, 1)
|
|
|
}
|
|
|
|
|
|
const openDialog = () => {
|
|
|
@@ -431,9 +717,9 @@ const openDialog = () => {
|
|
|
iden: genIden(),
|
|
|
keyIden: '',
|
|
|
keyAlgo: 'SM4-CTR',
|
|
|
- status: '启用'
|
|
|
+ status: '启用',
|
|
|
+ directories: [{ directory: '' }]
|
|
|
}
|
|
|
- formDirectories.value = [{ directory: '' }]
|
|
|
dialogFormVisible.value = true
|
|
|
}
|
|
|
|
|
|
@@ -447,12 +733,12 @@ const updateServiceInfo = row => {
|
|
|
iden: row.iden,
|
|
|
keyIden: row.keyIden,
|
|
|
keyAlgo: row.keyAlgo,
|
|
|
- status: row.status
|
|
|
+ status: row.status,
|
|
|
+ directories:
|
|
|
+ row.directories && row.directories.length
|
|
|
+ ? row.directories.map(d => ({ id: d.id, directory: d.directory }))
|
|
|
+ : [{ directory: '' }]
|
|
|
}
|
|
|
- formDirectories.value =
|
|
|
- row.directories && row.directories.length
|
|
|
- ? row.directories.map(d => ({ id: d.id, directory: d.directory }))
|
|
|
- : [{ directory: '' }]
|
|
|
dialogFormVisible.value = true
|
|
|
}
|
|
|
|
|
|
@@ -466,30 +752,38 @@ const closeDialog = () => {
|
|
|
iden: '',
|
|
|
keyIden: '',
|
|
|
keyAlgo: 'SM4-CTR',
|
|
|
- status: '启用'
|
|
|
+ status: '启用',
|
|
|
+ directories: []
|
|
|
}
|
|
|
- formDirectories.value = []
|
|
|
}
|
|
|
|
|
|
const enterDialog = async () => {
|
|
|
dialogForm.value.validate(async valid => {
|
|
|
if (!valid) return
|
|
|
- let serviceId = form.value.id
|
|
|
+ // 提交前再做一次 (ip + port + 保护目录) 组合唯一性校验
|
|
|
+ const dupDir = findDuplicateMount()
|
|
|
+ if (dupDir) {
|
|
|
+ ElMessage({
|
|
|
+ type: 'error',
|
|
|
+ message: `保护目录[${dupDir}]已被[${form.value.ip}:${form.value.port}]挂载`
|
|
|
+ })
|
|
|
+ return
|
|
|
+ }
|
|
|
try {
|
|
|
if (type.value === 'create') {
|
|
|
- serviceId = genId()
|
|
|
+ // 服务与保护目录由后端在同一事务里落库,任一条失败整体回滚
|
|
|
await createService({
|
|
|
- id: serviceId,
|
|
|
name: form.value.name,
|
|
|
ip: form.value.ip,
|
|
|
port: form.value.port,
|
|
|
iden: form.value.iden,
|
|
|
keyIden: form.value.keyIden,
|
|
|
keyAlgo: form.value.keyAlgo,
|
|
|
- status: form.value.status
|
|
|
+ status: form.value.status,
|
|
|
+ directories: form.value.directories.map(d => d.directory.trim()).filter(Boolean)
|
|
|
})
|
|
|
} else {
|
|
|
- await updateService(serviceId, {
|
|
|
+ await updateService(form.value.id, {
|
|
|
name: form.value.name,
|
|
|
ip: form.value.ip,
|
|
|
port: form.value.port,
|
|
|
@@ -499,31 +793,26 @@ const enterDialog = async () => {
|
|
|
status: form.value.status
|
|
|
})
|
|
|
// 保护目录按「删旧 + 重建」同步
|
|
|
- const oldDirs = formDirectories.value.filter(d => d.id).map(d => d.id)
|
|
|
+ const oldDirs = form.value.directories.filter(d => d.id).map(d => d.id)
|
|
|
await Promise.all(oldDirs.map(id => deleteDirectory(id)))
|
|
|
- }
|
|
|
-
|
|
|
- // 保存保护目录(过滤空值)
|
|
|
- const dirs = formDirectories.value.map(d => d.directory.trim()).filter(Boolean)
|
|
|
- for (const dir of dirs) {
|
|
|
- await createDirectory({ id: genId(), serviceId, directory: dir })
|
|
|
+ const dirs = form.value.directories.map(d => d.directory.trim()).filter(Boolean)
|
|
|
+ for (const dir of dirs) {
|
|
|
+ await createDirectory({ serviceId: form.value.id, directory: dir })
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
ElMessage({ type: 'success', message: '操作成功' })
|
|
|
closeDialog()
|
|
|
getTableData()
|
|
|
} catch (e) {
|
|
|
- // 错误提示已由 service.js 拦截器弹出
|
|
|
+ // 错误提示已由 ude.js 拦截器弹出
|
|
|
}
|
|
|
})
|
|
|
}
|
|
|
|
|
|
-// ---------- 删除(含关联保护目录) ----------
|
|
|
+// ---------- 删除(服务与其保护目录由后端同事务级联删除) ----------
|
|
|
const deleteServiceInfo = async row => {
|
|
|
- row.visible = false
|
|
|
try {
|
|
|
- const dirIds = (row.directories || []).map(d => d.id).filter(Boolean)
|
|
|
- await Promise.all(dirIds.map(id => deleteDirectory(id)))
|
|
|
await deleteService(row.id)
|
|
|
ElMessage({ type: 'success', message: '删除成功' })
|
|
|
if (tableData.value.length === 1 && page.value > 1) {
|
|
|
@@ -531,7 +820,28 @@ const deleteServiceInfo = async row => {
|
|
|
}
|
|
|
getTableData()
|
|
|
} catch (e) {
|
|
|
- // 错误提示已由 service.js 拦截器弹出
|
|
|
+ // 错误提示已由 ude.js 拦截器弹出
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ---------- 一键刷库 ----------
|
|
|
+// 下发刷库配置,外挂程序启动并同步配置后对保护目录内全量数据进行加密处理
|
|
|
+const brushServiceInfo = async row => {
|
|
|
+ try {
|
|
|
+ await ElMessageBox.confirm(
|
|
|
+ `确认对服务「${row.name}」下发一键刷库配置吗?下发后外挂程序启动并同步配置,将对保护目录内全量数据进行加密处理。`,
|
|
|
+ '一键刷库',
|
|
|
+ { type: 'warning', confirmButtonText: '确认下发', cancelButtonText: '取消' }
|
|
|
+ )
|
|
|
+ } catch (e) {
|
|
|
+ // 用户取消
|
|
|
+ return
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ await brushService(row.id)
|
|
|
+ ElMessage({ type: 'success', message: '刷库配置已下发' })
|
|
|
+ } catch (e) {
|
|
|
+ // 错误提示已由 ude.js 拦截器弹出
|
|
|
}
|
|
|
}
|
|
|
</script>
|
|
|
@@ -545,4 +855,28 @@ const deleteServiceInfo = async row => {
|
|
|
white-space: nowrap;
|
|
|
vertical-align: middle;
|
|
|
}
|
|
|
+
|
|
|
+.directory-list {
|
|
|
+ width: 100%;
|
|
|
+}
|
|
|
+
|
|
|
+.directory-list .el-form-item {
|
|
|
+ margin-bottom: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.directory-row {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.directory-input {
|
|
|
+ flex: 1;
|
|
|
+ width: 400px;
|
|
|
+ max-width: 100%;
|
|
|
+}
|
|
|
+
|
|
|
+.add-directory-btn {
|
|
|
+ margin-top: 4px;
|
|
|
+}
|
|
|
</style>
|