Quellcode durchsuchen

Merge remote-tracking branch 'origin/master'

wangkun vor 2 Wochen
Ursprung
Commit
2821008da3
2 geänderte Dateien mit 393 neuen und 194 gelöschten Zeilen
  1. 65 85
      src/api/ude.js
  2. 328 109
      src/view/ude/access/access.vue

+ 65 - 85
src/api/ude.js

@@ -1,19 +1,19 @@
 import axios from 'axios'
 import { ElMessage } from 'element-plus'
 import { useUserStore } from '@/pinia/modules/user'
-import router from '@/router/index'
 import { UDE_SERVER } from '@/config'
 
-// UDE 后端直连(不走 vite 代理,也不复用 @/utils/request:
-// 该全局封装会把 baseURL 强制覆盖为 /api 且只认 code===100000)。
-// 后端地址优先读运行时配置 public/config.js 的 window.APP_CONFIG.UDE_SERVER(部署后可直接改),
-// 其次读构建环境变量 VITE_UDE_SERVER,兜底 http://localhost:9990。
+// UDE 后端(数据库透明加密 udeServer,go-zero)统一接口封装:
+// 所有接口带 /api/v1 前缀、直连返回「裸 JSON」(无 code 包裹),
+// 列表接口为分页结构 {list, total}(pageSize 上限 100)。
+// 成功由 httpx.OkJsonCtx 返回业务数据,错误由后端 errorHandler 以非 2xx 状态码 +
+// {code, msg} 返回。后端地址读 window.APP_CONFIG.UDE_SERVER / VITE_UDE_SERVER。
 const service = axios.create({
   baseURL: UDE_SERVER,
   timeout: 30000
 })
 
-// 请求拦截:透传管理端登录 token(UDEServer 与管理前端 gva 共用 JWT signing-key 验签)
+// 透传登录态(udeServer 当前未启用 JWT,透传无副作用,便于后续统一鉴权)
 service.interceptors.request.use(
   config => {
     const userStore = useUserStore()
@@ -27,99 +27,79 @@ service.interceptors.request.use(
 
 service.interceptors.response.use(
   response => {
-    // 文件下载(导入模板)直接返回 blob
-    if (response.config.responseType === 'blob') {
-      return response.data
+    // udeServer 当前以 {code, message, data} 嵌套返回(go-zero 默认 SuccessHandler),
+    // 早期约定 "裸 JSON"(直接 {list,total}/实体),两种都接受:有 code 字段就解一层。
+    const body = response.data
+    if (body && typeof body === 'object' && 'code' in body && 'data' in body) {
+      return body.data
     }
-    const res = response.data
-    // 统一信封 {code, msg, data}:成功只认 code === 100000
-    if (res && typeof res === 'object' && 'code' in res) {
-      if (res.code === 100000) {
-        return res.data
-      }
-      ElMessage({ showClose: true, type: 'error', message: res.msg || '请求失败' })
-      return Promise.reject(new Error(res.msg || '请求失败'))
-    }
-    return res
+    return body
   },
   error => {
-    // 401 = 登录态缺失/过期(UDEServer JWT 校验拒绝):清登录态跳登录页,行为与 @/utils/request 一致
-    if (error.response?.status === 401) {
+    const status = error.response?.status
+    if (status === 401) {
       const userStore = useUserStore()
       userStore.token = ''
       localStorage.clear()
-      router.push({ name: 'Login', replace: true })
       ElMessage({ showClose: true, type: 'error', message: '登录已过期,请重新登录' })
       return Promise.reject(error)
     }
     ElMessage({
       showClose: true,
       type: 'error',
-      message: error.response?.data?.msg || error.message || '网络错误'
+      message: error.response?.data?.msg || error.response?.data?.message || error.message || '网络错误'
     })
     return Promise.reject(error)
   }
 )
 
-// 分页查询用户(page / pageSize / authMethod / keyword)
-export const getUserList = params => service.get('/api/v1/users', { params })
-
-// 编辑用户:双数据源后仅更新 UDE 属性(认证方式/标识/启用),身份字段以客户用户库为准
-export const updateUser = (id, data) => service.put(`/api/v1/users/${id}`, data)
-
-// 启用/禁用(1 启用 / 2 禁用)
-export const updateUserEnable = (id, enable) =>
-  service.patch(`/api/v1/users/${id}/enable`, { enable })
-
-// 更新二次认证方式
-export const updateUserAuth = (id, data) => service.put(`/api/v1/users/${id}/auth`, data)
-
-// 管理端 OTP 绑定:注册令牌 + 生成绑定二维码(入参 userName)
-export const otpRegister = data => service.post('/api/v1/otp/register', data)
-
-// 刷新 OTP 绑定二维码(入参 tid)
-export const otpBindInfo = data => service.post('/api/v1/otp/bind-info', data)
-
-// 动态令牌(OTP)配置
-export const getOtpConfig = () => service.get('/api/v1/config/otp')
-export const updateOtpConfig = data => service.put('/api/v1/config/otp', data)
-export const testOtp = data => service.post('/api/v1/config/otp/test', data)
-
-// 签名验签配置
-export const getSignConfig = () => service.get('/api/v1/config/sign')
-export const updateSignConfig = data => service.put('/api/v1/config/sign', data)
-export const testSign = () => service.post('/api/v1/config/sign/test')
-
-// 短信渠道配置
-export const listSmsConfigs = (params) => service({ url: '/api/v1/config/sms', method: 'get', params })
-export const createSmsConfig = (data) => service({ url: '/api/v1/config/sms', method: 'post', data })
-export const updateSmsConfig = (id, data) => service({ url: `/api/v1/config/sms/${id}`, method: 'put', data })
-export const deleteSmsConfig = (id) => service({ url: `/api/v1/config/sms/${id}`, method: 'delete' })
-export const updateSmsStatus = (id, status) => service({ url: `/api/v1/config/sms/${id}/status`, method: 'patch', data: { status }})
-export const testSms = (data) => service({ url: '/api/v1/config/sms/test', method: 'post', data })
-export const refreshSmsClient = () => service({ url: '/api/v1/config/sms/refresh', method: 'post' })
-
-// 用户库字段映射配置(产品化:前端可视化配置客户库映射)
-export const getUserdbTables = () => service.get('/api/v1/config/userdb/tables')
-export const getUserdbFields = (table) => service.get('/api/v1/config/userdb/fields', { params: { table } })
-export const getUserdbMapping = () => service.get('/api/v1/config/userdb/mapping')
-export const updateUserdbMapping = (data) => service.put('/api/v1/config/userdb/mapping', data)
-
-// 用户库连接配置:DSN 打码回显;测试连接通过才解锁保存;保存即热切换
-export const getUserdbConnection = () => service.get('/api/v1/config/userdb/connection')
-export const updateUserdbConnection = (data) => service.put('/api/v1/config/userdb/connection', data)
-export const testUserdbConnection = (data) => service.post('/api/v1/config/userdb/test', data)
-
-// 免改造代理配置(默认 vhost + 具名 vhost,保存即热生效;默认 target 空=恢复 yaml)
-export const getProxyConfig = () => service.get('/api/v1/config/proxy')
-export const updateProxyConfig = (data) => service.put('/api/v1/config/proxy', data)
-// 具名 vhost(前后端双域按 Host 分流):全量替换列表,空数组=清空
-export const updateProxyHosts = (data) => service.put('/api/v1/config/proxy/hosts', data)
-
-// 代理监听 host:port:入库优先于 yaml,保存即热重绑;port=0 恢复 yaml 兜底
-export const getProxyListen = () => service.get('/api/v1/config/proxy/listen')
-export const updateProxyListen = (data) => service.put('/api/v1/config/proxy/listen', data)
-
-// UKey 驱动型号(longmai 龙脉 / haitai 海泰 NPAPI):入库优先于 yaml,保存即热生效;model 空=恢复 yaml
-export const getProxyUkey = () => service.get('/api/v1/config/proxy/ukey')
-export const updateProxyUkey = (data) => service.put('/api/v1/config/proxy/ukey', data)
+const BASE = '/api/v1'
+const MAX_PAGE_SIZE = 100 // 后端 pagination.go 限制 pageSize 上限
+
+// 分页列表接口拉全量(前端需完整数据做客户端过滤/分页)
+async function fetchAll(getPage) {
+  const out = []
+  let page = 1
+  let total = 0
+  do {
+    const res = await getPage(page, MAX_PAGE_SIZE)
+    total = res?.total || 0
+    out.push(...(res?.list || []))
+    page++
+  } while (out.length < total)
+  return out
+}
+
+// ---- 接入服务(t_service)----
+export const getServiceList = (page = 1, pageSize = MAX_PAGE_SIZE) =>
+  service.get(`${BASE}/services`, { params: { page, pageSize } }) // -> {list, total}
+export const getAllServices = () => fetchAll(getServiceList) // -> ServiceResp[]
+export const createService = data => service.post(`${BASE}/services`, data) // data 无需 id(表自增)
+export const updateService = (id, data) => service.put(`${BASE}/services/${id}`, data)
+export const deleteService = id => service.delete(`${BASE}/services/${id}`)
+
+// 一键刷库:下发刷库指令,外挂程序启动并同步配置后对保护目录内全量数据做加密处理
+export const brushService = id => service.post(`${BASE}/services/${id}/brush`)
+
+// ---- 密钥(t_key,用于「密钥标识」下拉)----
+export const getKeyList = (page = 1, pageSize = MAX_PAGE_SIZE) =>
+  service.post(`${BASE}/keys/list`, { params: { page, pageSize } }) // -> {list, total}
+export const getAllKeys = () => fetchAll(getKeyList) // -> KeyResp[]
+
+// ---- 保护目录(t_directory)----
+export const getDirectoryList = (page = 1, pageSize = MAX_PAGE_SIZE) =>
+  service.get(`${BASE}/directories`, { params: { page, pageSize } }) // -> {list, total}
+export const getAllDirectories = () => fetchAll(getDirectoryList) // -> DirectoryResp[]
+export const createDirectory = data => service.post(`${BASE}/directories`, data) // data 无需 id(表自增),仅 serviceId/directory
+export const deleteDirectory = id => service.delete(`${BASE}/directories/${id}`)
+
+// ---- UKey 兼容桩(身份鉴别模板残留,UDE 项目不实际使用) ----
+// src/utils/ukey/driver.js 仍被 login / user 页面引用,
+// udeServer 不暴露 UKey 配置接口,统一兜底为 longmai,使 driver.js 至少能实例化。
+export const getProxyUkey = () => Promise.resolve({ model: 'longmai' })
+
+
+// 本地目录懒加载(udeServer 所在 Linux 主机文件系统,从根目录 / 开始,只列目录不列文件)
+export const getDirChildren = path =>
+  service.get(`${BASE}/dirs/children`, { params: { path: path || '/' } })
+// 返回: { nodes: [{name, path, isDir, hasChildren}] }

+ 328 - 109
src/view/ude/access/access.vue

@@ -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,37 @@
         </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 +290,17 @@
 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,
+  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 +315,7 @@ const searchInfo = ref({
   status: ''
 })
 
-// ---------- 列表(后端列表无分页/过滤,前端做客户端过滤 + 分页) ----------
+// ---------- 列表(后端列表仅分页无过滤,前端拉全量后做客户端过滤 + 分页) ----------
 const allData = ref([])
 const page = ref(1)
 const pageSize = ref(10)
@@ -328,23 +359,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 +431,165 @@ 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?)$/
+
+// ---------- 服务器本地目录懒加载树(从根目录 / 开始,只列目录) ----------
+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()
+}
+
+// 服务名称:只允许中文/英文/数字/空格/中划线/下划线/点
+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 +602,9 @@ const openDialog = () => {
     iden: genIden(),
     keyIden: '',
     keyAlgo: 'SM4-CTR',
-    status: '启用'
+    status: '启用',
+    directories: [{ directory: '' }]
   }
-  formDirectories.value = [{ directory: '' }]
   dialogFormVisible.value = true
 }
 
@@ -447,12 +618,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 +637,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 +678,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 +705,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 +740,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>