|
|
@@ -86,6 +86,14 @@
|
|
|
/>
|
|
|
</template>
|
|
|
</el-table-column>
|
|
|
+ <el-table-column align="left" label="挂载状态" width="110">
|
|
|
+ <template #default="scope">
|
|
|
+ <!-- el-tag: 彩色标签;:type 动态绑定颜色,绿色=已挂载,灰=未挂载,红=挂载失败 -->
|
|
|
+ <el-tag :type="mountTagType(scope.row.mountStatus)">
|
|
|
+ {{ mountStatusText(scope.row.mountStatus) }}
|
|
|
+ </el-tag>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
<el-table-column align="left" label="创建时间" width="180">
|
|
|
<template #default="scope">
|
|
|
<span>{{ formatDate(scope.row.createAt) }}</span>
|
|
|
@@ -111,7 +119,11 @@
|
|
|
@click="deleteServiceInfo(scope.row)"
|
|
|
>删除
|
|
|
</el-button>
|
|
|
+ <!-- 判断显示:仅配置了一键刷库(brush=1)的服务才显示手动刷库按钮 -->
|
|
|
<el-button
|
|
|
+ v-if="scope.row.brush === 1"
|
|
|
+ v-preReClick
|
|
|
+ icon="refresh"
|
|
|
link
|
|
|
type="primary"
|
|
|
@click="brushServiceInfo(scope.row)"
|
|
|
@@ -158,7 +170,7 @@
|
|
|
>
|
|
|
<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="请输入服务名称" style="width: 320px" />
|
|
|
+ <el-input v-model="form.name" clearable placeholder="请输入服务名称" maxlength="100" style="width: 320px" />
|
|
|
</el-form-item>
|
|
|
<el-form-item label="外挂服务IP" prop="ip">
|
|
|
<el-input v-model="form.ip" clearable placeholder="请输入外挂服务IP" maxlength="15" style="width: 240px" />
|
|
|
@@ -172,6 +184,7 @@
|
|
|
<el-form-item label="密钥标识" prop="keyIden">
|
|
|
<el-select
|
|
|
v-model="form.keyIden"
|
|
|
+ :disabled="type === 'update' && form.mountStatus === 'mounted'"
|
|
|
clearable
|
|
|
filterable
|
|
|
placeholder="请选择密钥标识"
|
|
|
@@ -184,6 +197,13 @@
|
|
|
:value="k.value"
|
|
|
/>
|
|
|
</el-select>
|
|
|
+ <!-- 挂载中禁换密钥:实际挂载仍用旧密钥解密,换了会造成库与实际脱节 -->
|
|
|
+ <div
|
|
|
+ v-if="type === 'update' && form.mountStatus === 'mounted'"
|
|
|
+ style="color: #e6a23c; font-size: 12px; line-height: 1.4; width: 100%"
|
|
|
+ >
|
|
|
+ 服务挂载中,密钥不可更换(实际挂载仍用旧密钥解密);如需更换请先卸载
|
|
|
+ </div>
|
|
|
</el-form-item>
|
|
|
<el-form-item label="密钥算法">
|
|
|
<el-input v-model="form.keyAlgo" disabled placeholder="请输入密钥算法" style="width: 220px" />
|
|
|
@@ -198,19 +218,27 @@
|
|
|
inactive-text="停用"
|
|
|
/>
|
|
|
</el-form-item>
|
|
|
+ <!-- 一键刷库:0=否(默认),1=创建后自动下发刷库(先刷库后挂载,下发逻辑见后端 notifyFuseBrush) -->
|
|
|
+ <el-form-item label="一键刷库">
|
|
|
+ <el-radio-group v-model="form.brush">
|
|
|
+ <el-radio :value="1">是</el-radio>
|
|
|
+ <el-radio :value="0">否</el-radio>
|
|
|
+ </el-radio-group>
|
|
|
+ </el-form-item>
|
|
|
<el-form-item label="保护目录" required>
|
|
|
<div class="directory-list">
|
|
|
<el-form-item
|
|
|
v-for="(item, index) in form.directories"
|
|
|
:key="index"
|
|
|
:prop="`directories.${index}.directory`"
|
|
|
- :rules="{ required: true, message: '请输入保护目录路径', trigger: 'blur' }"
|
|
|
+ :rules="{ required: true, message: '请点击下方「选择目录」从服务器选取', trigger: 'blur' }"
|
|
|
>
|
|
|
<div class="directory-row">
|
|
|
+ <!-- 只读:保护目录仅能通过「选择目录」树选择回填,不允许手动输入 -->
|
|
|
<el-input
|
|
|
v-model="item.directory"
|
|
|
- clearable
|
|
|
- placeholder="请输入保护目录路径"
|
|
|
+ readonly
|
|
|
+ placeholder="请点击下方「选择目录」从服务器选取"
|
|
|
class="directory-input"
|
|
|
/>
|
|
|
<el-button
|
|
|
@@ -238,7 +266,7 @@
|
|
|
<template #footer>
|
|
|
<div class="dialog-footer">
|
|
|
<el-button v-preReClick @click="closeDialog">取 消</el-button>
|
|
|
- <el-button v-preReClick type="primary" @click="enterDialog">确 定</el-button>
|
|
|
+ <el-button v-preReClick type="primary" @click="enterDialog">确 定</el-button>
|
|
|
</div>
|
|
|
</template>
|
|
|
</el-dialog>
|
|
|
@@ -250,7 +278,9 @@
|
|
|
width="560px"
|
|
|
:before-close="closeLocalTree"
|
|
|
>
|
|
|
+ <!-- v-if: 关闭弹窗即销毁树,重开时按当前表单 IP/port 重新懒加载,避免 lazy 缓存串机器 -->
|
|
|
<el-tree
|
|
|
+ v-if="localTreeVisible"
|
|
|
ref="localTreeRef"
|
|
|
lazy
|
|
|
show-checkbox
|
|
|
@@ -378,7 +408,24 @@ getTableData()
|
|
|
getKeyData()
|
|
|
|
|
|
// ---------- 服务状态开关 ----------
|
|
|
+// 挂载状态:后端字段 mountStatus(mounted/unmounted/failed) → 中文文案
|
|
|
+// 语法:对象字面量 [s] 取值,取不到(undefined)时用 || 兜底为未挂载
|
|
|
+const mountStatusText = s =>
|
|
|
+ ({ mounted: '已挂载', unmounted: '未挂载', failed: '挂载失败' }[s] || '未挂载')
|
|
|
+// 挂载状态 → el-tag 颜色:绿=已挂载,灰=未挂载,红=挂载失败
|
|
|
+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
|
|
|
+ }
|
|
|
try {
|
|
|
await updateService(row.id, {
|
|
|
name: row.name,
|
|
|
@@ -387,7 +434,9 @@ const changeStatus = async row => {
|
|
|
iden: row.iden,
|
|
|
keyIden: row.keyIden,
|
|
|
keyAlgo: row.keyAlgo,
|
|
|
- status: row.status
|
|
|
+ status: row.status,
|
|
|
+ // 状态开关是"全字段覆盖式"更新,必须带上 brush,否则会把一键刷库配置覆盖成 0
|
|
|
+ brush: row.brush || 0
|
|
|
})
|
|
|
ElMessage({ type: 'success', message: '状态更新成功' })
|
|
|
getTableData()
|
|
|
@@ -422,6 +471,7 @@ const form = ref({
|
|
|
keyIden: '',
|
|
|
keyAlgo: 'SM4-CTR',
|
|
|
status: '启用',
|
|
|
+ brush: 0,
|
|
|
directories: [{ directory: '' }]
|
|
|
})
|
|
|
|
|
|
@@ -433,7 +483,14 @@ const ipv4Regex =
|
|
|
const localTreeVisible = ref(false)
|
|
|
const localTreeRef = ref(null)
|
|
|
|
|
|
-const openLocalTree = () => {
|
|
|
+const openLocalTree = async () => {
|
|
|
+ // 目录树由 udeServer 转发到外挂程序(/path/lists)获取,依赖表单里填的外挂服务地址:
|
|
|
+ // IP/端口必须非空且格式校验通过才放行;校验失败时输入框下方显示红字,不弹树
|
|
|
+ try {
|
|
|
+ await dialogForm.value.validateField(['ip', 'port'])
|
|
|
+ } catch (e) {
|
|
|
+ return
|
|
|
+ }
|
|
|
localTreeVisible.value = true
|
|
|
}
|
|
|
|
|
|
@@ -441,16 +498,12 @@ const closeLocalTree = () => {
|
|
|
localTreeVisible.value = false
|
|
|
}
|
|
|
|
|
|
-// el-tree lazy 回调:根节点(level=0)请求 '/',其余请求该节点的 path,每次只加载一层
|
|
|
+// el-tree lazy 回调:ip/port/接入标识iden 取自表单(iden 后端转发外挂时作为 X-TOKEN 鉴权头),根节点(level=0)请求 '/',
|
|
|
+// 其余请求该节点的 path;树节点数据本身不带 ip/port(外挂程序只返回 name/path/isDir/hasChildren)
|
|
|
const loadLocalDirNode = (node, resolve) => {
|
|
|
const path = node.level === 0 ? '/' : node.data.path
|
|
|
- const data = {
|
|
|
- ip: form.value.ip,
|
|
|
- port: form.value.port,
|
|
|
- path
|
|
|
- }
|
|
|
-
|
|
|
- getDirChildren(data)
|
|
|
+ const { ip, port, iden } = form.value
|
|
|
+ getDirChildren(ip, port, path, iden)
|
|
|
.then(res => {
|
|
|
// hasChildren=false → leaf=true,前端不再显示展开箭头
|
|
|
resolve((res?.nodes || []).map(n => ({ ...n, leaf: !n.hasChildren })))
|
|
|
@@ -488,6 +541,9 @@ const serviceNameRegex = /^[\u4e00-\u9fa5a-zA-Z0-9 _.-]+$/
|
|
|
const validateServiceNameFormat = (rule, value, callback) => {
|
|
|
const v = (value || '').trim()
|
|
|
if (!v) return callback() // 必填交给 required
|
|
|
+ if (v.length > 100) {
|
|
|
+ return callback(new Error('服务名称长度不能超过100个字符'))
|
|
|
+ }
|
|
|
if (!serviceNameRegex.test(v)) {
|
|
|
return callback(
|
|
|
new Error('服务名称仅支持中文/英文/数字及 - _ . 空格,不能含特殊字符')
|
|
|
@@ -534,7 +590,7 @@ const validatePort = (rule, value, callback) => {
|
|
|
// 返回首个冲突的目录名,无冲突返回 null
|
|
|
const findDuplicateMount = () => {
|
|
|
const ip = (form.value.ip || '').trim()
|
|
|
- const port = (form.value.port || '').trim()
|
|
|
+ const port = String(form.value.port || '').trim() // String():编辑回显时 port 是数字(后端 int),数字没有 trim 方法
|
|
|
if (!ip || !port) return null
|
|
|
const newDirs = (form.value.directories || [])
|
|
|
.map(d => (d.directory || '').trim())
|
|
|
@@ -551,7 +607,7 @@ const findDuplicateMount = () => {
|
|
|
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) {
|
|
|
+ if ((s.ip || '').trim() === ip && String(s.port || '') === port) {
|
|
|
for (const d of dirsBySvc.get(s.id) || []) {
|
|
|
existingDirs.add((d || '').trim())
|
|
|
}
|
|
|
@@ -599,6 +655,7 @@ const openDialog = () => {
|
|
|
keyIden: '',
|
|
|
keyAlgo: 'SM4-CTR',
|
|
|
status: '启用',
|
|
|
+ brush: 0,
|
|
|
directories: [{ directory: '' }]
|
|
|
}
|
|
|
dialogFormVisible.value = true
|
|
|
@@ -615,6 +672,10 @@ const updateServiceInfo = row => {
|
|
|
keyIden: row.keyIden,
|
|
|
keyAlgo: row.keyAlgo,
|
|
|
status: row.status,
|
|
|
+ // 一键刷库配置回显(后端没返回时兜底 0,即"否")
|
|
|
+ brush: row.brush || 0,
|
|
|
+ // A 方案:记录挂载状态,模板用它置灰密钥下拉框(挂载中禁换密钥)
|
|
|
+ mountStatus: row.mountStatus,
|
|
|
directories:
|
|
|
row.directories && row.directories.length
|
|
|
? row.directories.map(d => ({ id: d.id, directory: d.directory }))
|
|
|
@@ -634,6 +695,7 @@ const closeDialog = () => {
|
|
|
keyIden: '',
|
|
|
keyAlgo: 'SM4-CTR',
|
|
|
status: '启用',
|
|
|
+ brush: 0,
|
|
|
directories: []
|
|
|
}
|
|
|
}
|
|
|
@@ -651,38 +713,52 @@ const enterDialog = async () => {
|
|
|
return
|
|
|
}
|
|
|
try {
|
|
|
+ let mountWarning = ''
|
|
|
if (type.value === 'create') {
|
|
|
// 服务与保护目录由后端在同一事务里落库,任一条失败整体回滚
|
|
|
- await createService({
|
|
|
+ const created = await createService({
|
|
|
name: form.value.name,
|
|
|
ip: form.value.ip,
|
|
|
- port: form.value.port,
|
|
|
+ port: Number(form.value.port), // Number():后端 port 已改 int,字符串数字统一转数字(非法值已被表单校验拦截)
|
|
|
iden: form.value.iden,
|
|
|
keyIden: form.value.keyIden,
|
|
|
keyAlgo: form.value.keyAlgo,
|
|
|
status: form.value.status,
|
|
|
+ brush: form.value.brush,
|
|
|
directories: form.value.directories.map(d => d.directory.trim()).filter(Boolean)
|
|
|
})
|
|
|
+ // 挂载通知失败等非致命问题:服务已创建成功,后端返回 mountWarning,黄色警告提示
|
|
|
+ mountWarning = created?.mountWarning || ''
|
|
|
} else {
|
|
|
- await updateService(form.value.id, {
|
|
|
+ // 后端收口:服务字段 + 保护目录全量一次提交,后端在单事务里完成
|
|
|
+ // "服务更新 + 目录全删全插",并联动外挂程序卸载被删目录/挂载新目录
|
|
|
+ // (FUSE 幂等决策表自动跳过已挂载的,并补挂 failed 服务的漏挂目录)
|
|
|
+ const updated = await updateService(form.value.id, {
|
|
|
name: form.value.name,
|
|
|
ip: form.value.ip,
|
|
|
- port: form.value.port,
|
|
|
+ port: Number(form.value.port), // Number():后端 port 已改 int,字符串数字统一转数字(非法值已被表单校验拦截)
|
|
|
iden: form.value.iden,
|
|
|
keyIden: form.value.keyIden,
|
|
|
keyAlgo: form.value.keyAlgo,
|
|
|
- status: form.value.status
|
|
|
+ status: form.value.status,
|
|
|
+ brush: form.value.brush,
|
|
|
+ directories: form.value.directories.map(d => d.directory.trim()).filter(Boolean)
|
|
|
})
|
|
|
- // 保护目录按「删旧 + 重建」同步
|
|
|
- const oldDirs = form.value.directories.filter(d => d.id).map(d => d.id)
|
|
|
- await Promise.all(oldDirs.map(id => deleteDirectory(id)))
|
|
|
- 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 })
|
|
|
- }
|
|
|
+ // 挂载联动失败等非致命警告放 mountWarning 字段,与新增服务同款黄条处理
|
|
|
+ mountWarning = updated?.mountWarning || ''
|
|
|
}
|
|
|
|
|
|
- ElMessage({ type: 'success', message: '操作成功' })
|
|
|
+ // 有挂载警告时弹黄色提示(文案已含「服务已创建」),不再重复弹绿色成功
|
|
|
+ if (mountWarning) {
|
|
|
+ ElMessage({
|
|
|
+ showClose: true,
|
|
|
+ type: 'warning',
|
|
|
+ message: mountWarning,
|
|
|
+ duration: 6000
|
|
|
+ })
|
|
|
+ } else {
|
|
|
+ ElMessage({ type: 'success', message: '操作成功' })
|
|
|
+ }
|
|
|
closeDialog()
|
|
|
getTableData()
|
|
|
} catch (e) {
|
|
|
@@ -693,9 +769,33 @@ const enterDialog = async () => {
|
|
|
|
|
|
// ---------- 删除(服务与其保护目录由后端同事务级联删除) ----------
|
|
|
const deleteServiceInfo = async row => {
|
|
|
+ // 删除是危险操作,先弹框确认、用户点了"确认删除"才真正调删除接口。
|
|
|
+ // 语法:ElMessageBox.confirm 在用户点"取消"时会让 await 抛出异常(reject),
|
|
|
+ // catch 里 return 静默收场 = 取消就不删;点确认则正常往下走。
|
|
|
+ try {
|
|
|
+ await ElMessageBox.confirm(
|
|
|
+ `确认删除服务「${row.name}」吗?其关联的保护目录记录将一并删除,删除后不可恢复。`,
|
|
|
+ '删除服务',
|
|
|
+ { type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消' }
|
|
|
+ )
|
|
|
+ } catch (e) {
|
|
|
+ return // 用户点了取消,不删除
|
|
|
+ }
|
|
|
try {
|
|
|
- await deleteService(row.id)
|
|
|
- ElMessage({ type: 'success', message: '删除成功' })
|
|
|
+ const res = await deleteService(row.id)
|
|
|
+ // P1:删除成功但联动卸载挂载失败时,后端返回 unmountWarning → 弹黄色警告
|
|
|
+ // 语法:res?.unmountWarning 是可选链,等价于 res && res.unmountWarning,
|
|
|
+ // res 为 undefined/null 时不会报错,直接返回 undefined(短路)
|
|
|
+ if (res?.unmountWarning) {
|
|
|
+ ElMessage({
|
|
|
+ type: 'warning',
|
|
|
+ message: res.unmountWarning,
|
|
|
+ showClose: true,
|
|
|
+ duration: 6000
|
|
|
+ })
|
|
|
+ } else {
|
|
|
+ ElMessage({ type: 'success', message: '删除成功' })
|
|
|
+ }
|
|
|
if (tableData.value.length === 1 && page.value > 1) {
|
|
|
page.value--
|
|
|
}
|
|
|
@@ -705,12 +805,18 @@ const deleteServiceInfo = async row => {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-// ---------- 一键刷库 ----------
|
|
|
-// 下发刷库配置,外挂程序启动并同步配置后对保护目录内全量数据进行加密处理
|
|
|
+// ---------- 一键刷库(手动补刷,仅 brush=1 的服务在操作列显示按钮) ----------
|
|
|
+// 点击后调用后端 /brush 独立接口下发刷库。
|
|
|
const brushServiceInfo = async row => {
|
|
|
+ // 挂载中的服务禁止刷库(第1道防线):历史文件已被挂载隐藏,
|
|
|
+ // 直接刷库的"清空明文"动作会穿透挂载删除底层密文,后端/外挂还有第2、3道防线
|
|
|
+ if (row.mountStatus === 'mounted') {
|
|
|
+ ElMessage({ type: 'warning', message: '历史文件已被挂载隐藏,请先卸掉挂载再执行刷库操作' })
|
|
|
+ return
|
|
|
+ }
|
|
|
try {
|
|
|
await ElMessageBox.confirm(
|
|
|
- `确认对服务「${row.name}」下发一键刷库配置吗?下发后外挂程序启动并同步配置,将对保护目录内全量数据进行加密处理。`,
|
|
|
+ `确认对服务「${row.name}」下发一键刷库吗?将对保护目录内全量存量数据进行加密处理。`,
|
|
|
'一键刷库',
|
|
|
{ type: 'warning', confirmButtonText: '确认下发', cancelButtonText: '取消' }
|
|
|
)
|