Browse Source

feat:1.新增运维管理模块和配置管理模块

wangkun 1 week ago
parent
commit
89265b6190

+ 29 - 0
src/api/sys/pageSetting.js

@@ -0,0 +1,29 @@
+import service from '@/utils/request'
+
+// 注意:以下接口路径为占位(按 UDE /v1 惯例拟定),后端路由确定后统一替换
+
+// 获取页面配置(布局/登录页初始化时调用,占位阶段静默失败走默认值)
+export const getPageSetting = (params) => {
+  return service({
+    url: '/v1/customPage/get',
+    method: 'get',
+    params,
+    silentError: true
+  })
+}
+// 设置页面配置
+export const setPageSetting = (data) => {
+  return service({
+    url: '/v1/customPage/set',
+    method: 'post',
+    data
+  })
+}
+// 清空页面配置(恢复默认)
+export const clearPageSetting = (data) => {
+  return service({
+    url: '/v1/customPage/clear',
+    method: 'post',
+    data
+  })
+}

+ 36 - 0
src/api/sys/sysSetting.js

@@ -0,0 +1,36 @@
+import service from '@/utils/request'
+
+// 注意:以下接口路径为占位(按 UDE /v1 惯例拟定),后端路由确定后统一替换
+
+// 获取配置列表
+export const getConfigList = (data) => {
+  return service({
+    url: '/v1/applicationConfig/configList',
+    method: 'post',
+    data
+  })
+}
+// 新增配置
+export const addConfig = (data) => {
+  return service({
+    url: '/v1/applicationConfig/addConfig',
+    method: 'post',
+    data
+  })
+}
+// 修改配置
+export const updateConfig = (data) => {
+  return service({
+    url: '/v1/applicationConfig/updateConfig',
+    method: 'post',
+    data
+  })
+}
+// 删除配置
+export const deleteConfig = (data) => {
+  return service({
+    url: '/v1/applicationConfig/deleteConfig',
+    method: 'post',
+    data
+  })
+}

+ 54 - 0
src/pinia/modules/pageSetting.js

@@ -0,0 +1,54 @@
+import { defineStore } from 'pinia'
+import { ref } from 'vue'
+import { clearPageSetting, getPageSetting } from '@/api/sys/pageSetting'
+
+// 平台默认名称(与 core/config.js 的 appName 保持一致)
+const DEFAULT_NAME = '非结构化数据透明加密平台'
+
+export const usePageSettingStore = defineStore('pageSetting', () => {
+  const pageSetting = ref({
+    name: DEFAULT_NAME,
+    logo: '',
+    isLoaded: false
+  })
+
+  // 初始化(布局/登录页挂载时调用):读取失败时静默走默认值
+  const initPageSetting = async () => {
+    try {
+      const res = await getPageSetting()
+      if (res.code === 100000) {
+        pageSetting.value = {
+          name: res.data.name || DEFAULT_NAME,
+          logo: res.data.logo || '',
+          isLoaded: true
+        }
+      }
+    } catch {
+      // 占位接口阶段静默失败
+    }
+    pageSetting.value.isLoaded = true
+  }
+
+  const setPageSetting = val => {
+    pageSetting.value = val
+  }
+
+  const resetPageSetting = async () => {
+    const res = await clearPageSetting()
+    if (res.code === 100000) {
+      pageSetting.value = {
+        name: DEFAULT_NAME,
+        logo: '',
+        isLoaded: true
+      }
+    }
+    return pageSetting.value
+  }
+
+  return {
+    pageSetting,
+    setPageSetting,
+    initPageSetting,
+    resetPageSetting
+  }
+})

+ 10 - 0
src/utils/request.js

@@ -113,6 +113,11 @@ service.interceptors.response.use(
       return response
     }
 
+    // 静默失败:调用方标记 silentError 的请求不弹业务错误提示(占位接口/后台轮询场景)
+    if (response.config && response.config.silentError) {
+      return response
+    }
+
     if (response.data.code === 100000 || response.headers.success === 'true') {
       if (response.headers.message) {
         response.data.message = decodeURI(response.headers.message)
@@ -141,6 +146,11 @@ service.interceptors.response.use(
       closeLoading()
     }
 
+    // 静默失败:调用方标记 silentError 的请求不弹接口报错弹窗(占位接口/后台轮询场景)
+    if (error.config && error.config.silentError) {
+      return error
+    }
+
     // 打印详细错误信息
     console.error('请求失败:', {
       错误信息: error.message,

+ 8 - 2
src/view/layout/index.vue

@@ -16,13 +16,13 @@
           <img
             alt
             class="w-9 h-9 p-1 bg-white rounded-full"
-            src="@/assets/logo.png"
+            :src="pageSettingStore.pageSetting.logo || defaultLogo"
           >
           <div
             v-if="isSider"
             :style="{color:textColor}"
             class="inline-flex font-bold text-2xl"
-          >{{ $GIN_VUE_ADMIN.appName }}</div>
+          >{{ pageSettingStore.pageSetting.name || '非结构化数据透明加密平台' }}</div>
         </div>
         <Aside class="aside" />
       </el-aside>
@@ -204,11 +204,17 @@ import { useRouter, useRoute } from 'vue-router'
 import { useRouterStore } from '@/pinia/modules/router'
 import { fmtTitle } from '@/utils/fmtRouterTitle'
 import { useUserStore } from '@/pinia/modules/user'
+import { usePageSettingStore } from '@/pinia/modules/pageSetting'
+import defaultLogo from '@/assets/logo.png'
 
 defineOptions({
   name: 'Layout',
 })
 
+const pageSettingStore = usePageSettingStore()
+// 布局初始化时读取页面配置(自定义名称/Logo),读取失败静默走默认值
+pageSettingStore.initPageSetting()
+
 const router = useRouter()
 const route = useRoute()
 const routerStore = useRouterStore()

+ 12 - 2
src/view/login/index.vue

@@ -13,11 +13,15 @@
         >
           <div>
             <div class="flex items-center justify-center">
-              <img alt class="w-24" src="@/assets/logo.png" />
+              <img
+                alt
+                class="w-24"
+                :src="pageSettingStore.pageSetting.logo || defaultLogo"
+              />
             </div>
             <div class="mb-9">
               <p class="text-center text-4xl font-bold">
-                {{ $GIN_VUE_ADMIN.appName }}
+                {{ pageSettingStore.pageSetting.name || '非结构化数据透明加密平台' }}
               </p>
             </div>
 
@@ -162,6 +166,8 @@ import { reactive, ref, onMounted } from 'vue'
 import { ElMessage } from 'element-plus'
 import { useRouter } from 'vue-router'
 import { useUserStore } from '@/pinia/modules/user'
+import { usePageSettingStore } from '@/pinia/modules/pageSetting'
+import defaultLogo from '@/assets/logo.png'
 import { getLoginType } from '@/api/config'
 import { getUkeyDriver } from '@/utils/ukey/driver.js'
 
@@ -169,6 +175,10 @@ defineOptions({
   name: 'Login'
 })
 
+const pageSettingStore = usePageSettingStore()
+// 登录页也读取页面配置(自定义名称/Logo)
+pageSettingStore.initPageSetting()
+
 onMounted(async () => {
   await LoginType()
 })

+ 227 - 0
src/view/ude/operations/index.vue

@@ -0,0 +1,227 @@
+<template>
+  <div class="p-6 bg-[#fff] space-y-4">
+    <div class="font-bold text-2xl"> 运维管理</div>
+    <el-divider />
+    <!-- 表单 -->
+    <el-form
+      :model="form"
+      label-width="100px"
+      class="w-full md:w-2/3"
+    >
+      <el-form-item label="命令类型">
+        <el-select
+          v-model="form.type"
+          placeholder="请选择命令"
+        >
+          <el-option
+            label="Ping"
+            value="ping"
+          />
+          <el-option
+            label="Traceroute"
+            value="traceroute"
+          />
+          <el-option
+            label="Telnet"
+            value="telnet"
+          />
+        </el-select>
+      </el-form-item>
+
+      <el-form-item label="IP 地址">
+        <el-input
+          v-model="form.ip"
+          placeholder="请输入 IP 地址"
+        />
+      </el-form-item>
+
+      <el-form-item
+        v-if="form.type === 'telnet'"
+        label="端口号"
+      >
+        <el-input
+          v-model="form.port"
+          placeholder="请输入端口号"
+        />
+      </el-form-item>
+
+      <el-form-item>
+        <el-button
+          type="primary"
+          @click="sendCommand"
+        >发送请求</el-button>
+        <el-button
+          type="danger"
+          @click="clearTerminal"
+        >清空终端</el-button>
+      </el-form-item>
+    </el-form>
+
+    <!-- 终端显示框 -->
+    <div
+      ref="terminalRef"
+      class="terminal"
+    >
+      <div
+        v-for="(line, i) in terminalOutput"
+        :key="i"
+      >
+        {{ line }}
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
+
+defineOptions({
+  name: 'MaskOperations'
+})
+
+const form = ref({
+  type: 'ping', // ping | traceroute | telnet
+  ip: '',
+  port: ''
+})
+
+const terminalRef = ref(null)
+const terminalOutput = ref([])
+
+let ws = null
+
+// 有新输出时滚动到底部(移植时新增的小优化,不合适可去掉)
+const scrollToBottom = () => {
+  nextTick(() => {
+    if (terminalRef.value) {
+      terminalRef.value.scrollTop = terminalRef.value.scrollHeight
+    }
+  })
+}
+
+// 推送一行终端输出
+const pushOutput = line => {
+  terminalOutput.value.push(line)
+  scrollToBottom()
+}
+
+// 初始化 WebSocket
+const initWebSocket = () => {
+  // 开发环境:/api/v1 由 vite 代理到 VITE_UDE_SERVER(vite.config 已开 ws:true)
+  // 部署环境:由 Nginx 将 /api/v1/ws/ops 转发到 UDE 服务器(location 需带 Upgrade/Connection 头)
+  const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
+  const host = window.location.host
+  const wsUrl = `${protocol}://${host}/api/v1/ws/ops`
+  ws = new WebSocket(wsUrl)
+
+  ws.onopen = () => {
+    pushOutput('[系统] WebSocket 已连接')
+  }
+
+  // 处理返回
+  ws.onmessage = event => {
+    try {
+      const data = JSON.parse(event.data)
+
+      if (data.output && data.output.trim() !== '') {
+        pushOutput(data.output)
+      }
+
+      // 命令结束后留一个空行,让下一条命令分隔开
+      if (data.status === 'done') {
+        pushOutput('')
+      }
+    } catch {
+      pushOutput(event.data)
+    }
+  }
+  ws.onerror = () => {
+    pushOutput('[错误] WebSocket 出错')
+  }
+
+  ws.onclose = () => {
+    pushOutput('[系统] WebSocket 已关闭')
+  }
+}
+
+onMounted(() => initWebSocket())
+onBeforeUnmount(() => {
+  if (ws) ws.close()
+})
+
+const sendCommand = () => {
+  const ipRegex =
+        /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/
+  const portRegex =
+        /^(?:[1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$/
+
+  // 校验 IP
+  if (!ipRegex.test(form.value.ip)) {
+    pushOutput(`[错误] 请输入正确的 IPv4 地址`)
+    return
+  }
+
+  // 如果是 telnet,还要校验端口
+  if (form.value.type === 'telnet') {
+    if (!portRegex.test(form.value.port)) {
+      pushOutput(`[错误] 请输入 1-65535 之间的端口号`)
+      return
+    }
+  }
+
+  let payload = {}
+  let command = ''
+
+  if (form.value.type === 'ping') {
+    payload = { action: 'ping', target: form.value.ip }
+    command = `ping ${form.value.ip}`
+  } else if (form.value.type === 'traceroute') {
+    payload = { action: 'traceroute', target: form.value.ip }
+    command = `traceroute ${form.value.ip}`
+  } else if (form.value.type === 'telnet') {
+    if (!form.value.port) {
+      pushOutput(`[错误] 请输入端口号`)
+      return
+    }
+    payload = { action: 'telnet', host: form.value.ip, port: Number(form.value.port) }
+    command = `telnet ${form.value.ip} ${form.value.port}`
+  }
+  if (terminalOutput.value.length > 0) {
+    pushOutput('') // 空行
+  }
+  // 显示用户输入的命令(模拟 CMD 敲击)
+  pushOutput(`[命令] ${command}`)
+
+  // 发送 JSON 命令到后端 WebSocket
+  if (ws && ws.readyState === WebSocket.OPEN) {
+    ws.send(JSON.stringify(payload))
+  } else {
+    pushOutput(`[错误] WebSocket 未连接`)
+  }
+}
+
+// 清空终端
+const clearTerminal = () => {
+  terminalOutput.value = []
+}
+
+</script>
+
+<style scoped>
+.terminal {
+  background-color: #1e1e1e;           /* 终端背景 */
+  color: #ffffff;                      /* 终端文字颜色,纯绿色更明显 */
+  font-family: 'Courier New', monospace; /* 常用等宽字体 */
+  font-size: 14px;                     /* 字体大小,可根据需要调整 */
+  line-height: 1.5;                    /* 行高更舒适 */
+  padding: 12px;                        /* 内边距 */
+  height: 500px;                        /* 高度,可根据需要调整 */
+  overflow-y: auto;                     /* 超出显示滚动条 */
+  border-radius: 6px;                   /* 圆角,可选 */
+  white-space: pre-wrap;                /* 保留空格和换行 */
+  word-wrap: break-word;                /* 防止长行溢出 */
+  box-shadow: inset 0 0 5px #000000;    /* 内部微阴影,增强层次感 */
+  scrollbar-width: thin;                /* Firefox滚动条细一些 */
+}
+
+</style>

+ 455 - 0
src/view/ude/setting/pageSetting/index.vue

@@ -0,0 +1,455 @@
+<script setup>
+import { onMounted, ref } from 'vue'
+import { setPageSetting } from '@/api/sys/pageSetting'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { usePageSettingStore } from '@/pinia/modules/pageSetting'
+import 'vue-cropper/dist/index.css'
+import { VueCropper } from 'vue-cropper'
+import defaultLogo from '@/assets/logo.png'
+
+defineOptions({
+  name: 'MaskPageSetting'
+})
+
+const pageSettingStore = usePageSettingStore()
+const formRef = ref(null)
+const form = ref({
+  name: '',
+  logo: '',
+  isLoaded: false
+})
+const rules = ref({
+  name: [{ min: 2, max: 10, message: '自定义名称长度为2-10个字符', trigger: 'blur' }]
+})
+const handleGetPageSetting = async() => {
+  await pageSettingStore.initPageSetting()
+  form.value = {
+    name: pageSettingStore.pageSetting.name || '非结构化数据透明加密平台',
+    logo: pageSettingStore.pageSetting.logo || '',
+  }
+}
+const changeSetting = async() => {
+  await formRef.value.validate()
+  const params = {
+    name: form.value.name,
+    logo: form.value.logo
+  }
+  const res = await setPageSetting(params)
+  if (res.code === 100000) {
+    ElMessage.success('修改成功')
+  } else {
+    ElMessage.error(res.msg)
+  }
+  handleGetPageSetting()
+}
+onMounted(() => {
+  handleGetPageSetting()
+})
+function resolveLogo(path) {
+  // 如果为空,返回默认logo
+  if (!path) {
+    return defaultLogo
+  }
+  // 如果是 base64,直接返回
+  if (path.startsWith('data:image')) {
+    return path
+  }
+  // 如果是相对路径/别名路径,解析成真实地址
+  return new URL(path, import.meta.url).href
+}
+const dialogImageVisible = ref(false)
+const option = ref({
+  img: resolveLogo(pageSettingStore.pageSetting.logo), // 裁剪图片的地址
+  outputSize: 1, // 裁剪生成图片的质量(可选0.1 - 1)
+  outputType: 'jpeg', // 裁剪生成图片的格式(jpeg || png || webp)
+  info: true, // 图片大小信息
+  canScale: true, // 图片是否允许滚轮缩放
+  autoCrop: true, // 是否默认生成截图框
+  autoCropWidth: 150, // 默认生成截图框宽度
+  autoCropHeight: 150, // 默认生成截图框高度
+  fixed: true, // 是否开启截图框宽高固定比例
+  fixedNumber: [1, 1], // 截图框的宽高比例
+  full: false, // false按原比例裁切图片,不失真
+  fixedBox: false, // 固定截图框大小,不允许改变
+  canMove: true, // 上传图片是否可以移动
+  canMoveBox: true, // 截图框能否拖动
+  original: false, // 上传图片按照原始比例渲染
+  centerBox: false, // 截图框是否被限制在图片里面
+  height: true, // 是否按照设备的dpr 输出等比例图片
+  infoTrue: false, // true为展示真实输出图片宽高,false展示看到的截图框宽高
+  maxImgSize: 3000, // 限制图片最大宽度和高度
+  enlarge: 1, // 图片根据截图框输出比例倍数
+  mode: '300px 300px' // 图片默认渲染方式
+})
+const imgLoad = msg => {
+  if (msg === 'success') {
+    isReady.value = true
+  }
+}
+const loading = ref(false)
+// 实时的刷新
+const realTime = () => {
+  if (loading.value) return
+  loading.value = true
+  // 节流
+  setTimeout(() => {
+    cropper.value.getCropBlob(data => {
+      baseUrl.value = URL.createObjectURL(data) // 创建获取Blob对象的URL
+      loading.value = false
+    })
+  }, 500)
+}
+const cropper = ref(null)
+const fileName = ref('')
+
+const isReady = ref(false)
+const baseUrl = ref()
+// 选择图片
+const selectClick = () => {
+  inputRef.value?.click()
+}
+// 选择图片
+const selectImg = e => {
+  const eTarget = e.target
+  if (!eTarget.files) return
+  const file = eTarget.files[0]
+  // 确保 fileName 被正确赋值
+  fileName.value = file.name
+  if (!/\.(jpg|jpeg|png|JPG|PNG)$/.test(eTarget.value)) {
+    ElMessage.warning('图片格式错误')
+    return false
+  }
+  const reader = new FileReader()
+  reader.readAsDataURL(file) // 将文件转化为base64
+  reader.onload = e => {
+    let data
+    if (!e.target) return
+    if (typeof e.target.result === 'object') {
+      data = URL.createObjectURL(new Blob([e.target.result ?? '']))
+    } else {
+      data = e.target.result
+    }
+    option.value.img = data
+  }
+}
+const inputRef = ref()
+// 向左旋转
+const rotateLeft = () => {
+  cropper.value.rotateLeft()
+}
+// 向右旋转
+const rotateRight = () => {
+  cropper.value.rotateRight()
+}
+// 上传图片
+const imgUpload = () => {
+  // 获取截图的 blob 数据
+  cropper.value.getCropBlob(async data => {
+    // 上传到服务器
+    // 获取原始文件类型
+    const fileType = fileName.value.split('.').pop().toLowerCase()
+    const mimeType = `image/${fileType === 'jpg' ? 'jpeg' : fileType}`
+    const file = new File([data], fileName.value, { type: mimeType })
+
+    // 校验图片尺寸
+    const isSizeValid = await validateImageSize(file)
+    if (!isSizeValid) {
+      ElMessage.warning('图片尺寸不符合要求,请重新裁剪!')
+      return
+    }
+    // 把图片转成base64
+    form.value.logo = await new Promise(resolve => {
+      const reader = new FileReader()
+      reader.readAsDataURL(file)
+      reader.onloadend = () => resolve(reader.result)
+    })
+    // 重置裁剪组件状态
+    resetCropper()
+    dialogImageVisible.value = false
+  })
+}
+// 校验图片尺寸的函数
+const validateImageSize = file => {
+  return new Promise(resolve => {
+    const reader = new FileReader()
+    reader.onload = e => {
+      const img = new Image()
+      img.src = e.target.result
+      img.onload = () => {
+        const width = img.width
+        const height = img.height
+        // 这里可以根据实际需求设置图片尺寸的限制
+        const minWidth = 150
+        const minHeight = 150
+        const isValid =
+            width <= minWidth &&
+            height <= minHeight &&
+            width / height === minWidth / minHeight
+        resolve(isValid)
+      }
+    }
+    reader.readAsDataURL(file)
+  })
+}
+// 重置裁剪组件状态的函数
+const resetCropper = () => {
+  option.value.img = `${baseUrl.value}`
+  fileName.value = ''
+  isReady.value = false
+  baseUrl.value = ''
+  // 你还可以根据需要重置其他相关状态
+}
+const openFileDialog = () => {
+  dialogImageVisible.value = true
+}
+const resetSetting = async() => {
+  const confirm = await ElMessageBox.confirm(
+    '确认恢复默认设置吗?',
+    '提示',
+    {
+      confirmButtonText: '确定',
+      cancelButtonText: '取消',
+      type: 'warning'
+    }
+  ).catch(() => {
+    ElMessage.info('已取消操作')
+    // 取消操作
+  })
+  if (!confirm) return
+  await pageSettingStore.resetPageSetting()
+
+  form.value = {
+    name: pageSettingStore.pageSetting.name || '非结构化数据透明加密平台',
+    logo: pageSettingStore.pageSetting.logo || '',
+  }
+}
+</script>
+
+<template>
+  <div >
+    <div class="p-6 bg-[#fff] space-y-4 h-[calc(100vh-220px)]">
+      <div class="text-3xl">页面配置</div>
+      <el-divider class="my-4" />
+      <el-form
+        ref="formRef"
+        :model="form"
+        :rules="rules"
+        label-position="left"
+      >
+        <el-form-item
+          label="自定义名称"
+          prop="name"
+          class="my-10"
+        >
+          <el-input
+            v-model="form.name"
+            placeholder="请输入自定义名称"
+            class="w-1/6 "
+          />
+
+        </el-form-item>
+        <el-form-item
+          label="自定义Logo"
+          prop="logo"
+          class="form-item-center "
+        >
+          <div class="image-container">
+            <el-image
+              :src="resolveLogo(form.logo)"
+              class="image-position"
+              fit="cover"
+            />
+            <el-button
+              v-preReClick
+              class="button-position"
+              @click="openFileDialog()"
+            >修改头像</el-button
+            >
+          </div>
+        </el-form-item>
+      </el-form>
+      <div class="mt-10">
+        <el-button
+          v-preReClick
+          type="primary"
+
+          @click="changeSetting()"
+        >修改配置</el-button>
+        <el-button
+          v-preReClick
+          type="primary"
+          @click="resetSetting()"
+        >
+          恢复默认
+        </el-button>
+      </div>
+
+    </div>
+    <el-dialog
+      v-model="dialogImageVisible"
+      class="p-[50px]"
+      width="900"
+    >
+      <div class="flex items-center justify-center">
+        <div class="w-[710px]">
+          <div class="w-[710px] h-[300px] flex items-center">
+            <!-- 给组件固定宽高 -->
+            <div
+              class="h-[300px] w-[510px]"
+              style="width: 500px"
+            >
+              <VueCropper
+                ref="cropper"
+                :auto-crop="option.autoCrop"
+                :auto-crop-height="option.autoCropHeight"
+                :auto-crop-width="option.autoCropWidth"
+                :can-move="option.canMove"
+                :can-move-box="option.canMoveBox"
+                :can-scale="option.canScale"
+                :center-box="option.centerBox"
+                :enlarge="option.enlarge"
+                :fixed="option.fixed"
+                :fixed-box="option.fixedBox"
+                :fixed-number="option.fixedNumber"
+                :full="option.full"
+                :height="option.height"
+                :img="option.img"
+                :info="option.info"
+                :info-true="option.infoTrue"
+                :max-img-size="option.maxImgSize"
+                :mode="option.mode"
+                :original="option.original"
+                :output-size="option.outputSize"
+                :output-type="option.outputType"
+                @imgLoad="imgLoad"
+                @realTime="realTime"
+              />
+            </div>
+            <div class="ml-[60px]">
+              <div
+                class="text-center text-[#409EFF] mb-[20px] text-[20px] font-medium"
+              >
+                预览
+              </div>
+              <div class="flex items-center">
+                <div v-if="isReady">
+                  <el-image
+                    :src="baseUrl"
+                    class="rounded-[50%] w-[150px] h-[150px]"
+                  />
+                </div>
+              </div>
+            </div>
+          </div>
+          <div class="flex items-center justify-center mt-[30px]">
+            <div class="relative">
+              <el-button
+                class="mr-[15px]"
+                plain
+                type="primary"
+                @click="selectClick"
+              >
+                选择图片
+              </el-button>
+              <input
+                ref="inputRef"
+                accept="image/png, image/jpeg, image/gif, image/jpg"
+                style="position: absolute; clip: rect(0 0 0 0)"
+                type="file"
+                @change="selectImg"
+              >
+            </div>
+            <el-button
+              style="width: 96px"
+              type="primary"
+              @click="rotateLeft"
+            >↺ 左旋转</el-button>
+            <el-button
+              style="width: 96px"
+              type="primary"
+              @click="rotateRight"
+            >↻ 右旋转</el-button>
+            <el-button
+              plain
+              type="primary"
+              @click="imgUpload"
+            >上传图片</el-button>
+          </div>
+        </div>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.image-position {
+  border-radius: 50%;
+  width: 75px;
+  height: 75px;
+}
+/* 让该行整体垂直居中 */
+.form-item-center {
+  display: flex;
+  align-items: center; /* 垂直居中 label 和内容 */
+  /* 注意:Element Plus 的 label 宽度可能会影响,这里不改 label-width,仅做垂直对齐 */
+  gap: 12px;
+}
+
+/* 保证 label 本身也是纵向居中(增强兼容性) */
+.form-item-center :deep(.el-form-item__label) {
+  display: flex;
+  align-items: center;
+  height: 100%;
+  /* 可选:居中对齐文本 */
+  justify-content: center;
+  padding-top: 0; /* 覆盖默认上内边距 */
+}
+
+/* 内容区也用 flex 对齐,方便图片和按钮横向对齐 */
+.form-item-center :deep(.el-form-item__content) {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+}
+
+/* 限制图片尺寸并确保内部 img cover 填充 */
+.image-position {
+  width: 75px;
+  height: 75px;
+  border-radius: 50%;
+  overflow: hidden;
+  display: inline-block;
+}
+
+/* 因为 el-image 是个包装元素,强制其内部 img 填充容器 */
+.form-item-center :deep(.image-position) img {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+  display: block;
+}
+.button-position {
+  border-radius: 50%;
+  width: 75px;
+  height: 75px;
+  background-color: rgba(0, 0, 0, 0);
+  color: rgba(0, 0, 0, 0);
+}
+.button-position:hover {
+  border-radius: 50%;
+  width: 75px;
+  height: 75px;
+  background-color: rgba(0, 0, 0, 0.5);
+  color: #ffffff;
+  font-weight: bolder;
+}
+.image-container {
+  position: relative;
+}
+
+.image-container .button-position {
+  position: absolute;
+  top: 0;
+  left: 0;
+}
+</style>

+ 353 - 0
src/view/ude/setting/sysSetting/index.vue

@@ -0,0 +1,353 @@
+<template>
+  <div>
+    <div class="gva-search-box">
+      <el-form
+        ref="elSearchFormRef"
+        :inline="true"
+        :model="searchInfo"
+        class="demo-form-inline"
+      >
+        <el-form-item
+          label="配置项"
+          prop="configItem"
+        >
+          <el-input
+            v-model="searchInfo.configItem"
+            placeholder="搜索条件"
+          />
+        </el-form-item>
+        <el-form-item
+          label="配置描述"
+          prop="description"
+        >
+          <el-input
+            v-model="searchInfo.description"
+            placeholder="搜索条件"
+          />
+        </el-form-item>
+        <el-form-item>
+          <el-button
+            v-preReClick
+            type="primary"
+            icon="search"
+            @click="onSubmit"
+          >查询</el-button>
+          <el-button
+            icon="refresh"
+            @click="onReset"
+          >重置</el-button>
+        </el-form-item>
+      </el-form>
+    </div>
+    <div class="gva-table-box">
+      <div class="gva-btn-list">
+        <el-button
+          v-preReClick
+          type="primary"
+          icon="plus"
+          @click="openDialog"
+        >新增配置项</el-button>
+      </div>
+      <el-table
+        ref="multipleTable"
+        style="width: 100%"
+        tooltip-effect="dark"
+        :data="tableData"
+        row-key="ID"
+      >
+        <el-table-column
+          align="center"
+          label="配置项"
+          prop="configItem"
+          width="350"
+        />
+        <el-table-column
+          align="center"
+          label="配置值"
+          prop="configValue"
+          width="350"
+        />
+        <el-table-column
+          align="center"
+          label="配置描述"
+          prop="description"
+          width="600"
+        />
+        <el-table-column
+          align="center"
+          label="操作"
+          fixed="right"
+          min-width="240"
+        >
+          <template #default="scope">
+            <el-button
+              type="primary"
+              link
+              icon="edit"
+              @click="updateRow(scope.row)"
+            >更新配置</el-button>
+            <el-button
+              v-if="getDeleteFlag(scope.row.editFlag)"
+              type="primary"
+              link
+              icon="delete"
+              @click="deleteRow(scope.row)"
+            >删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+      <div class="gva-pagination">
+        <el-pagination
+          layout="total, sizes, prev, pager, next, jumper"
+          :current-page="page"
+          :page-size="pageSize"
+          :page-sizes="[10, 30, 50, 100]"
+          :total="total"
+          @current-change="handleCurrentChange"
+          @size-change="handleSizeChange"
+        />
+      </div>
+    </div>
+    <el-dialog
+      v-model="dialogVisible"
+      :title="dialogTitle"
+    >
+      <el-form
+        ref="elFormRef"
+        :model="formData"
+        :rules="rules"
+        label-width="100px"
+        class="demo-ruleForm"
+      >
+        <el-form-item
+          label="配置项"
+          prop="configItem"
+        >
+          <el-input
+            v-model="formData.configItem"
+            :disabled="editFlag"
+            placeholder="请输入配置项"
+          />
+        </el-form-item>
+        <el-form-item
+          label="配置值"
+          prop="configValue"
+        >
+          <el-select
+            v-if="formData.configItem === 'log_record_level'"
+            v-model="formData.configValue"
+            placeholder="请选择配置值"
+          >
+            <el-option
+              v-for="item in configValueOptions"
+              :key="item.value"
+              :label="item.label"
+              :value="item.value"
+            />
+          </el-select>
+          <el-input
+            v-else
+            v-model="formData.configValue"
+            placeholder="请输入配置值"
+          >
+            <template #prefix>
+              <span
+                v-if="formData.configItem === 'file_endpoint'"
+                class="bg-gray-300 text-black text-sm px-1.5 py-0.5"
+              >
+                http://
+              </span>
+            </template>
+          </el-input>
+        </el-form-item>
+        <el-form-item
+          label="配置描述"
+          prop="description"
+        >
+          <el-input
+            v-model="formData.description"
+            type="textarea"
+            rows="4"
+            placeholder="请输入配置描述"
+          />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <div class="dialog-footer">
+          <el-button v-preReClick @click="dialogVisible=false">取 消</el-button>
+          <el-button
+            v-preReClick
+            type="primary"
+            @click="submitForm()"
+          >确 定</el-button>
+        </div>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+<script setup>
+import { ref } from 'vue'
+import { addConfig, deleteConfig, getConfigList, updateConfig } from '@/api/sys/sysSetting'
+import { ElMessage, ElMessageBox } from 'element-plus'
+
+defineOptions({
+  name: 'MaskSysSetting'
+})
+
+const searchInfo = ref({})
+const tableData = ref([])
+
+const page = ref(1)
+const pageSize = ref(10)
+const total = ref(0)
+const elSearchFormRef = ref(null)
+
+const dialogVisible = ref(false)
+const dialogTitle = ref('')
+const formData = ref({})
+const editFlag = ref(false)
+const rules = ref({
+  configItem: [
+    { required: true, message: '请输入配置项', trigger: 'blur' },
+    { max: 30, message: '配置项长度不能超过 30 个字符', trigger: 'blur' }
+  ],
+  configValue: [
+    { required: true, message: '请输入配置值', trigger: 'blur' },
+    { max: 64, message: '配置值长度不能超过 64 个字符', trigger: 'blur' }
+  ],
+  description: [
+    { max: 200, message: '配置描述长度不能超过 200 个字符', trigger: 'blur' }
+  ]
+})
+const elFormRef = ref(null)
+const configValueOptions = ref([
+  {
+    value: 'debug',
+    label: 'debug'
+  },
+  {
+    value: 'info',
+    label: 'info'
+  },
+  {
+    value: 'warn',
+    label: 'warn'
+  },
+  {
+    value: 'error',
+    label: 'error'
+  }
+])
+// 新增
+const openDialog = () => {
+  dialogVisible.value = true
+  dialogTitle.value = '新增'
+  editFlag.value = false
+  formData.value = {
+    configItem: '',
+    configValue: '',
+    description: ''
+  }
+}
+const getEditFlag = (e) => {
+  editFlag.value = e === 1
+}
+const getDeleteFlag = (e) => {
+  return e === 2
+}
+// 修改
+const updateRow = (row) => {
+  dialogVisible.value = true
+  dialogTitle.value = '修改'
+  getEditFlag(row.editFlag)
+  formData.value = {
+    id: row.id,
+    configItem: row.configItem,
+    configValue: row.configValue,
+    description: row.description
+  }
+}
+// 删除
+const deleteRow = async(row) => {
+  ElMessageBox.confirm('确定要删除吗?', '提示', {
+    confirmButtonText: '确定',
+    cancelButtonText: '取消',
+    type: 'warning'
+  }).then(async() => {
+    // 调用删除接口
+    const res = await deleteConfig({
+      id: row.id
+    })
+    if (res.code === 100000) {
+      ElMessage.success('删除成功')
+      getTableData()
+    }
+  }).catch(() => {
+    ElMessage.info('已取消删除')
+  })
+}
+// 提交表单
+const submitForm = async() => {
+  await elFormRef.value?.validate(async(valid) => {
+    if (!valid) return
+    // 新增
+    if (dialogTitle.value === '新增') {
+      // 调用新增接口
+      const res = await addConfig(formData.value)
+      if (res.code === 100000) {
+        ElMessage.success('新增成功')
+        dialogVisible.value = false
+        await getTableData()
+      }
+    } else {
+      // 调用更新接口
+      const res = await updateConfig(formData.value)
+      if (res.code === 100000) {
+        ElMessage.success('更新成功')
+        dialogVisible.value = false
+        await getTableData()
+      }
+    }
+  })
+}
+// 重置
+const onReset = () => {
+  searchInfo.value = {}
+  getTableData()
+}
+
+// 搜索
+const onSubmit = () => {
+  elSearchFormRef.value?.validate(async(valid) => {
+    if (!valid) return
+    page.value = 1
+    pageSize.value = 10
+    getTableData()
+  })
+}
+// 分页
+const handleSizeChange = (val) => {
+  pageSize.value = val
+  getTableData()
+}
+
+// 修改页面容量
+const handleCurrentChange = (val) => {
+  page.value = val
+  getTableData()
+}
+
+const getTableData = async() => {
+  const res = await getConfigList({
+    page: page.value,
+    pageSize: pageSize.value,
+    ...searchInfo.value
+  })
+  if (res.code === 100000) {
+    tableData.value = res.data.configs
+    total.value = res.data.total
+  }
+}
+
+getTableData()
+</script>

+ 1 - 0
vite.config.js

@@ -60,6 +60,7 @@ export default ({ command, mode }) => {
         [process.env.VITE_BASE_API + '/v1']: {
           target: `${process.env.VITE_UDE_SERVER}`,
           changeOrigin: false,
+          ws: true, // 支持运维管理 WebSocket(/api/v1/ws/ops)升级代理
           rewrite: path => path.replace(new RegExp('^'), '')
         },
         [process.env.VITE_BASE_API]: {