ソースを参照

feat:已实现挂载、卸载功能 + 数据库字段规范 + 拉取到挂载服务的目录及完善前端校验

lipan 1 週間 前
親
コミット
ef9c62b800
2 ファイル変更、144 行追加、38 行削除
  1. 3 3
      src/api/ude.js
  2. 141 35
      src/view/ude/access/access.vue

+ 3 - 3
src/api/ude.js

@@ -99,6 +99,6 @@ export const deleteDirectory = id => service.delete(`${BASE}/directories/${id}`)
 export const getProxyUkey = () => Promise.resolve({ model: 'longmai' })
 
 // 本地目录懒加载(udeServer 所在 Linux 主机文件系统,从根目录 / 开始,只列目录不列文件)
-export const getDirChildren = (data) =>
-  service.get(`${BASE}/dirs/children`, { params: { ip: data.ip, port: data.port, path: data.path || '/' }})
-// 返回: { nodes: [{name, path, isDir, hasChildren}] }
+export const getDirChildren = (ip,port,path,iden) =>
+  service.get(`${BASE}/dirs/children`, { params: { ip: ip,port: port, path: path || '/', iden: iden } })
+// 返回: { nodes: [{name, path, isDir, hasChildren}] }

+ 141 - 35
src/view/ude/access/access.vue

@@ -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: '取消' }
     )