Quellcode durchsuchen

1、增加运维管理和运维历史2个功能模块

程健 vor 3 Wochen
Ursprung
Commit
d789078c87
34 geänderte Dateien mit 2648 neuen und 208 gelöschten Zeilen
  1. 1 1
      public/config.js
  2. 4 0
      src/api/contract/index.js
  3. 69 0
      src/api/operation/operationEvent.js
  4. 1 1
      src/api/work/trainHead.js
  5. 2 7
      src/config/net.config.js
  6. 2 8
      src/extra/VabIconSelector/index.vue
  7. 2 7
      src/extra/VabIconSelector/remixIcon.js
  8. 2 6
      src/store/modules/settings.js
  9. 5 12
      src/store/modules/tabs.js
  10. 9 1
      src/utils/micro_request.js
  11. 2 4
      src/utils/request.js
  12. 1 3
      src/vab/components/VabBreadcrumb/index.vue
  13. 7 21
      src/vab/components/VabColumnBar/index.vue
  14. 2 8
      src/vab/components/VabErrorLog/index.vue
  15. 1 4
      src/vab/components/VabFold/index.vue
  16. 2 7
      src/vab/components/VabHeader/index.vue
  17. 1 3
      src/vab/components/VabLogo/index.vue
  18. 3 10
      src/vab/components/VabMenu/components/VabMenuItem.vue
  19. 1 5
      src/vab/components/VabMenu/components/VabSubmenu.vue
  20. 3 12
      src/vab/components/VabMenu/index.vue
  21. 1 4
      src/vab/components/VabRefresh/index.vue
  22. 14 19
      src/vab/components/VabRouterView/index.vue
  23. 4 16
      src/vab/components/VabSideBar/index.vue
  24. 1 2
      src/vab/index.js
  25. 1 3
      src/vab/layouts/VabLayoutCommon/index.vue
  26. 1 4
      src/vab/layouts/VabLayoutVertical/index.vue
  27. 1301 0
      src/views/devops/operation/components/OperationDetail.vue
  28. 361 0
      src/views/devops/operation/components/OperationEdit.vue
  29. 498 0
      src/views/devops/operation/index.vue
  30. 297 0
      src/views/devops/operationHistory/index.vue
  31. 9 9
      src/views/work/train/head/components/CreateTrainHead.vue
  32. 5 5
      src/views/work/train/head/components/Detail.vue
  33. 10 10
      src/views/work/train/head/components/FeedBackDetail.vue
  34. 25 16
      vue.config.js

+ 1 - 1
public/config.js

@@ -1,3 +1,3 @@
 const $GlobalConfig = {
-  baseUrl: '/vab-mock-server',
+  baseUrl: '/api',
 }

+ 4 - 0
src/api/contract/index.js

@@ -70,4 +70,8 @@ export default {
   updateReNew(query) {
     return micro_request.postRequest(basePath, 'CtrContract', 'UpdateReNew', query)
   },
+  // 合同模糊搜索(运维模块用)
+  searchContract(query) {
+    return micro_request.postRequest(basePath, 'CtrContract', 'SearchContract', query)
+  },
 }

+ 69 - 0
src/api/operation/operationEvent.js

@@ -0,0 +1,69 @@
+import micro_request from '@/utils/micro_request'
+
+const basePath = process.env.VUE_APP_ParentPath
+
+const eventStatusToOperateType = {
+  40: '40',
+  70: '70',
+  80: '80',
+}
+
+export default {
+  getList(query) {
+    return micro_request.postRequest(basePath, 'Operation', 'GetList', query)
+  },
+  getDetail(query) {
+    return micro_request.postRequest(basePath, 'Operation', 'GetEntityById', query)
+  },
+  doAdd(query) {
+    return micro_request.postRequest(basePath, 'Operation', 'Create', query)
+  },
+  doEdit(query) {
+    return micro_request.postRequest(basePath, 'Operation', 'UpdateById', query)
+  },
+  doDelete(query) {
+    return micro_request.postRequest(basePath, 'Operation', 'DeleteByIds', query)
+  },
+  assignOpsUser(query) {
+    return micro_request.postRequest(basePath, 'Operation', 'AssignOpsUser', query)
+  },
+  updateStatus(query) {
+    const operateType = query.operateType || eventStatusToOperateType[query.eventStatus] || '20'
+    return micro_request.postRequest(basePath, 'Operation', 'Process', {
+      id: query.id,
+      operateType: operateType,
+      handleContent: query.handleContent || '',
+      handleResult: query.handleResult || '',
+    })
+  },
+  getRecordList(query) {
+    return micro_request.postRequest(basePath, 'Operation', 'GetRecords', query)
+  },
+  addRecord(query) {
+    return micro_request.postRequest(basePath, 'Operation', 'AddRecord', query)
+  },
+  uploadAttachment(query) {
+    return micro_request.postRequest(basePath, 'Operation', 'UploadAttachment', query)
+  },
+  getAttachmentList(query) {
+    return micro_request.postRequest(basePath, 'Operation', 'GetAttachments', { id: query.eventId })
+  },
+  deleteAttachment(query) {
+    return micro_request.postRequest(basePath, 'Operation', 'DeleteAttachment', query)
+  },
+  getKanbanData(query) {
+    return micro_request.postRequest(basePath, 'Operation', 'GetKanbanData', query)
+  },
+  getStats() {
+    return micro_request.postRequest(basePath, 'Operation', 'GetStats', {})
+  },
+  getHistoryList(query) {
+    return micro_request.postRequest(basePath, 'Operation', 'GetHistoryList', query)
+  },
+  export(query) {
+    return micro_request.postRequest(basePath, 'Operation', 'Export', query)
+  },
+  exportNonClosed(query) {
+    return micro_request.postRequest(basePath, 'Operation', 'ExportNonClosed', query)
+  },
+}

+ 1 - 1
src/api/work/trainHead.js

@@ -22,7 +22,7 @@ export default {
   getDetail(query) {
     return micro_request.postRequest(basePath, 'TrainHead', 'GetEntityById', query)
   },
-   //详情
+  //详情
   getFeedBackDetail(query) {
     return micro_request.postRequest(basePath, 'TrainHead', 'GetDetailById', query)
   },

+ 2 - 7
src/config/net.config.js

@@ -2,13 +2,8 @@
  * @description 导出网络配置
  **/
 module.exports = {
-  // 默认的接口地址,开发环境和生产环境都会走/vab-mock-server
-  // 正式项目可以选择自己配置成需要的接口地址,如"https://api.xxx.com"
-  // 问号后边代表开发环境,冒号后边代表生产环境
-  baseURL:
-    process.env.NODE_ENV === 'development'
-      ? '/vab-mock-server'
-      : '/vab-mock-server',
+  // 默认接口前缀改为/api,可通过VUE_APP_BASE_API覆盖
+  baseURL: process.env.VUE_APP_BASE_API || '/api',
   // 配后端数据的接收方式application/json;charset=UTF-8 或 application/x-www-form-urlencoded;charset=UTF-8
   contentType: 'application/json;charset=UTF-8',
   // 最长请求时间

+ 2 - 8
src/extra/VabIconSelector/index.vue

@@ -8,9 +8,7 @@
               <el-input v-model="queryForm.title" />
             </el-form-item>
             <el-form-item label-width="0">
-              <el-button native-type="submit" type="primary" @click="queryData">
-                查询
-              </el-button>
+              <el-button native-type="submit" type="primary" @click="queryData">查询</el-button>
             </el-form-item>
           </el-form>
         </vab-query-form-top-panel>
@@ -73,11 +71,7 @@
         this.fetchData()
       },
       fetchData() {
-        const { list, total } = getRemixIconList(
-          this.queryForm.title,
-          this.queryForm.pageNum,
-          this.queryForm.pageSize
-        )
+        const { list, total } = getRemixIconList(this.queryForm.title, this.queryForm.pageNum, this.queryForm.pageSize)
         this.queryIcon = list
         this.total = total
       },

+ 2 - 7
src/extra/VabIconSelector/remixIcon.js

@@ -2277,12 +2277,7 @@ export function getRemixIconList(title, pageNum, pageSize) {
   pageNum = pageNum || 1
   pageSize = pageSize || 72
 
-  const mockList = IconList.filter(
-    (item) => !(title && item.indexOf(title) < 0)
-  )
-  const list = mockList.filter(
-    (item, index) =>
-      index < pageSize * pageNum && index >= pageSize * (pageNum - 1)
-  )
+  const mockList = IconList.filter((item) => !(title && item.indexOf(title) < 0))
+  const list = mockList.filter((item, index) => index < pageSize * pageNum && index >= pageSize * (pageNum - 1))
   return { list: list, total: mockList.length }
 }

+ 2 - 6
src/store/modules/settings.js

@@ -95,13 +95,9 @@ const mutations = {
     localStorage.removeItem('theme')
   },
   updateTheme(state) {
-    document.getElementsByTagName(
-      'body'
-    )[0].className = `vab-theme-${state.theme.themeName}`
+    document.getElementsByTagName('body')[0].className = `vab-theme-${state.theme.themeName}`
     if (state.theme.background !== 'none') {
-      document
-        .getElementsByTagName('body')[0]
-        .classList.add(state.theme.background)
+      document.getElementsByTagName('body')[0].classList.add(state.theme.background)
     }
   },
 }

+ 5 - 12
src/store/modules/tabs.js

@@ -20,8 +20,7 @@ const mutations = {
     else if (!target) state.visitedRoutes.push(Object.assign({}, route))
 
     //应对极特殊情况:没有配置noClosable的情况,默认使当前tab不可关闭
-    if (!state.visitedRoutes.find((route) => route.meta.noClosable))
-      state.visitedRoutes[0].meta.noClosable = true
+    if (!state.visitedRoutes.find((route) => route.meta.noClosable)) state.visitedRoutes[0].meta.noClosable = true
   },
   /**
    * @description 删除当前标签页
@@ -42,9 +41,7 @@ const mutations = {
    * @returns
    */
   delOthersVisitedRoutes(state, path) {
-    state.visitedRoutes = state.visitedRoutes.filter(
-      (route) => route.meta.noClosable || route.path === path
-    )
+    state.visitedRoutes = state.visitedRoutes.filter((route) => route.meta.noClosable || route.path === path)
   },
   /**
    * @description 删除当前标签页左边全部标签页
@@ -79,9 +76,7 @@ const mutations = {
    * @returns
    */
   delAllVisitedRoutes(state) {
-    state.visitedRoutes = state.visitedRoutes.filter(
-      (route) => route.meta.noClosable
-    )
+    state.visitedRoutes = state.visitedRoutes.filter((route) => route.meta.noClosable)
   },
   /**
    * @description 修改 meta
@@ -91,10 +86,8 @@ const mutations = {
   changeTabsMeta(state, options) {
     function handleVisitedRoutes(visitedRoutes) {
       return visitedRoutes.map((route) => {
-        if (route.name === options.name || route.meta.title === options.title)
-          Object.assign(route.meta, options.meta)
-        if (route.children && route.children.length)
-          route.children = handleVisitedRoutes(route.children)
+        if (route.name === options.name || route.meta.title === options.title) Object.assign(route.meta, options.meta)
+        if (route.children && route.children.length) route.children = handleVisitedRoutes(route.children)
         return route
       })
     }

+ 9 - 1
src/utils/micro_request.js

@@ -7,9 +7,17 @@ import { getFormData } from '@/utils/index'
 
 axios.defaults.headers['Content-Type'] = 'application/json;charset=utf-8'
 
+const microServiceProxyPrefix = '/micro-srv-proxy'
+const isHttpUrl = (url) => /^https?:\/\//i.test(url || '')
+
+const microBaseURL =
+  process.env.NODE_ENV === 'development' && isHttpUrl(process.env.VUE_APP_MicroSrvProxy_API)
+    ? microServiceProxyPrefix
+    : process.env.VUE_APP_MicroSrvProxy_API
+
 const service = axios.create({
   // axios中请求配置有baseURL选项,表示请求URL公共部分
-  baseURL: process.env.VUE_APP_MicroSrvProxy_API,
+  baseURL: microBaseURL,
   // 超时
   timeout: 60000,
 })

+ 2 - 4
src/utils/request.js

@@ -1,7 +1,7 @@
 import Vue from 'vue'
 import axios from 'axios'
 import {
-  // baseURL,
+  baseURL,
   contentType,
   debounce,
   messageName,
@@ -83,9 +83,7 @@ const handleData = async ({ data, status = 0, statusText }) => {
  * @description axios初始化
  */
 const instance = axios.create({
-  // baseURL,
-  // eslint-disable-next-line no-undef
-  baseURL: '/vab-mock-server',
+  baseURL,
   timeout: requestTimeout,
   headers: {
     'Content-Type': contentType,

+ 1 - 3
src/vab/components/VabBreadcrumb/index.vue

@@ -21,9 +21,7 @@
         routes: 'routes/routes',
       }),
       breadcrumbList() {
-        return handleMatched(this.routes, this.$route.name).filter(
-          (item) => item.meta.breadcrumbHidden !== true
-        )
+        return handleMatched(this.routes, this.$route.name).filter((item) => item.meta.breadcrumbHidden !== true)
       },
     },
     methods: {

+ 7 - 21
src/vab/components/VabColumnBar/index.vue

@@ -6,10 +6,7 @@
       ['vab-column-bar-container-' + theme.columnStyle]: true,
     }">
     <vab-logo />
-    <el-tabs
-      v-model="extra.first"
-      tab-position="left"
-      @tab-click="handleTabClick">
+    <el-tabs v-model="extra.first" tab-position="left" @tab-click="handleTabClick">
       <template v-for="(route, index) in handleRoutes">
         <el-tab-pane :key="index + route.name" :name="route.name">
           <template slot="label">
@@ -20,10 +17,7 @@
               }"
               :title="translateTitle(route.meta.title)">
               <div>
-                <vab-icon
-                  v-if="route.meta.icon"
-                  :icon="route.meta.icon"
-                  :is-custom-svg="route.meta.isCustomSvg" />
+                <vab-icon v-if="route.meta.icon" :icon="route.meta.icon" :is-custom-svg="route.meta.isCustomSvg" />
                 <span>
                   {{ translateTitle(route.meta.title) }}
                 </span>
@@ -44,10 +38,7 @@
         {{ translateTitle(handleGroupTitle) }}
       </el-divider>
       <template v-for="route in handlePartialRoutes">
-        <vab-menu
-          v-if="route.meta && !route.meta.hidden"
-          :key="route.path"
-          :item="route" />
+        <vab-menu v-if="route.meta && !route.meta.hidden" :key="route.path" :item="route" />
       </template>
     </el-menu>
   </el-scrollbar>
@@ -80,9 +71,7 @@
         extra: 'settings/extra',
       }),
       handleRoutes() {
-        return this.routes.filter(
-          (route) => route.meta && route.meta.hidden !== true
-        )
+        return this.routes.filter((route) => route.meta && route.meta.hidden !== true)
       },
       handleActiveMenu() {
         return this.routes.find((route) => route.name === this.extra.first)
@@ -125,19 +114,16 @@
       translateTitle,
       handleTabClick(handler) {
         this.handleNoColumn()
-        if (handler !== true && openFirstMenu)
-          this.$router.push(this.handleActiveMenu)
+        if (handler !== true && openFirstMenu) this.$router.push(this.handleActiveMenu)
       },
       handleNoColumn() {
         this.$nextTick(() => {
           if (this.theme.layout === 'column' && this.$route.meta.noColumn) {
             this.foldSideBar()
-            if (document.querySelector('.fold-unfold'))
-              document.querySelector('.fold-unfold').style = 'display:none'
+            if (document.querySelector('.fold-unfold')) document.querySelector('.fold-unfold').style = 'display:none'
           } else {
             this.openSideBar()
-            if (document.querySelector('.fold-unfold'))
-              document.querySelector('.fold-unfold').style = ''
+            if (document.querySelector('.fold-unfold')) document.querySelector('.fold-unfold').style = ''
           }
         })
       },

+ 2 - 8
src/vab/components/VabErrorLog/index.vue

@@ -1,8 +1,6 @@
 <template>
   <div v-if="errorLogs.length > 0">
-    <el-badge
-      :value="errorLogs.length"
-      @click.native="dialogTableVisible = true">
+    <el-badge :value="errorLogs.length" @click.native="dialogTableVisible = true">
       <vab-icon icon="bug-line" />
     </el-badge>
 
@@ -36,11 +34,7 @@
         </el-table-column>
         <el-table-column label="操作" width="380">
           <template #default="{ row }">
-            <a
-              v-for="(item, index) in searchList"
-              :key="index"
-              :href="item.url + row.err.message"
-              target="_blank">
+            <a v-for="(item, index) in searchList" :key="index" :href="item.url + row.err.message" target="_blank">
               <el-button type="primary">
                 {{ item.title }}
               </el-button>

+ 1 - 4
src/vab/components/VabFold/index.vue

@@ -1,8 +1,5 @@
 <template>
-  <vab-icon
-    class="fold-unfold"
-    :icon="collapse ? 'menu-unfold-line' : 'menu-fold-line'"
-    @click="toggleCollapse" />
+  <vab-icon class="fold-unfold" :icon="collapse ? 'menu-unfold-line' : 'menu-fold-line'" @click="toggleCollapse" />
 </template>
 
 <script>

+ 2 - 7
src/vab/components/VabHeader/index.vue

@@ -64,9 +64,7 @@
       },
       handleRoutes() {
         return this.routes.flatMap((route) => {
-          return route.meta && route.meta.levelHidden === true && route.children
-            ? route.children
-            : route
+          return route.meta && route.meta.levelHidden === true && route.children ? route.children : route
         })
       },
     },
@@ -105,10 +103,7 @@
         height: $base-top-bar-height;
 
         ::v-deep {
-          > .el-menu--horizontal.el-menu
-            > .el-submenu
-            > .el-submenu__title
-            > .el-submenu__icon-arrow {
+          > .el-menu--horizontal.el-menu > .el-submenu > .el-submenu__title > .el-submenu__icon-arrow {
             float: right;
             margin-top: ($base-top-bar-height - 11) / 2 !important;
           }

+ 1 - 3
src/vab/components/VabLogo/index.vue

@@ -9,9 +9,7 @@
         <!-- 使用自定义svg示例 -->
         <vab-icon v-if="logo" :icon="logo" is-custom-svg />
       </span>
-      <span
-        class="title"
-        :class="{ 'hidden-xs-only': theme.layout === 'horizontal' }">
+      <span class="title" :class="{ 'hidden-xs-only': theme.layout === 'horizontal' }">
         {{ title }}
       </span>
     </router-link>

+ 3 - 10
src/vab/components/VabMenu/components/VabMenuItem.vue

@@ -8,15 +8,10 @@
     <span :title="translateTitle(itemOrMenu.meta.title)">
       {{ translateTitle(itemOrMenu.meta.title) }}
     </span>
-    <el-tag
-      v-if="itemOrMenu.meta && itemOrMenu.meta.badge"
-      effect="dark"
-      type="danger">
+    <el-tag v-if="itemOrMenu.meta && itemOrMenu.meta.badge" effect="dark" type="danger">
       {{ itemOrMenu.meta.badge }}
     </el-tag>
-    <span
-      v-if="itemOrMenu.meta && itemOrMenu.meta.dot"
-      class="vab-dot vab-dot-error">
+    <span v-if="itemOrMenu.meta && itemOrMenu.meta.dot" class="vab-dot vab-dot-error">
       <span />
     </span>
   </el-menu-item>
@@ -54,9 +49,7 @@
         if (target === '_blank') {
           if (isExternal(routePath)) window.open(routePath)
           else if (this.$route.fullPath !== routePath)
-            routerMode === 'hash'
-              ? window.open('/#' + routePath)
-              : window.open(routePath)
+            routerMode === 'hash' ? window.open('/#' + routePath) : window.open(routePath)
         } else {
           if (isExternal(routePath)) window.location.href = routePath
           else if (this.$route.fullPath !== routePath) {

+ 1 - 5
src/vab/components/VabMenu/components/VabSubmenu.vue

@@ -4,11 +4,7 @@
       <vab-menu :key="route.path" :item="route" />
     </template>
   </div>
-  <el-submenu
-    v-else
-    ref="subMenu"
-    :index="itemOrMenu.path"
-    :popper-append-to-body="false">
+  <el-submenu v-else ref="subMenu" :index="itemOrMenu.path" :popper-append-to-body="false">
     <template slot="title">
       <vab-icon
         v-if="itemOrMenu.meta && itemOrMenu.meta.icon"

+ 3 - 12
src/vab/components/VabMenu/index.vue

@@ -1,8 +1,5 @@
 <template>
-  <component
-    :is="menuComponent"
-    v-if="item.meta && !item.meta.hidden"
-    :item-or-menu="item">
+  <component :is="menuComponent" v-if="item.meta && !item.meta.hidden" :item-or-menu="item">
     <template v-if="item.children && item.children.length">
       <el-scrollbar
         v-if="
@@ -10,16 +7,10 @@
           (layout !== 'horizontal' && collapse && item.children.length > 18)
         "
         class="vab-menu-children-height">
-        <vab-menu
-          v-for="route in item.children"
-          :key="route.path"
-          :item="route" />
+        <vab-menu v-for="route in item.children" :key="route.path" :item="route" />
       </el-scrollbar>
       <template v-else>
-        <vab-menu
-          v-for="route in item.children"
-          :key="route.path"
-          :item="route" />
+        <vab-menu v-for="route in item.children" :key="route.path" :item="route" />
       </template>
     </template>
   </component>

+ 1 - 4
src/vab/components/VabRefresh/index.vue

@@ -1,8 +1,5 @@
 <template>
-  <vab-icon
-    v-if="theme.showRefresh"
-    icon="refresh-line"
-    @click="refreshRoute" />
+  <vab-icon v-if="theme.showRefresh" icon="refresh-line" @click="refreshRoute" />
 </template>
 
 <script>

+ 14 - 19
src/vab/components/VabRouterView/index.vue

@@ -43,29 +43,24 @@
     },
     created() {
       this.updateKeepAliveNameList()
-      this.$baseEventBus.$on(
-        'reload-router-view',
-        (refreshRouteName = this.$route.name) => {
-          if (this.theme.showProgressBar) VabProgress.start()
-          const cacheActivePath = this.routerKey
-          this.routerKey = null
-          this.updateKeepAliveNameList(refreshRouteName)
-          this.$nextTick(() => {
-            this.routerKey = cacheActivePath
-            this.updateKeepAliveNameList()
-          })
-          setTimeout(() => {
-            if (this.theme.showProgressBar) VabProgress.done()
-          }, 200)
-        }
-      )
+      this.$baseEventBus.$on('reload-router-view', (refreshRouteName = this.$route.name) => {
+        if (this.theme.showProgressBar) VabProgress.start()
+        const cacheActivePath = this.routerKey
+        this.routerKey = null
+        this.updateKeepAliveNameList(refreshRouteName)
+        this.$nextTick(() => {
+          this.routerKey = cacheActivePath
+          this.updateKeepAliveNameList()
+        })
+        setTimeout(() => {
+          if (this.theme.showProgressBar) VabProgress.done()
+        }, 200)
+      })
     },
     methods: {
       updateKeepAliveNameList(refreshRouteName = null) {
         this.keepAliveNameList = this.visitedRoutes
-          .filter(
-            (item) => !item.meta.noKeepAlive && item.name !== refreshRouteName
-          )
+          .filter((item) => !item.meta.noKeepAlive && item.name !== refreshRouteName)
           .map((item) => item.name)
       },
     },

+ 4 - 16
src/vab/components/VabSideBar/index.vue

@@ -5,12 +5,7 @@
       'is-collapse': collapse,
       'side-bar-common': layout === 'common',
     }">
-    <vab-logo
-      v-if="
-        layout === 'vertical' ||
-        layout === 'comprehensive' ||
-        layout === 'float'
-      " />
+    <vab-logo v-if="layout === 'vertical' || layout === 'comprehensive' || layout === 'float'" />
     <el-menu
       :active-text-color="variables['menu-color-active']"
       :background-color="variables['menu-background']"
@@ -23,10 +18,7 @@
       :text-color="variables['menu-color']"
       :unique-opened="uniqueOpened">
       <template v-for="(route, index) in handleRoutes">
-        <vab-menu
-          v-if="route.meta && !route.meta.hidden"
-          :key="index + route.name"
-          :item="route" />
+        <vab-menu v-if="route.meta && !route.meta.hidden" :key="index + route.name" :item="route" />
       </template>
     </el-menu>
   </el-scrollbar>
@@ -65,15 +57,11 @@
         return this.layout === 'comprehensive'
           ? this.handlePartialRoutes
           : this.routes.flatMap((route) =>
-              route.meta && route.meta.levelHidden === true && route.children
-                ? route.children
-                : route
+              route.meta && route.meta.levelHidden === true && route.children ? route.children : route
             )
       },
       handlePartialRoutes() {
-        const activeMenu = this.routes.find(
-          (route) => route.name === this.extra.first
-        )
+        const activeMenu = this.routes.find((route) => route.name === this.extra.first)
         return activeMenu ? activeMenu.children : []
       },
     },

+ 1 - 2
src/vab/index.js

@@ -24,6 +24,5 @@ const Components = require.context('.', true, /\.vue$/)
 Components.keys()
   .map(Components)
   .forEach((item) => {
-    if (item.default.name && item.default.name !== 'Layouts')
-      Vue.component(item.default.name, item.default)
+    if (item.default.name && item.default.name !== 'Layouts') Vue.component(item.default.name, item.default)
   })

+ 1 - 3
src/vab/layouts/VabLayoutCommon/index.vue

@@ -77,9 +77,7 @@
 
     ::v-deep {
       .vab-tabs-content {
-        width: calc(
-          100% - 60px - #{$base-font-size-default} - #{$base-padding} - 2px
-        ) !important;
+        width: calc(100% - 60px - #{$base-font-size-default} - #{$base-padding} - 2px) !important;
       }
 
       .vab-header {

+ 1 - 4
src/vab/layouts/VabLayoutVertical/index.vue

@@ -7,10 +7,7 @@
       'no-tabs-bar': !showTabs,
     }">
     <vab-side-bar />
-    <div
-      v-if="device === 'mobile' && !collapse"
-      class="v-modal"
-      @click="handleFoldSideBar" />
+    <div v-if="device === 'mobile' && !collapse" class="v-modal" @click="handleFoldSideBar" />
     <div
       class="vab-main"
       :class="{

+ 1301 - 0
src/views/devops/operation/components/OperationDetail.vue

@@ -0,0 +1,1301 @@
+<template>
+  <div>
+    <el-dialog
+      class="process-dialog"
+      :close-on-click-modal="false"
+      :show-close="false"
+      top="5vh"
+      :visible="visible"
+      width="900px"
+      @close="handleClose">
+      <!-- 头部标题区域 -->
+      <div slot="title" class="dialog-header">
+        <div class="header-left">
+          <div class="header-icon">{{ data.eventNo ? data.eventNo.charAt(0) : 'E' }}</div>
+          <span class="header-title">{{ data.eventNo }}</span>
+        </div>
+        <div class="header-actions">
+          <template v-if="readOnly">
+            <el-button size="small" @click="handleClose">关闭</el-button>
+          </template>
+          <template v-else-if="isReceiveMode">
+            <el-button size="small" @click="handleClose">取消</el-button>
+            <el-button :loading="submitLoading" size="small" type="primary" @click="handleReceive">确认接收</el-button>
+          </template>
+          <template v-else>
+            <el-button v-if="data.eventStatus === '10'" size="small" type="primary" @click="handleReceive">
+              接收
+            </el-button>
+            <template v-else-if="data.eventStatus === '20' || data.eventStatus === '30'">
+              <el-button size="small" type="danger" @click="handleComplete">关闭</el-button>
+              <el-button size="small" type="warning" @click="handleSuspend">挂起</el-button>
+              <el-button size="small" type="info" @click="handleTransferDev">转开发</el-button>
+            </template>
+            <template v-else-if="data.eventStatus === '40'">
+              <el-button size="small" type="danger" @click="handleComplete">关闭</el-button>
+              <el-button size="small" type="warning" @click="handleSuspend">挂起</el-button>
+            </template>
+            <template v-else-if="data.eventStatus === '70'">
+              <el-button size="small" type="success" @click="handleResume">转处理</el-button>
+            </template>
+            <el-button size="small" @click="handleClose">取消</el-button>
+          </template>
+        </div>
+      </div>
+
+      <!-- 事件标题 -->
+      <div class="event-main-title">{{ data.eventTitle }}</div>
+
+      <!-- 信息卡片区域 -->
+      <div class="info-cards">
+        <div class="info-card">
+          <div class="card-icon blue">
+            <i class="el-icon-user" />
+          </div>
+          <div class="card-content">
+            <div class="card-value">{{ data.opsUserName || '-' }}</div>
+            <div class="card-label">运维人员</div>
+          </div>
+        </div>
+        <div class="info-card">
+          <div class="card-icon orange">
+            <i class="el-icon-s-promotion" />
+          </div>
+          <div class="card-content">
+            <div class="card-value">{{ getStatusLabel(data.eventStatus) }}</div>
+            <div class="card-label">当前状态</div>
+          </div>
+        </div>
+        <div class="info-card">
+          <div class="card-icon red">
+            <i class="el-icon-warning" />
+          </div>
+          <div class="card-content">
+            <div class="card-value">{{ data.priorityLevel }}</div>
+            <div class="card-label">优先级</div>
+          </div>
+        </div>
+        <div class="info-card info-card-large">
+          <div class="card-icon green">
+            <i class="el-icon-office-building" />
+          </div>
+          <div class="card-content">
+            <div class="card-value">{{ data.custName }}</div>
+            <div class="card-label">客户名称</div>
+          </div>
+        </div>
+      </div>
+
+      <!-- 主内容区 -->
+      <div class="dialog-body">
+        <!-- 左侧描述区域 -->
+        <div class="left-panel">
+          <div class="section-title">描述</div>
+
+          <!-- 只读模式显示事件描述 -->
+          <div class="event-desc-wrapper">
+            <div class="event-desc" v-html="data.eventDesc || '暂无描述'"></div>
+          </div>
+
+          <!-- 属性区域 - 平铺显示 -->
+          <div class="property-section">
+            <div class="section-title">属性信息</div>
+            <div class="property-grid">
+              <div class="property-item">
+                <span class="property-label">负责人</span>
+                <span class="property-value">{{ data.opsUserName || '-' }}</span>
+              </div>
+              <div class="property-item">
+                <span class="property-label">事件类型</span>
+                <span class="property-value">{{ getEventTypeLabel(data.eventType) }}</span>
+              </div>
+              <div class="property-item">
+                <span class="property-label">优先级</span>
+                <span class="property-value">{{ data.priorityLevel }}</span>
+              </div>
+              <div class="property-item">
+                <span class="property-label">合同编号</span>
+                <span class="property-value">{{ data.eventNo }}</span>
+              </div>
+              <div class="property-item">
+                <span class="property-label">反馈人</span>
+                <span class="property-value">{{ data.feedbackReporter || '-' }}</span>
+              </div>
+              <div class="property-item">
+                <span class="property-label">反馈时间</span>
+                <span class="property-value">{{ formatTime(data.feedbackDate) }}</span>
+              </div>
+            </div>
+          </div>
+        </div>
+
+        <!-- 右侧动态区域 -->
+        <div class="right-panel">
+          <div class="panel-header">
+            <span class="panel-title">动态</span>
+          </div>
+          <el-tabs v-model="activeTab">
+            <el-tab-pane label="过程记录" name="record">
+              <div class="record-container">
+                <div class="record-list">
+                  <div v-if="recordList.length === 0" class="empty-record">暂无过程记录</div>
+                  <div v-for="(record, index) in recordList" :key="index" class="record-item">
+                    <div class="record-avatar">{{ getFirstChar(record.handleUserName) }}</div>
+                    <div class="record-user">{{ record.handleUserName }}</div>
+                    <div class="record-content-box">
+                      <div class="record-content" v-html="record.handleContent"></div>
+                    </div>
+                    <div class="record-time">{{ formatTime(record.handleDate) }}</div>
+                  </div>
+                </div>
+
+                <!-- 快速登记功能 -->
+                <div v-if="!readOnly" class="quick-record-section">
+                  <div v-if="!showQuickInput" class="quick-record-trigger" @click="showQuickInput = true">
+                    <span class="placeholder">记录一下...</span>
+                    <i class="el-icon-paperclip" @click.stop="openFileUpload" />
+                  </div>
+                  <div v-else class="quick-record-editor">
+                    <div style="border: 1px solid #dcdfe6; border-radius: 4px">
+                      <Toolbar
+                        :default-config="quickToolbarConfig"
+                        :editor="quickEditor"
+                        :mode="mode"
+                        style="border-bottom: 1px solid #dcdfe6" />
+                      <Editor
+                        v-model="quickRecordContent"
+                        :default-config="quickEditorConfig"
+                        :mode="mode"
+                        style="height: 120px; overflow-y: auto"
+                        @onCreated="onQuickEditorCreated" />
+                    </div>
+                    <div class="quick-record-actions">
+                      <el-button size="small" @click="cancelQuickRecord">取消</el-button>
+                      <el-button :loading="submitLoading" size="small" type="primary" @click="submitQuickRecord">
+                        发送
+                      </el-button>
+                    </div>
+                  </div>
+                </div>
+              </div>
+            </el-tab-pane>
+            <el-tab-pane label="文件" name="file">
+              <div class="file-list">
+                <div v-if="attachmentList.length === 0" class="empty-file">暂无文件</div>
+                <div v-for="(file, index) in attachmentList" :key="index" class="file-item">
+                  <i class="el-icon-document" />
+                  <span class="file-name">{{ file.fileName }}</span>
+                  <el-button size="mini" type="text" @click="downloadFile(file)">下载</el-button>
+                </div>
+              </div>
+            </el-tab-pane>
+          </el-tabs>
+        </div>
+      </div>
+    </el-dialog>
+
+    <el-dialog
+      append-to-body
+      :close-on-click-modal="false"
+      title="关闭事件"
+      :visible.sync="closeDialogVisible"
+      width="450px">
+      <el-form label-width="80px">
+        <el-form-item label="解决状态">
+          <el-select v-model="closeForm.handleResult" style="width: 100%">
+            <el-option v-for="item in handleResultOptions" :key="item.value" :label="item.label" :value="item.value" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="关闭原因">
+          <el-input
+            v-model="closeForm.handleContent"
+            maxlength="500"
+            placeholder="请输入关闭原因"
+            :rows="4"
+            type="textarea" />
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="closeDialogVisible = false">取消</el-button>
+        <el-button :loading="submitLoading" type="primary" @click="submitClose">确定</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+  import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
+  import operationEventApi from '@/api/operation/operationEvent'
+  import { parseTime } from '@/utils'
+
+  export default {
+    name: 'OperationDetail',
+    components: { Editor, Toolbar },
+    props: {
+      visible: {
+        type: Boolean,
+        default: false,
+      },
+      data: {
+        type: Object,
+        default: () => ({}),
+      },
+      mode: {
+        type: String,
+        default: 'view',
+      },
+      action: {
+        type: String,
+        default: '',
+      },
+      readOnly: {
+        type: Boolean,
+        default: false,
+      },
+    },
+    data() {
+      return {
+        editor: null,
+        toolbarConfig: {
+          toolbarKeys: [
+            'bold',
+            'italic',
+            'underline',
+            'through',
+            '|',
+            'color',
+            'bgColor',
+            '|',
+            'bulletedList',
+            'numberedList',
+            '|',
+            'uploadImage',
+            '|',
+            'undo',
+            'redo',
+          ],
+        },
+        editorConfig: {
+          placeholder: '请输入处理描述...',
+          MENU_CONF: {
+            uploadImage: {
+              customUpload: this.handleImageUpload,
+            },
+          },
+        },
+        isEditing: false,
+        isReceiveMode: false,
+        isProcessMode: false,
+        showProperty: false,
+        activeTab: 'record',
+        submitLoading: false,
+        recordList: [],
+        attachmentList: [],
+        editForm: {
+          handleContent: '',
+          handleResult: '10',
+        },
+        // 快速登记相关数据
+        showQuickInput: false,
+        quickEditor: null,
+        quickRecordContent: '',
+        quickToolbarConfig: {
+          toolbarKeys: ['uploadImage', '|', 'bold', 'italic', 'underline', '|', 'bulletedList', 'numberedList'],
+        },
+        quickEditorConfig: {
+          placeholder: '记录一下...',
+          MENU_CONF: {
+            uploadImage: {
+              customUpload: this.handleQuickImageUpload,
+            },
+          },
+        },
+        closeDialogVisible: false,
+        closeForm: {
+          handleResult: '10',
+          handleContent: '',
+        },
+        handleResultOptions: [
+          { value: '10', label: '已解决' },
+          { value: '20', label: '部分解决' },
+          { value: '30', label: '未解决' },
+        ],
+        detailAction: '',
+      }
+    },
+    watch: {
+      visible(val) {
+        if (val) {
+          this.initDialog()
+          this.fetchRecordList()
+          this.fetchAttachmentList()
+        }
+      },
+      mode: {
+        immediate: true,
+        handler(val) {
+          if (val === 'receive') {
+            this.isReceiveMode = true
+            this.isProcessMode = false
+            this.isEditing = false
+          } else if (val === 'process') {
+            this.isReceiveMode = false
+            this.isProcessMode = true
+            this.isEditing = true
+            this.editForm.handleContent = ''
+          } else {
+            this.isReceiveMode = false
+            this.isProcessMode = false
+            this.isEditing = false
+          }
+        },
+      },
+    },
+    beforeDestroy() {
+      if (this.editor) {
+        this.editor.destroy()
+        this.editor = null
+      }
+    },
+    methods: {
+      onEditorCreated(editor) {
+        this.editor = editor
+      },
+      handleImageUpload(file, insertFn) {
+        const reader = new FileReader()
+        reader.readAsDataURL(file)
+        reader.onload = () => {
+          insertFn(reader.result, file.name, reader.result)
+        }
+      },
+      initDialog() {
+        this.showProperty = false
+        this.activeTab = 'record'
+        this.detailAction = this.action || ''
+        if (this.mode === 'receive') {
+          this.isReceiveMode = true
+          this.isProcessMode = false
+          this.isEditing = false
+        } else if (this.mode === 'process') {
+          this.isReceiveMode = false
+          this.isProcessMode = true
+          this.isEditing = true
+          this.editForm.handleContent = ''
+        } else {
+          this.isReceiveMode = false
+          this.isProcessMode = false
+          this.isEditing = false
+        }
+      },
+      startReceive() {
+        this.isReceiveMode = true
+        this.isProcessMode = false
+        this.isEditing = false
+      },
+      startAction(action) {
+        this.isReceiveMode = false
+        this.isProcessMode = true
+        this.isEditing = true
+        this.editForm.handleContent = ''
+        this.detailAction = action
+      },
+      startProcess() {
+        this.isReceiveMode = false
+        this.isProcessMode = true
+        this.isEditing = true
+        this.editForm.handleContent = ''
+      },
+      async fetchRecordList() {
+        if (!this.data.id) return
+        try {
+          const res = await operationEventApi.getRecordList({ eventId: this.data.id })
+          if (res.code === 200) {
+            this.recordList = res.data.list || []
+          }
+        } catch (error) {
+          console.error('获取过程记录失败:', error)
+        }
+      },
+      async fetchAttachmentList() {
+        if (!this.data.id) return
+        try {
+          const res = await operationEventApi.getAttachmentList({ eventId: this.data.id })
+          if (res.code === 200) {
+            this.attachmentList = res.data || []
+          }
+        } catch (error) {
+          console.error('获取附件列表失败:', error)
+        }
+      },
+      async handleReceive() {
+        this.submitLoading = true
+        try {
+          const res = await operationEventApi.assignOpsUser({
+            id: this.data.id,
+          })
+          if (res.code === 200) {
+            this.$message.success('接收成功')
+            this.$emit('refresh')
+            this.handleClose()
+          }
+        } catch (error) {
+          console.error('接收失败:', error)
+        } finally {
+          this.submitLoading = false
+        }
+      },
+      async handleSaveProcess() {
+        if (!this.editForm.handleContent.trim()) {
+          this.$message.warning('请输入处理描述')
+          return
+        }
+
+        this.submitLoading = true
+        try {
+          const res = await operationEventApi.addRecord({
+            eventId: this.data.id,
+            handleContent: this.editForm.handleContent,
+            handleResult: this.editForm.handleResult,
+          })
+
+          if (res.code === 200) {
+            this.$message.success('处理记录保存成功')
+            this.fetchRecordList()
+            this.isProcessMode = false
+            this.isEditing = false
+            this.$emit('refresh')
+          }
+        } catch (error) {
+          console.error('保存处理记录失败:', error)
+        } finally {
+          this.submitLoading = false
+        }
+      },
+      downloadFile(file) {
+        window.open(file.fileUrl, '_blank')
+      },
+      handleClose() {
+        this.isReceiveMode = false
+        this.isProcessMode = false
+        this.isEditing = false
+        this.editForm.handleContent = ''
+        if (this.editor) {
+          this.editor.clear()
+        }
+        this.$emit('update:visible', false)
+      },
+      getStatusLabel(status) {
+        const map = {
+          10: '待处理',
+          20: '处理中(重点)',
+          30: '处理中(普通)',
+          40: '转研发',
+          70: '挂起',
+          80: '已关闭',
+        }
+        return map[status] || status
+      },
+      getEventTypeLabel(type) {
+        const map = {
+          10: '操作咨询',
+          20: '数据处理',
+          30: '系统BUG',
+          40: '功能调整',
+          50: '二开需求',
+          90: '其他问题',
+        }
+        return map[type] || type
+      },
+      formatTime(time) {
+        return time ? parseTime(time, '{y}-{m}-{d} {h}:{i}') : '-'
+      },
+      // 状态变更处理方法
+      async handleComplete() {
+        this.closeForm.handleResult = '10'
+        this.closeForm.handleContent = ''
+        this.closeDialogVisible = true
+      },
+      async submitClose() {
+        if (!this.closeForm.handleContent.trim()) {
+          this.$message.warning('请输入关闭原因')
+          return
+        }
+        this.submitLoading = true
+        try {
+          const res = await operationEventApi.updateStatus({
+            id: this.data.id,
+            eventStatus: '80',
+            handleContent: this.closeForm.handleContent.trim(),
+            handleResult: this.closeForm.handleResult,
+          })
+          if (res.code === 200) {
+            this.$message.success('事件已关闭')
+            this.closeDialogVisible = false
+            this.isProcessMode = false
+            this.isEditing = false
+            this.$emit('refresh')
+            this.handleClose()
+          }
+        } catch (error) {
+          console.error('关闭事件失败:', error)
+        } finally {
+          this.submitLoading = false
+        }
+      },
+      async handleSuspend() {
+        this.$prompt('请填写挂起原因', '挂起事件', {
+          confirmButtonText: '确定',
+          cancelButtonText: '取消',
+          inputType: 'textarea',
+          inputPlaceholder: '请输入挂起原因',
+          inputValidator: (value) => {
+            if (!value || !value.trim()) {
+              return '挂起原因不能为空'
+            }
+            return true
+          },
+        })
+          .then(async ({ value }) => {
+            this.submitLoading = true
+            try {
+              const res = await operationEventApi.updateStatus({
+                id: this.data.id,
+                eventStatus: '70',
+                handleContent: value.trim(),
+              })
+              if (res.code === 200) {
+                this.$message.success('事件已挂起')
+                this.isProcessMode = false
+                this.isEditing = false
+                this.$emit('refresh')
+                this.handleClose()
+              }
+            } catch (error) {
+              console.error('挂起事件失败:', error)
+            } finally {
+              this.submitLoading = false
+            }
+          })
+          .catch(() => {})
+      },
+      async handleTransferDev() {
+        this.$prompt('请填写转研发原因', '转研发处理', {
+          confirmButtonText: '确定',
+          cancelButtonText: '取消',
+          inputType: 'textarea',
+          inputPlaceholder: '请输入转研发原因',
+          inputValidator: (value) => {
+            if (!value || !value.trim()) {
+              return '转研发原因不能为空'
+            }
+            return true
+          },
+        })
+          .then(async ({ value }) => {
+            this.submitLoading = true
+            try {
+              const res = await operationEventApi.updateStatus({
+                id: this.data.id,
+                eventStatus: '40',
+                handleContent: value.trim(),
+              })
+              if (res.code === 200) {
+                this.$message.success('事件已转研发')
+                this.isProcessMode = false
+                this.isEditing = false
+                this.$emit('refresh')
+                this.handleClose()
+              }
+            } catch (error) {
+              console.error('转研发失败:', error)
+            } finally {
+              this.submitLoading = false
+            }
+          })
+          .catch(() => {})
+      },
+      async handleResume() {
+        this.$prompt('请填写转处理说明', '转处理', {
+          confirmButtonText: '确定',
+          cancelButtonText: '取消',
+          inputType: 'textarea',
+          inputPlaceholder: '请输入转处理说明',
+          inputValidator: (value) => {
+            if (!value || !value.trim()) return '请填写转处理说明'
+            return true
+          },
+        })
+          .then(async ({ value }) => {
+            this.submitLoading = true
+            try {
+              const res = await operationEventApi.updateStatus({
+                id: this.data.id,
+                operateType: '25',
+                handleContent: value,
+              })
+              if (res.code === 200) {
+                this.$message.success('事件已转回处理中')
+                this.isProcessMode = false
+                this.isEditing = false
+                this.$emit('refresh')
+                this.handleClose()
+              }
+            } catch (error) {
+              console.error('转处理失败:', error)
+            } finally {
+              this.submitLoading = false
+            }
+          })
+          .catch(() => {})
+      },
+      // 快速登记相关方法
+      onQuickEditorCreated(editor) {
+        this.quickEditor = editor
+      },
+      handleQuickImageUpload(file, insertFn) {
+        const reader = new FileReader()
+        reader.readAsDataURL(file)
+        reader.onload = () => {
+          insertFn(reader.result, file.name, reader.result)
+        }
+      },
+      openFileUpload() {
+        this.$message.info('文件上传功能开发中...')
+      },
+      cancelQuickRecord() {
+        this.showQuickInput = false
+        this.quickRecordContent = ''
+        if (this.quickEditor) {
+          this.quickEditor.clear()
+        }
+      },
+      async submitQuickRecord() {
+        if (!this.quickRecordContent.trim()) {
+          this.$message.warning('请输入记录内容')
+          return
+        }
+
+        this.submitLoading = true
+        try {
+          const res = await operationEventApi.addRecord({
+            eventId: this.data.id,
+            handleContent: this.quickRecordContent,
+            handleResult: '30',
+          })
+
+          if (res.code === 200) {
+            this.$message.success('记录提交成功')
+            this.fetchRecordList()
+            this.showQuickInput = false
+            this.quickRecordContent = ''
+            if (this.quickEditor) {
+              this.quickEditor.clear()
+            }
+            this.$emit('refresh')
+          }
+        } catch (error) {
+          console.error('提交记录失败:', error)
+        } finally {
+          this.submitLoading = false
+        }
+      },
+      getFirstChar(name) {
+        if (!name) return '?'
+        return name.charAt(0)
+      },
+    },
+  }
+</script>
+
+<style src="@wangeditor/editor/dist/css/style.css"></style>
+
+<style lang="scss" scoped>
+  .process-dialog {
+    ::v-deep .el-dialog {
+      margin-top: 5vh !important;
+    }
+
+    ::v-deep .el-dialog__body {
+      padding: 0;
+    }
+
+    ::v-deep .el-dialog__header {
+      padding: 20px 20px 10px;
+    }
+  }
+
+  .dialog-header {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    width: 100%;
+
+    .header-left {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+    }
+
+    .header-icon {
+      width: 24px;
+      height: 24px;
+      border-radius: 50%;
+      background: #409eff;
+      color: #fff;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      font-size: 12px;
+      font-weight: bold;
+    }
+
+    .header-title {
+      font-size: 16px;
+      font-weight: 500;
+      color: #303133;
+    }
+
+    .header-actions {
+      display: flex;
+      gap: 8px;
+    }
+  }
+
+  .event-main-title {
+    font-size: 18px;
+    font-weight: 600;
+    color: #303133;
+    padding: 8px 20px;
+  }
+
+  .info-cards {
+    display: flex;
+    gap: 16px;
+    padding: 16px 20px;
+    border-bottom: 1px solid #ebeef5;
+
+    .info-card {
+      flex: 1;
+      display: flex;
+      align-items: center;
+      gap: 10px;
+
+      &.info-card-large {
+        flex: 2;
+
+        .card-value {
+          max-width: none !important;
+          white-space: normal !important;
+          word-break: break-all !important;
+          overflow: visible !important;
+          text-overflow: clip !important;
+        }
+      }
+
+      .card-icon {
+        width: 40px;
+        height: 40px;
+        border-radius: 50%;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        color: #fff;
+        font-size: 18px;
+
+        &.blue {
+          background: #409eff;
+        }
+
+        &.orange {
+          background: #e6a23c;
+        }
+
+        &.red {
+          background: #f56c6c;
+        }
+
+        &.green {
+          background: #67c23a;
+        }
+      }
+
+      .card-content {
+        .card-value {
+          font-size: 14px;
+          font-weight: 500;
+          color: #303133;
+          max-width: 120px;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          white-space: nowrap;
+        }
+
+        .card-label {
+          font-size: 12px;
+          color: #909399;
+          margin-top: 2px;
+        }
+      }
+    }
+  }
+
+  .dialog-body {
+    display: flex;
+    height: calc(90vh - 200px);
+    max-height: calc(90vh - 200px);
+
+    .left-panel {
+      flex: 1;
+      padding: 16px 20px;
+      overflow-y: auto;
+      border-right: 1px solid #ebeef5;
+
+      .section-title {
+        font-size: 14px;
+        font-weight: 500;
+        color: #606266;
+        margin-bottom: 12px;
+      }
+
+      .event-desc-wrapper {
+        height: 240px;
+        background: #f5f7fa;
+        border-radius: 4px;
+        margin-bottom: 12px;
+        overflow-y: auto;
+        padding: 12px;
+
+        /* 自定义滚动条样式 */
+        &::-webkit-scrollbar {
+          width: 6px;
+        }
+
+        &::-webkit-scrollbar-track {
+          background: transparent;
+        }
+
+        &::-webkit-scrollbar-thumb {
+          background: #c0c4cc;
+          border-radius: 3px;
+        }
+
+        &::-webkit-scrollbar-thumb:hover {
+          background: #909399;
+        }
+      }
+
+      .event-desc {
+        font-size: 14px;
+        color: #606266;
+        line-height: 1.8;
+        word-wrap: break-word;
+        word-break: break-all;
+
+        ::v-deep img {
+          max-width: 100%;
+          height: auto;
+          border-radius: 4px;
+          margin: 12px 0;
+          display: block;
+          box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+        }
+
+        ::v-deep p {
+          margin: 8px 0;
+        }
+
+        ::v-deep ul,
+        ::v-deep ol {
+          margin: 8px 0;
+          padding-left: 24px;
+        }
+
+        ::v-deep li {
+          margin: 4px 0;
+        }
+      }
+
+      .property-section {
+        margin-top: 16px;
+
+        .section-title {
+          font-size: 14px;
+          font-weight: 500;
+          color: #606266;
+          margin-bottom: 12px;
+        }
+
+        .property-grid {
+          display: grid;
+          grid-template-columns: repeat(2, 1fr);
+          gap: 12px 20px;
+
+          .property-item {
+            display: flex;
+            align-items: center;
+
+            .property-label {
+              width: 70px;
+              font-size: 13px;
+              color: #909399;
+              flex-shrink: 0;
+            }
+
+            .property-value {
+              flex: 1;
+              font-size: 13px;
+              color: #303133;
+              overflow: hidden;
+              text-overflow: ellipsis;
+              white-space: nowrap;
+            }
+          }
+        }
+      }
+    }
+
+    .right-panel {
+      width: 300px;
+      display: flex;
+      flex-direction: column;
+      overflow: hidden;
+
+      .panel-header {
+        padding: 12px 16px;
+        border-bottom: 1px solid #ebeef5;
+        flex-shrink: 0;
+
+        .panel-title {
+          font-size: 14px;
+          font-weight: 500;
+          color: #303133;
+        }
+      }
+
+      ::v-deep .el-tabs {
+        flex: 1;
+        display: flex;
+        flex-direction: column;
+        overflow: hidden;
+        min-height: 0;
+
+        .el-tabs__header {
+          margin: 0;
+          padding: 0 16px;
+          flex-shrink: 0;
+        }
+
+        .el-tabs__content {
+          flex: 1;
+          overflow: hidden;
+          overflow: hidden;
+          min-height: 0;
+          padding: 12px 16px 4px;
+          display: flex;
+          flex-direction: column;
+          min-height: 0;
+        }
+
+        .el-tab-pane {
+          flex: 1;
+          display: flex;
+          flex-direction: column;
+          overflow: hidden;
+          min-height: 0;
+        }
+      }
+
+      .record-container {
+        display: flex;
+        flex-direction: column;
+        flex: 1;
+        overflow: hidden;
+        min-height: 0;
+
+        .record-list {
+          flex: 1;
+          overflow-y: auto;
+          min-height: 0;
+          position: relative;
+          padding-left: 40px;
+
+          &::before {
+            content: '';
+            position: absolute;
+            left: 15px;
+            top: 0;
+            bottom: 0;
+            width: 2px;
+            border-left: 2px dashed #dcdfe6;
+          }
+
+          .empty-record {
+            text-align: center;
+            color: #909399;
+            font-size: 13px;
+            padding: 20px 0;
+          }
+
+          .record-item {
+            position: relative;
+            padding: 12px 0;
+
+            &:first-child {
+              padding-top: 0;
+            }
+
+            .record-avatar {
+              position: absolute;
+              left: -33px;
+              top: 0;
+              width: 28px;
+              height: 28px;
+              border-radius: 50%;
+              background: #67c23a;
+              color: #fff;
+              display: flex;
+              align-items: center;
+              justify-content: center;
+              font-size: 12px;
+              font-weight: 500;
+              z-index: 1;
+            }
+
+            .record-user {
+              font-size: 14px;
+              font-weight: 500;
+              color: #303133;
+              margin-bottom: 8px;
+            }
+
+            .record-content-box {
+              background: #f5f7fa;
+              border-radius: 8px;
+              padding: 12px;
+              border: 1px solid #e4e7ed;
+
+              .record-content {
+                font-size: 13px;
+                color: #606266;
+                line-height: 1.6;
+
+                ::v-deep img {
+                  max-width: 100%;
+                  height: auto;
+                  border-radius: 4px;
+                  margin: 4px 0;
+                }
+              }
+
+              .file-attachment {
+                display: flex;
+                align-items: center;
+                gap: 8px;
+                margin-top: 8px;
+                padding: 8px;
+                background: #fff;
+                border-radius: 4px;
+
+                i {
+                  font-size: 20px;
+                  color: #909399;
+                }
+
+                .file-name {
+                  flex: 1;
+                  font-size: 13px;
+                  color: #606266;
+                  overflow: hidden;
+                  text-overflow: ellipsis;
+                  white-space: nowrap;
+                }
+              }
+            }
+
+            .record-time {
+              font-size: 12px;
+              color: #909399;
+              margin-top: 8px;
+            }
+          }
+        }
+      }
+
+      .empty-record {
+        text-align: center;
+        color: #909399;
+        font-size: 13px;
+        padding: 20px 0;
+      }
+
+      .record-item {
+        position: relative;
+        padding: 12px 0;
+
+        &:first-child {
+          padding-top: 0;
+        }
+
+        .record-avatar {
+          position: absolute;
+          left: -33px;
+          top: 0;
+          width: 28px;
+          height: 28px;
+          border-radius: 50%;
+          background: #67c23a;
+          color: #fff;
+          display: flex;
+          align-items: center;
+          justify-content: center;
+          font-size: 12px;
+          font-weight: 500;
+          z-index: 1;
+        }
+
+        .record-user {
+          font-size: 14px;
+          font-weight: 500;
+          color: #303133;
+          margin-bottom: 8px;
+        }
+
+        .record-content-box {
+          background: #f5f7fa;
+          border-radius: 8px;
+          padding: 12px;
+          border: 1px solid #e4e7ed;
+
+          .record-content {
+            font-size: 13px;
+            color: #606266;
+            line-height: 1.6;
+
+            ::v-deep img {
+              max-width: 100%;
+              height: auto;
+              border-radius: 4px;
+              margin: 4px 0;
+            }
+          }
+
+          .file-attachment {
+            display: flex;
+            align-items: center;
+            gap: 8px;
+            margin-top: 8px;
+            padding: 8px;
+            background: #fff;
+            border-radius: 4px;
+
+            i {
+              font-size: 20px;
+              color: #909399;
+            }
+
+            .file-name {
+              flex: 1;
+              font-size: 13px;
+              color: #606266;
+              overflow: hidden;
+              text-overflow: ellipsis;
+              white-space: nowrap;
+            }
+          }
+        }
+
+        .record-time {
+          font-size: 12px;
+          color: #909399;
+          margin-top: 8px;
+        }
+      }
+    }
+
+    // 快速登记样式
+    .quick-record-section {
+      margin-top: 8px;
+      padding-top: 8px;
+      border-top: 1px solid #ebeef5;
+      flex-shrink: 0;
+      background: #fff;
+      position: relative;
+      z-index: 1;
+
+      .quick-record-trigger {
+        display: flex;
+        align-items: center;
+        justify-content: space-between;
+        padding: 10px 12px;
+        background: #f5f7fa;
+        border-radius: 4px;
+        cursor: pointer;
+        transition: all 0.3s;
+
+        &:hover {
+          background: #e4e7ed;
+        }
+
+        .placeholder {
+          font-size: 13px;
+          color: #909399;
+        }
+
+        i {
+          font-size: 16px;
+          color: #606266;
+          cursor: pointer;
+          padding: 4px;
+
+          &:hover {
+            color: #409eff;
+          }
+        }
+      }
+
+      .quick-record-editor {
+        .quick-record-actions {
+          display: flex;
+          justify-content: flex-end;
+          gap: 8px;
+          margin-top: 8px;
+        }
+      }
+    }
+
+    .file-list {
+      .empty-file {
+        text-align: center;
+        color: #909399;
+        font-size: 13px;
+        padding: 20px 0;
+      }
+
+      .file-item {
+        display: flex;
+        align-items: center;
+        gap: 8px;
+        padding: 10px 0;
+        border-bottom: 1px solid #f0f0f0;
+
+        &:last-child {
+          border-bottom: none;
+        }
+
+        i {
+          font-size: 18px;
+          color: #909399;
+        }
+
+        .file-name {
+          flex: 1;
+          font-size: 13px;
+          color: #606266;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          white-space: nowrap;
+        }
+      }
+    }
+  }
+</style>

+ 361 - 0
src/views/devops/operation/components/OperationEdit.vue

@@ -0,0 +1,361 @@
+<template>
+  <el-dialog
+    :close-on-click-modal="false"
+    :title="title"
+    top="2vh"
+    :visible="visible"
+    width="900px"
+    @close="handleClose">
+    <el-form ref="form" class="dialog-form" label-position="right" label-width="100px" :model="form" :rules="rules">
+      <el-form-item label="事件标题" prop="eventTitle">
+        <el-input v-model="form.eventTitle" placeholder="请输入事件标题" />
+      </el-form-item>
+
+      <el-form-item label="事件描述" prop="eventDesc">
+        <div style="border: 1px solid #ccc; z-index: 100">
+          <Toolbar
+            :default-config="toolbarConfig"
+            :editor="editor"
+            :mode="mode"
+            style="border-bottom: 1px solid #ccc" />
+          <Editor
+            v-model="form.eventDesc"
+            :default-config="editorConfig"
+            :mode="mode"
+            style="height: 180px; overflow-y: hidden"
+            @onCreated="onEditorCreated" />
+        </div>
+      </el-form-item>
+
+      <el-row :gutter="20">
+        <el-col :span="12">
+          <el-form-item label="事件类型" prop="eventType">
+            <el-select v-model="form.eventType" placeholder="请选择事件类型" style="width: 100%">
+              <el-option label="操作咨询" value="10" />
+              <el-option label="数据处理" value="20" />
+              <el-option label="系统BUG" value="30" />
+              <el-option label="功能调整" value="40" />
+              <el-option label="二开需求" value="50" />
+              <el-option label="其他问题" value="90" />
+            </el-select>
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="优先级" prop="priorityLevel">
+            <el-select v-model="form.priorityLevel" placeholder="请选择优先级" style="width: 100%">
+              <el-option label="P1 紧急" value="P1" />
+              <el-option label="P2 一般" value="P2" />
+              <el-option label="P3 低优" value="P3" />
+            </el-select>
+          </el-form-item>
+        </el-col>
+      </el-row>
+
+      <el-form-item label="合同" prop="contractId">
+        <el-select
+          v-model="form.contractId"
+          clearable
+          filterable
+          placeholder="请输入合同编号、客户名称或签约单位搜索"
+          remote
+          :remote-method="searchContract"
+          reserve-keyword
+          style="width: 100%"
+          @change="handleContractChange">
+          <el-option
+            v-for="item in contractList"
+            :key="item.id"
+            :label="item.contractCode + ' - ' + item.contractName + ' - ' + item.custName"
+            :value="item.id" />
+        </el-select>
+      </el-form-item>
+
+      <el-row :gutter="20">
+        <el-col :span="12">
+          <el-form-item label="客户" prop="custId">
+            <el-input v-model="form.custName" disabled placeholder="选择合同后自动带出" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="产品线" prop="productLine">
+            <el-input v-model="productLineName" disabled placeholder="选择合同后自动带出" />
+          </el-form-item>
+        </el-col>
+      </el-row>
+
+      <el-row :gutter="20">
+        <el-col :span="12">
+          <el-form-item label="反馈人" prop="feedbackReporter">
+            <el-input v-model="form.feedbackReporter" placeholder="请输入反馈人" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="反馈来源" prop="feedbackSource">
+            <el-select v-model="form.feedbackSource" placeholder="请选择反馈来源" style="width: 100%">
+              <el-option label="客户" value="10" />
+              <el-option label="销售" value="20" />
+              <el-option label="交付" value="30" />
+            </el-select>
+          </el-form-item>
+        </el-col>
+      </el-row>
+    </el-form>
+
+    <div slot="footer">
+      <el-button @click="handleClose">取消</el-button>
+      <el-button :loading="submitLoading" type="primary" @click="handleSubmit">
+        {{ form.id ? '保存' : '新增' }}
+      </el-button>
+    </div>
+  </el-dialog>
+</template>
+
+<script>
+  import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
+  import operationEventApi from '@/api/operation/operationEvent'
+  import contractApi from '@/api/contract/index'
+  import store from '@/store'
+
+  export default {
+    name: 'OperationEdit',
+    components: { Editor, Toolbar },
+    props: {
+      visible: {
+        type: Boolean,
+        default: false,
+      },
+      data: {
+        type: Object,
+        default: null,
+      },
+    },
+    data() {
+      return {
+        editor: null,
+        mode: 'default',
+        toolbarConfig: {
+          toolbarKeys: [
+            'bold',
+            'italic',
+            'underline',
+            'through',
+            '|',
+            'color',
+            'bgColor',
+            '|',
+            'bulletedList',
+            'numberedList',
+            '|',
+            'uploadImage',
+            '|',
+            'undo',
+            'redo',
+          ],
+        },
+        editorConfig: {
+          placeholder: '请输入事件描述...',
+          MENU_CONF: {
+            uploadImage: {
+              customUpload: this.handleImageUpload,
+            },
+          },
+        },
+        form: {
+          id: null,
+          eventTitle: '',
+          eventDesc: '',
+          eventType: '',
+          priorityLevel: 'P2',
+          feedbackSource: '',
+          feedbackReporter: '',
+          contractId: null,
+          contractName: '',
+          custId: null,
+          custName: '',
+          productLine: '',
+          isBig: '20',
+          isOps: '10',
+        },
+        rules: {
+          eventTitle: [{ required: true, message: '请输入事件标题', trigger: 'blur' }],
+          eventDesc: [{ required: true, message: '请输入事件描述', trigger: 'blur' }],
+          eventType: [{ required: true, message: '请选择事件类型', trigger: 'change' }],
+          priorityLevel: [{ required: true, message: '请选择优先级', trigger: 'change' }],
+          contractId: [{ required: true, message: '请选择合同', trigger: 'change' }],
+          feedbackSource: [{ required: true, message: '请选择反馈来源', trigger: 'change' }],
+        },
+        submitLoading: false,
+        contractList: [],
+      }
+    },
+    computed: {
+      title() {
+        return this.form.id ? '编辑运维事件' : '新建运维事件'
+      },
+      productLineName() {
+        const map = {
+          10: 'Biobank',
+          20: 'LIMS',
+          30: 'CellBank',
+        }
+        return map[this.form.productLine] || ''
+      },
+    },
+    watch: {
+      visible(val) {
+        if (val && this.data) {
+          this.initForm()
+        } else if (val) {
+          this.resetForm()
+        }
+      },
+    },
+    beforeDestroy() {
+      if (this.editor) {
+        this.editor.destroy()
+        this.editor = null
+      }
+    },
+    methods: {
+      onEditorCreated(editor) {
+        this.editor = editor
+      },
+      handleImageUpload(file, insertFn) {
+        const reader = new FileReader()
+        reader.readAsDataURL(file)
+        reader.onload = () => {
+          insertFn(reader.result, file.name, reader.result)
+        }
+      },
+      initForm() {
+        this.form = { ...this.data }
+        if (this.data.contractId) {
+          contractApi.getDetails({ id: this.data.contractId }).then((res) => {
+            if (res.code === 200 && res.data) {
+              const c = res.data
+              const maintenanceEndTime = c.softwareMaintenanceEndTime || ''
+              this.contractList = [
+                {
+                  id: c.id,
+                  contractCode: c.contractCode || '',
+                  contractName: c.contractName || '',
+                  custId: c.custId,
+                  custName: c.custName || '',
+                  signatoryUnit: c.signatoryUnit || '',
+                  productLine: c.productLine || '',
+                  isBig: c.isBig || '20',
+                  softwareMaintenanceEndTime: maintenanceEndTime,
+                },
+              ]
+            }
+          })
+        }
+      },
+      resetForm() {
+        const userId = store.getters['user/id']
+        const userName = store.getters['user/username']
+        const nickName = store.getters['user/nickName']
+        this.form = {
+          id: null,
+          eventTitle: '',
+          eventDesc: '',
+          eventType: '',
+          priorityLevel: 'P2',
+          feedbackSource: '',
+          feedbackReporter: nickName || userName || '',
+          feedbackUserId: userId || null,
+          contractId: null,
+          contractName: '',
+          custId: null,
+          custName: '',
+          productLine: '',
+          isBig: '20',
+          isOps: '10',
+        }
+        this.contractList = []
+        if (this.editor) {
+          this.editor.clear()
+        }
+      },
+      handleClose() {
+        this.$refs.form.resetFields()
+        this.resetForm()
+        this.$emit('update:visible', false)
+      },
+      handleContractChange(val) {
+        const contract = this.contractList.find((item) => item.id === val)
+        if (contract) {
+          this.form.contractName = contract.contractCode + ' - ' + contract.contractName
+          this.form.custId = contract.custId
+          this.form.custName = contract.custName
+          this.form.productLine = contract.productLine
+          this.form.isBig = contract.isBig
+          if (contract.softwareMaintenanceEndTime) {
+            const endTime = new Date(contract.softwareMaintenanceEndTime)
+            this.form.isOps = endTime >= new Date() ? '10' : '20'
+          } else {
+            this.form.isOps = '20'
+          }
+        } else {
+          this.form.custId = null
+          this.form.custName = ''
+          this.form.productLine = ''
+          this.form.isBig = '20'
+          this.form.isOps = '20'
+        }
+      },
+      searchContract(query) {
+        if (query !== '') {
+          contractApi
+            .searchContract({ searchText: query })
+            .then((res) => {
+              if (res.code === 200 && res.data && res.data.list) {
+                this.contractList = res.data.list
+              }
+            })
+            .catch(() => {
+              this.contractList = []
+            })
+        } else {
+          this.contractList = []
+        }
+      },
+      async handleSubmit() {
+        this.$refs.form.validate(async (valid) => {
+          if (!valid) return
+
+          this.submitLoading = true
+          try {
+            const userId = store.getters['user/id']
+            const userName = store.getters['user/username']
+            const nickName = store.getters['user/nickName']
+            const submitData = {
+              ...this.form,
+              feedbackUserId: userId || null,
+              feedbackUserName: nickName || userName || '',
+            }
+            const api = this.form.id ? operationEventApi.doEdit : operationEventApi.doAdd
+            const res = await api(submitData)
+            if (res.code === 200) {
+              this.$message.success(this.form.id ? '编辑成功' : '创建成功')
+              this.$emit('refresh')
+              this.handleClose()
+            }
+          } catch (error) {
+            console.error('提交失败:', error)
+          } finally {
+            this.submitLoading = false
+          }
+        })
+      },
+    },
+  }
+</script>
+
+<style src="@wangeditor/editor/dist/css/style.css"></style>
+
+<style lang="scss" scoped>
+  .dialog-form {
+    padding-right: 10px;
+  }
+</style>

+ 498 - 0
src/views/devops/operation/index.vue

@@ -0,0 +1,498 @@
+<template>
+  <div class="operation-container">
+    <div class="toolbar">
+      <el-button icon="el-icon-plus" type="primary" @click="handleAdd">新增</el-button>
+      <div class="toolbar-right">
+        <span class="sort-label">排序</span>
+        <el-select v-model="queryForm.sortBy" class="sort-select" size="small">
+          <el-option label="反馈时间" value="feedbackDate" />
+          <el-option label="客户名称" value="custName" />
+        </el-select>
+        <el-select v-model="queryForm.searchType" class="search-type-select" size="small">
+          <el-option label="标题" value="title" />
+          <el-option label="客户" value="custName" />
+          <el-option label="反馈人" value="feedbackReporter" />
+          <el-option label="编号" value="eventNo" />
+        </el-select>
+        <el-input
+          v-model="queryForm.keyWords"
+          class="search-input"
+          clearable
+          placeholder=""
+          prefix-icon="el-icon-search"
+          size="small"
+          @keyup.enter.native="handleSearch" />
+        <el-button icon="el-icon-search" size="small" type="primary" @click="handleSearch">查询</el-button>
+        <el-button icon="el-icon-refresh-right" size="small" style="margin-left: 8px" @click="handleReset">
+          重置
+        </el-button>
+        <el-button icon="el-icon-download" size="small" style="margin-left: 8px" type="success" @click="handleExport">
+          导出
+        </el-button>
+      </div>
+    </div>
+
+    <div class="kanban-board">
+      <div
+        v-for="column in kanbanColumns"
+        :key="column.status"
+        class="kanban-column"
+        :class="{ 'drag-over': column.isDragOver }"
+        @dragleave="handleDragLeave(column)"
+        @dragover.prevent="handleDragOver(column)"
+        @drop="handleDrop(column, $event)">
+        <div class="column-header">
+          <span class="column-title">{{ column.name }}</span>
+          <el-tag size="mini" type="info">{{ column.count }}</el-tag>
+        </div>
+        <div class="column-content">
+          <div
+            v-for="item in column.list"
+            :key="item.id"
+            class="kanban-card"
+            draggable="true"
+            @click="handleEdit(item)"
+            @dragstart="handleDragStart(item, $event)">
+            <div class="card-header">
+              <span class="card-title">{{ item.eventTitle }}</span>
+              <el-tag size="mini" :type="getPriorityType(item.priorityLevel)">
+                {{ item.priorityLevel }}
+              </el-tag>
+            </div>
+            <div class="card-body">
+              <div class="card-info">
+                <span class="info-item">
+                  <i class="el-icon-office-building" />
+                  {{ item.custName }}
+                </span>
+                <span class="info-item info-item-row">
+                  <span>
+                    <i class="el-icon-user" />
+                    {{ item.feedbackReporter || '-' }}
+                  </span>
+                  <span>
+                    <i class="el-icon-time" />
+                    {{ formatTime(item.feedbackDate) }}
+                  </span>
+                </span>
+              </div>
+              <div class="card-tags">
+                <el-tag v-if="item.isBig === '10'" size="mini" type="danger">重点项目</el-tag>
+                <el-tag v-if="item.isOps === '10'" size="mini" type="success">运维期</el-tag>
+                <el-tag size="mini" type="warning">{{ getEventTypeLabel(item.eventType) }}</el-tag>
+              </div>
+            </div>
+            <div class="card-footer">
+              <span class="event-no">{{ item.eventNo }}</span>
+              <div class="card-actions">
+                <el-button v-if="item.eventStatus === '10'" size="mini" type="text" @click.stop="handleAssign(item)">
+                  接收
+                </el-button>
+                <template v-if="item.eventStatus === '20' || item.eventStatus === '30'">
+                  <el-button size="mini" type="text" @click.stop="handleAction(item, 'close')">关闭</el-button>
+                  <el-button size="mini" type="text" @click.stop="handleAction(item, 'suspend')">挂起</el-button>
+                  <el-button size="mini" type="text" @click.stop="handleAction(item, 'transfer')">转研发</el-button>
+                </template>
+                <template v-if="item.eventStatus === '40'">
+                  <el-button size="mini" type="text" @click.stop="handleAction(item, 'close')">关闭</el-button>
+                  <el-button size="mini" type="text" @click.stop="handleAction(item, 'suspend')">挂起</el-button>
+                </template>
+                <template v-if="item.eventStatus === '70'">
+                  <el-button size="mini" type="text" @click.stop="handleAction(item, 'resume')">转处理</el-button>
+                </template>
+              </div>
+            </div>
+          </div>
+          <div v-if="column.list.length === 0" class="empty-column">暂无工作项</div>
+        </div>
+      </div>
+    </div>
+
+    <OperationEdit ref="editRef" :data="currentRow" :visible.sync="editVisible" @refresh="fetchData" />
+    <OperationDetail
+      ref="detailRef"
+      :action="detailAction"
+      :data="currentRow || {}"
+      :mode="detailMode"
+      :visible.sync="detailVisible"
+      @refresh="fetchData" />
+  </div>
+</template>
+
+<script>
+  import OperationEdit from './components/OperationEdit'
+  import OperationDetail from './components/OperationDetail'
+  import operationEventApi from '@/api/operation/operationEvent'
+  import { parseTime } from '@/utils'
+  import to from 'await-to-js'
+
+  export default {
+    name: 'Operation',
+    components: { OperationEdit, OperationDetail },
+    data() {
+      return {
+        queryForm: {
+          keyWords: '',
+          searchType: 'title',
+          sortBy: 'feedbackDate',
+          pageNum: 1,
+          pageSize: 1000,
+        },
+        kanbanColumns: [
+          { status: '10', name: '待处理', count: 0, list: [], isDragOver: false },
+          { status: '20', name: '处理中(重点)', count: 0, list: [], isDragOver: false },
+          { status: '30', name: '处理中(普通)', count: 0, list: [], isDragOver: false },
+          { status: '40', name: '转研发', count: 0, list: [], isDragOver: false },
+          { status: '70', name: '挂起', count: 0, list: [], isDragOver: false },
+        ],
+        editVisible: false,
+        detailVisible: false,
+        detailMode: 'view',
+        detailAction: '',
+        currentRow: null,
+        draggingItem: null,
+      }
+    },
+    created() {
+      this.fetchData()
+    },
+    methods: {
+      async fetchData() {
+        try {
+          const res = await operationEventApi.getKanbanData(this.queryForm)
+          if (res.code === 200 && res.data) {
+            this.updateKanbanData(res.data)
+          }
+        } catch (error) {
+          console.error('获取看板数据失败:', error)
+        }
+      },
+      updateKanbanData(data) {
+        this.kanbanColumns.forEach((column) => {
+          const columnData = data[column.status] || { list: [], count: 0 }
+          column.list = columnData.list || []
+          column.count = columnData.count || 0
+        })
+      },
+      handleSearch() {
+        this.queryForm.pageNum = 1
+        this.fetchData()
+      },
+      handleReset() {
+        this.queryForm = {
+          keyWords: '',
+          searchType: 'title',
+          sortBy: 'feedbackDate',
+          pageNum: 1,
+          pageSize: 1000,
+        }
+        this.fetchData()
+      },
+      handleAdd() {
+        this.currentRow = null
+        this.editVisible = true
+      },
+      handleEdit(row) {
+        this.currentRow = row
+        this.detailMode = 'view'
+        this.detailVisible = true
+      },
+      handleAssign(row) {
+        this.currentRow = row
+        this.detailMode = 'receive'
+        this.detailVisible = true
+      },
+      handleAction(row, action) {
+        this.currentRow = row
+        if (action === 'close') {
+          this.detailMode = 'process'
+          this.detailAction = 'close'
+        } else if (action === 'suspend') {
+          this.detailMode = 'process'
+          this.detailAction = 'suspend'
+        } else if (action === 'transfer') {
+          this.detailMode = 'process'
+          this.detailAction = 'transfer'
+        } else if (action === 'resume') {
+          this.detailMode = 'process'
+          this.detailAction = 'resume'
+        }
+        this.detailVisible = true
+      },
+      handleProcess(row) {
+        this.currentRow = row
+        this.detailMode = 'process'
+        this.detailAction = 'process'
+        this.detailVisible = true
+      },
+      handleDragStart(item, event) {
+        this.draggingItem = item
+        event.dataTransfer.effectAllowed = 'move'
+        event.dataTransfer.setData('text/plain', item.id)
+      },
+      handleDragOver(column) {
+        column.isDragOver = true
+      },
+      handleDragLeave(column) {
+        column.isDragOver = false
+      },
+      async handleDrop(column, event) {
+        event.preventDefault()
+        column.isDragOver = false
+
+        if (!this.draggingItem || this.draggingItem.eventStatus === column.status) {
+          return
+        }
+
+        try {
+          const res = await operationEventApi.updateStatus({
+            id: this.draggingItem.id,
+            eventStatus: column.status,
+          })
+          if (res.code === 200) {
+            this.$message.success('状态更新成功')
+            this.fetchData()
+          }
+        } catch (error) {
+          console.error('更新状态失败:', error)
+        }
+        this.draggingItem = null
+      },
+      getPriorityType(priority) {
+        const map = {
+          P1: 'danger',
+          P2: 'warning',
+          P3: 'success',
+        }
+        return map[priority] || 'info'
+      },
+      getEventTypeLabel(type) {
+        const map = {
+          10: '操作咨询',
+          20: '数据处理',
+          30: '系统BUG',
+          40: '功能调整',
+          50: '二开需求',
+          90: '其他问题',
+        }
+        return map[type] || type
+      },
+      formatTime(time) {
+        return time ? parseTime(time, '{y}-{m}-{d}') : '-'
+      },
+      async handleExport() {
+        const params = {}
+        const [err, res] = await to(operationEventApi.exportNonClosed(params))
+        if (err) {
+          return
+        }
+        if (res.data.content) {
+          try {
+            const binaryString = window.atob(res.data.content)
+            const len = binaryString.length
+            const bytes = new Uint8Array(len)
+            for (let i = 0; i < len; i++) {
+              bytes[i] = binaryString.charCodeAt(i)
+            }
+            const blob = new Blob([bytes], {
+              type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+            })
+            const link = document.createElement('a')
+            link.href = window.URL.createObjectURL(blob)
+            link.download = `运维事件_${new Date().getTime()}.xlsx`
+            document.body.appendChild(link)
+            link.click()
+            document.body.removeChild(link)
+          } catch (e) {
+            console.error('下载失败', e)
+          }
+        }
+      },
+    },
+  }
+</script>
+
+<style lang="scss" scoped>
+  .operation-container {
+    padding: 20px;
+    display: flex;
+    flex-direction: column;
+    height: calc(100vh - 84px);
+  }
+
+  .toolbar {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    margin-bottom: 10px;
+    padding-bottom: 10px;
+    border-bottom: 1px solid #ebeef5;
+  }
+
+  .toolbar-right {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+  }
+
+  .sort-label {
+    font-size: 14px;
+    color: #606266;
+    margin-left: 10px;
+  }
+
+  .sort-select {
+    width: 120px;
+  }
+
+  .search-type-select {
+    width: 100px;
+  }
+
+  .search-input {
+    width: 200px;
+  }
+
+  .kanban-board {
+    display: flex;
+    gap: 12px;
+    overflow-x: hidden;
+    padding: 0;
+    flex: 1;
+    height: calc(100vh - 180px);
+  }
+
+  .kanban-column {
+    flex: 1;
+    min-width: 0;
+    background: #f5f7fa;
+    border-radius: 8px;
+    display: flex;
+    flex-direction: column;
+    height: 100%;
+
+    &.drag-over {
+      background: #e6f7ff;
+      border: 2px dashed #1890ff;
+    }
+  }
+
+  .column-header {
+    padding: 12px 16px;
+    border-bottom: 1px solid #ebeef5;
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    background: #fff;
+    border-radius: 8px 8px 0 0;
+  }
+
+  .column-title {
+    font-weight: 600;
+    font-size: 14px;
+    color: #303133;
+  }
+
+  .column-content {
+    flex: 1;
+    overflow-y: auto;
+    padding: 12px;
+    min-height: 0;
+  }
+
+  .kanban-card {
+    background: #fff;
+    border-radius: 6px;
+    padding: 12px;
+    margin-bottom: 12px;
+    cursor: pointer;
+    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+    transition: all 0.3s;
+
+    &:hover {
+      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+      transform: translateY(-2px);
+    }
+  }
+
+  .card-header {
+    display: flex;
+    justify-content: space-between;
+    align-items: flex-start;
+    margin-bottom: 8px;
+  }
+
+  .card-title {
+    font-size: 14px;
+    font-weight: 500;
+    color: #303133;
+    line-height: 1.4;
+    flex: 1;
+    margin-right: 8px;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+  }
+
+  .card-body {
+    margin-bottom: 8px;
+  }
+
+  .card-info {
+    display: flex;
+    flex-direction: column;
+    gap: 4px;
+    margin-bottom: 8px;
+    font-size: 12px;
+    color: #606266;
+
+    .info-item {
+      display: flex;
+      align-items: center;
+      gap: 4px;
+    }
+
+    .info-item-row {
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+
+      span {
+        display: flex;
+        align-items: center;
+        gap: 4px;
+      }
+    }
+  }
+
+  .card-tags {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 4px;
+  }
+
+  .card-footer {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    padding-top: 8px;
+    border-top: 1px solid #ebeef5;
+  }
+
+  .card-actions {
+    display: flex;
+    gap: 4px;
+  }
+
+  .event-no {
+    font-size: 12px;
+    color: #909399;
+  }
+
+  .empty-column {
+    text-align: center;
+    padding: 40px 20px;
+    color: #909399;
+    font-size: 14px;
+  }
+</style>

+ 297 - 0
src/views/devops/operationHistory/index.vue

@@ -0,0 +1,297 @@
+<template>
+  <div class="operation-history-container">
+    <vab-query-form>
+      <vab-query-form-top-panel>
+        <el-form ref="queryForm" :inline="true" :model="queryForm" @submit.native.prevent>
+          <el-form-item prop="scopeType">
+            <el-radio-group v-model="queryForm.scopeType" size="small" @change="queryData">
+              <el-radio-button label="my">个人</el-radio-button>
+              <el-radio-button label="all">全部</el-radio-button>
+            </el-radio-group>
+          </el-form-item>
+          <el-form-item>
+            <el-checkbox v-model="queryForm.includeClosed" size="small" @change="queryData">未关闭</el-checkbox>
+          </el-form-item>
+          <el-form-item prop="eventTitle">
+            <el-input
+              v-model="queryForm.eventTitle"
+              clearable
+              placeholder="事件标题"
+              size="small"
+              @keyup.enter.native="queryData" />
+          </el-form-item>
+          <el-form-item prop="custName">
+            <el-input
+              v-model="queryForm.custName"
+              clearable
+              placeholder="客户名称"
+              size="small"
+              @keyup.enter.native="queryData" />
+          </el-form-item>
+          <el-form-item prop="feedbackReporter">
+            <el-input
+              v-model="queryForm.feedbackReporter"
+              clearable
+              placeholder="反馈人"
+              size="small"
+              @keyup.enter.native="queryData" />
+          </el-form-item>
+          <el-form-item prop="opsUserName">
+            <el-input
+              v-model="queryForm.opsUserName"
+              clearable
+              placeholder="处理人"
+              size="small"
+              @keyup.enter.native="queryData" />
+          </el-form-item>
+          <el-form-item prop="dateRange">
+            <el-date-picker
+              v-model="queryForm.dateRange"
+              end-placeholder="结束日期"
+              range-separator="至"
+              size="small"
+              start-placeholder="反馈开始日期"
+              type="daterange"
+              value-format="yyyy-MM-dd" />
+          </el-form-item>
+          <el-form-item>
+            <el-button icon="el-icon-search" size="small" type="primary" @click="queryData">查询</el-button>
+            <el-button icon="el-icon-refresh-right" size="small" @click="reset">重置</el-button>
+          </el-form-item>
+        </el-form>
+      </vab-query-form-top-panel>
+    </vab-query-form>
+
+    <div class="export-btn-fixed">
+      <el-button icon="el-icon-download" size="small" type="success" @click="handleExport">导出</el-button>
+    </div>
+
+    <el-table ref="table" v-loading="listLoading" border :data="list" :height="$baseTableHeight(1)">
+      <el-table-column align="center" label="序号" show-overflow-tooltip width="60">
+        <template #default="{ $index }">
+          {{ (queryForm.pageNum - 1) * queryForm.pageSize + $index + 1 }}
+        </template>
+      </el-table-column>
+      <el-table-column align="center" label="事件编号" min-width="130" prop="eventNo" show-overflow-tooltip />
+      <el-table-column align="center" label="事件标题" min-width="150" prop="eventTitle" show-overflow-tooltip />
+      <el-table-column align="center" label="客户名称" min-width="120" prop="custName" show-overflow-tooltip />
+      <el-table-column align="center" label="反馈人" min-width="100" prop="feedbackReporter" show-overflow-tooltip />
+      <el-table-column align="center" label="反馈时间" min-width="110" prop="feedbackDate" show-overflow-tooltip>
+        <template #default="{ row }">
+          {{ parseTime(row.feedbackDate, '{y}-{m}-{d}') }}
+        </template>
+      </el-table-column>
+      <el-table-column align="center" label="处理人" min-width="100" prop="opsUserName" show-overflow-tooltip />
+      <el-table-column align="center" label="事件类型" min-width="90" prop="eventType" show-overflow-tooltip>
+        <template #default="{ row }">
+          {{ getEventTypeLabel(row.eventType) }}
+        </template>
+      </el-table-column>
+      <el-table-column align="center" label="优先级" min-width="80" prop="priorityLevel" show-overflow-tooltip />
+      <el-table-column align="center" label="事件状态" min-width="100" prop="eventStatus" show-overflow-tooltip>
+        <template #default="{ row }">
+          {{ getStatusLabel(row.eventStatus) }}
+        </template>
+      </el-table-column>
+      <el-table-column align="center" label="关闭时间" min-width="110" prop="completeTime" show-overflow-tooltip>
+        <template #default="{ row }">
+          {{ parseTime(row.completeTime, '{y}-{m}-{d}') }}
+        </template>
+      </el-table-column>
+      <el-table-column align="center" fixed="right" label="操作" width="80">
+        <template #default="{ row }">
+          <el-button type="text" @click="handleView(row)">详情</el-button>
+        </template>
+      </el-table-column>
+      <template #empty>
+        <el-image class="vab-data-empty" :src="require('@/assets/empty_images/data_empty.png')" />
+      </template>
+    </el-table>
+
+    <el-pagination
+      background
+      :current-page="queryForm.pageNum"
+      :layout="layout"
+      :page-size="queryForm.pageSize"
+      :total="total"
+      @current-change="handleCurrentChange"
+      @size-change="handleSizeChange" />
+
+    <operation-detail
+      v-if="detailVisible"
+      :data="currentRow"
+      :mode="'view'"
+      :read-only="true"
+      :visible.sync="detailVisible" />
+  </div>
+</template>
+
+<script>
+  import to from 'await-to-js'
+  import operationEventApi from '@/api/operation/operationEvent'
+  import OperationDetail from '@/views/devops/operation/components/OperationDetail'
+
+  export default {
+    name: 'OperationHistory',
+    components: { OperationDetail },
+    data() {
+      return {
+        layout: 'total, sizes, prev, pager, next, jumper',
+        listLoading: false,
+        list: [],
+        total: 0,
+        detailVisible: false,
+        currentRow: null,
+        queryForm: {
+          scopeType: 'my',
+          includeClosed: false,
+          eventTitle: '',
+          custName: '',
+          feedbackReporter: '',
+          opsUserName: '',
+          dateRange: [],
+          pageNum: 1,
+          pageSize: 10,
+        },
+      }
+    },
+    activated() {
+      this.fetchData()
+    },
+    mounted() {
+      this.fetchData()
+    },
+    methods: {
+      async fetchData() {
+        this.listLoading = true
+        const params = {
+          scopeType: this.queryForm.scopeType,
+          includeClosed: this.queryForm.includeClosed,
+          eventTitle: this.queryForm.eventTitle,
+          custName: this.queryForm.custName,
+          feedbackReporter: this.queryForm.feedbackReporter,
+          opsUserName: this.queryForm.opsUserName,
+          beginTime:
+            this.queryForm.dateRange && this.queryForm.dateRange.length === 2 ? this.queryForm.dateRange[0] : '',
+          endTime: this.queryForm.dateRange && this.queryForm.dateRange.length === 2 ? this.queryForm.dateRange[1] : '',
+          pageNum: this.queryForm.pageNum,
+          pageSize: this.queryForm.pageSize,
+        }
+        const [err, res] = await to(operationEventApi.getHistoryList(params))
+        if (err) {
+          this.listLoading = false
+          return
+        }
+        this.list = res.data.list || []
+        this.total = res.data.total || 0
+        this.listLoading = false
+        this.$nextTick(() => {
+          if (this.$refs.table) this.$refs.table.doLayout()
+        })
+      },
+      queryData() {
+        this.queryForm.pageNum = 1
+        this.fetchData()
+      },
+      reset() {
+        this.queryForm = {
+          scopeType: 'my',
+          includeClosed: false,
+          eventTitle: '',
+          custName: '',
+          feedbackReporter: '',
+          opsUserName: '',
+          dateRange: [],
+          pageNum: 1,
+          pageSize: 10,
+        }
+        this.fetchData()
+      },
+      handleSizeChange(val) {
+        this.queryForm.pageSize = val
+        this.fetchData()
+      },
+      handleCurrentChange(val) {
+        this.queryForm.pageNum = val
+        this.fetchData()
+      },
+      handleView(row) {
+        this.currentRow = row
+        this.detailVisible = true
+      },
+      getStatusLabel(status) {
+        const map = {
+          10: '待处理',
+          20: '处理中(重点)',
+          30: '处理中(普通)',
+          40: '转研发',
+          70: '挂起',
+          80: '已关闭',
+        }
+        return map[status] || status
+      },
+      getEventTypeLabel(type) {
+        const map = {
+          10: '操作咨询',
+          20: '数据处理',
+          30: '系统BUG',
+          40: '功能调整',
+          50: '二开需求',
+          90: '其他问题',
+        }
+        return map[type] || type
+      },
+      async handleExport() {
+        const params = {
+          scopeType: this.queryForm.scopeType,
+          includeClosed: this.queryForm.includeClosed,
+          eventTitle: this.queryForm.eventTitle,
+          custName: this.queryForm.custName,
+          feedbackReporter: this.queryForm.feedbackReporter,
+          opsUserName: this.queryForm.opsUserName,
+          beginTime:
+            this.queryForm.dateRange && this.queryForm.dateRange.length === 2 ? this.queryForm.dateRange[0] : '',
+          endTime: this.queryForm.dateRange && this.queryForm.dateRange.length === 2 ? this.queryForm.dateRange[1] : '',
+        }
+        const [err, res] = await to(operationEventApi.export(params))
+        if (err) {
+          return
+        }
+        if (res.data.content) {
+          try {
+            const binaryString = window.atob(res.data.content)
+            const len = binaryString.length
+            const bytes = new Uint8Array(len)
+            for (let i = 0; i < len; i++) {
+              bytes[i] = binaryString.charCodeAt(i)
+            }
+            const blob = new Blob([bytes], {
+              type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+            })
+            const link = document.createElement('a')
+            link.href = window.URL.createObjectURL(blob)
+            link.download = `运维历史_${new Date().getTime()}.xlsx`
+            document.body.appendChild(link)
+            link.click()
+            document.body.removeChild(link)
+          } catch (e) {
+            console.error('下载失败', e)
+          }
+        }
+      },
+    },
+  }
+</script>
+
+<style scoped>
+  .operation-history-container {
+    padding: 10px;
+    position: relative;
+  }
+  .export-btn-fixed {
+    position: absolute;
+    top: 12px;
+    right: 10px;
+    z-index: 100;
+  }
+</style>

+ 9 - 9
src/views/work/train/head/components/CreateTrainHead.vue

@@ -15,7 +15,7 @@
         </el-col>
         <el-col :span="24">
           <el-form-item label="培训日期" prop="trainDate">
-            <el-date-picker v-model="form.trainDate" type="date" placeholder="选择日期" style="width: 100%" />
+            <el-date-picker v-model="form.trainDate" placeholder="选择日期" style="width: 100%" type="date" />
           </el-form-item>
         </el-col>
         <el-col :span="24">
@@ -48,37 +48,37 @@
     </el-form>
     <el-button icon="el-icon-plus" type="primary" @click="createFeedBack">添加销售工程师</el-button>
 
-    <el-table :data="form.saleList" border style="width: 100%; margin-top: 10px">
+    <el-table border :data="form.saleList" style="width: 100%; margin-top: 10px">
       <el-table-column align="center" label="序号" width="50">
         <template #default="{ $index }">
           {{ $index + 1 }}
         </template>
       </el-table-column>
-      <el-table-column prop="saleName" label="销售工程师" min-width="140" />
+      <el-table-column label="销售工程师" min-width="140" prop="saleName" />
 
       <el-table-column fixed="right" label="操作" width="100">
         <template slot-scope="scope">
           <!-- <el-button type="text" @click="scope.row" size="small">编辑</el-button> -->
-          <el-button type="text" @click="deleteFeedBack(scope.$index)" size="small">删除</el-button>
+          <el-button size="small" type="text" @click="deleteFeedBack(scope.$index)">删除</el-button>
         </template>
       </el-table-column>
     </el-table>
 
-    <el-button icon="el-icon-plus" type="primary" @click="AddDis" style="margin-top: 10px">添加渠道</el-button>
+    <el-button icon="el-icon-plus" style="margin-top: 10px" type="primary" @click="AddDis">添加渠道</el-button>
 
-    <el-table :data="form.distributorList" border style="width: 100%; margin-top: 10px">
+    <el-table border :data="form.distributorList" style="width: 100%; margin-top: 10px">
       <el-table-column align="center" label="序号" width="50">
         <template #default="{ $index }">
           {{ $index + 1 }}
         </template>
       </el-table-column>
-      <el-table-column prop="distributorName" label="渠道名称" min-width="140" />
-      <el-table-column prop="saleName" label="销售工程师" min-width="140" />
+      <el-table-column label="渠道名称" min-width="140" prop="distributorName" />
+      <el-table-column label="销售工程师" min-width="140" prop="saleName" />
 
       <el-table-column fixed="right" label="操作" width="100">
         <template slot-scope="scope">
           <!-- <el-button type="text" @click="scope.row" size="small">编辑</el-button> -->
-          <el-button type="text" @click="deleteDis(scope.$index)" size="small">删除</el-button>
+          <el-button size="small" type="text" @click="deleteDis(scope.$index)">删除</el-button>
         </template>
       </el-table-column>
     </el-table>

+ 5 - 5
src/views/work/train/head/components/Detail.vue

@@ -1,6 +1,6 @@
 <template>
   <el-dialog append-to-body :title="title" :visible.sync="dialogFormVisible" width="1000px" @close="close">
-    <el-descriptions class="margin-top mb10" :column="2" border>
+    <el-descriptions border class="margin-top mb10" :column="2">
       <el-descriptions-item>
         <template slot="label">培训主题</template>
         {{ detail.trainTitle }}
@@ -18,14 +18,14 @@
 
     <vxe-table
       ref="feedBackRef"
-      class="mt10"
       border
-      resizable
-      show-overflow
+      class="mt10"
       :data="detail.distributorList"
       :edit-config="{ trigger: 'click', mode: 'cell' }"
+      resizable
+      show-overflow
       style="height: 300px; margin-top: 10px; width: 960px">
-      <vxe-column type="seq" width="50" title="序号" />
+      <vxe-column title="序号" type="seq" width="50" />
       <vxe-column field="saleName" title="销售工程师" width="120" />
       <vxe-column field="distributorName" title="渠道名称" width="180" />
 

+ 10 - 10
src/views/work/train/head/components/FeedBackDetail.vue

@@ -1,13 +1,13 @@
 <template>
   <el-dialog append-to-body :title="title" :visible.sync="dialogFormVisible" width="1000px" @close="close">
-    <el-descriptions class="margin-top mb10" :column="2" border>
+    <el-descriptions border class="margin-top mb10" :column="2">
       <el-descriptions-item style="width: 50%">
         <template slot="label">培训主题</template>
         {{ detail.trainTitle }}
       </el-descriptions-item>
       <el-descriptions-item style="width: 50%">
         <template slot="label">培训日期</template>
-         {{ parseTime(detail.trainDate, '{y}-{m}-{d}') }}
+        {{ parseTime(detail.trainDate, '{y}-{m}-{d}') }}
       </el-descriptions-item>
       <el-descriptions-item>
         <template slot="label">培训时间</template>
@@ -18,32 +18,32 @@
 
     <vxe-table
       ref="feedBackRef"
-      class="mt10"
       border
-      resizable
-      show-overflow
+      class="mt10"
       :data="detail.distributorList"
       :edit-config="{ trigger: 'click', mode: 'cell' }"
+      resizable
+      show-overflow
       style="height: 300px; margin-top: 10px">
-      <vxe-column type="seq" width="50" title="序号" />
+      <vxe-column title="序号" type="seq" width="50" />
       <vxe-column field="distributorName" title="渠道名称" width="180" />
 
       <vxe-column field="trainingPersNum" title="参训人数" width="120">
         <template #default="{ row }">
           <vxe-input
             v-model.number="row.trainingPersNum"
-            type="number"
             :disabled="type == 'detail'"
-            placeholder="参训人数" />
+            placeholder="参训人数"
+            type="number" />
         </template>
       </vxe-column>
       <vxe-column field="distributorFeedback" title="渠道反馈">
         <template #default="{ row }">
           <vxe-input
             v-model="row.distributorFeedback"
-            type="text"
             :disabled="type == 'detail'"
-            placeholder="请输入渠道反馈" />
+            placeholder="请输入渠道反馈"
+            type="text" />
         </template>
       </vxe-column>
     </vxe-table>

+ 25 - 16
vue.config.js

@@ -36,6 +36,10 @@ const resolve = (dir) => {
   return path.join(__dirname, dir)
 }
 
+const microServiceProxyPrefix = '/micro-srv-proxy'
+const restApiProxyPrefix = '/api'
+const isHttpUrl = (url) => /^https?:\/\//i.test(url || '')
+
 module.exports = {
   publicPath,
   assetsDir,
@@ -51,22 +55,27 @@ module.exports = {
       warnings: true,
       errors: true,
     },
-    // 注释掉的地方是前端配置代理访问后端的示例
-    // baseURL必须为/xxx,而不是后端服务器,请先了解代理逻辑,再设置前端代理
-    // !!!一定要注意!!!
-    // 1.这里配置了跨域及代理只针对开发环境生效
-    // 2.不建议你在前端配置跨域,建议你后端配置Allow-Origin,Method,Headers,放行token字段,一步到位
-    // 3.后端配置了跨域,就不需要前端再配置,会发生Origin冲突
-    // proxy: {
-    //   [baseURL]: {
-    //     target: `http://你的后端接口地址`,
-    //     ws: true,
-    //     changeOrigin: true,
-    //     pathRewrite: {
-    //       ['^' + baseURL]: '',
-    //     },
-    //   },
-    // },
+    // 开发环境代理:当前端和后端端口不一致时,统一经由 devServer 转发,避免浏览器 CORS 限制
+    proxy: isHttpUrl(process.env.VUE_APP_MicroSrvProxy_API)
+      ? {
+          [restApiProxyPrefix]: {
+            target: process.env.VUE_APP_MicroSrvProxy_API,
+            ws: false,
+            changeOrigin: true,
+            pathRewrite: {
+              [`^${restApiProxyPrefix}`]: '',
+            },
+          },
+          [microServiceProxyPrefix]: {
+            target: process.env.VUE_APP_MicroSrvProxy_API,
+            ws: true,
+            changeOrigin: true,
+            pathRewrite: {
+              [`^${microServiceProxyPrefix}`]: '',
+            },
+          },
+        }
+      : {},
   },
   configureWebpack() {
     return {