Просмотр исходного кода

Merge branch 'sso' of shuzhihua/randd-vue into master

yanghao 2 недель назад
Родитель
Сommit
8f2eb2810c

+ 1 - 1
.env.local

@@ -4,7 +4,7 @@ NODE_ENV=development
 VITE_DEV=true
 
 # 请求路径
-VITE_BASE_URL='http://172.30.41.105:38080'
+VITE_BASE_URL='https://rdm.deepoil.cc'
 
 # 文件上传类型:server - 后端上传, client - 前端直连上传,仅支持 S3 服务
 VITE_UPLOAD_TYPE=server

+ 3 - 0
package.json

@@ -83,6 +83,9 @@
     "vue3-print-nb": "^0.1.4",
     "vue3-signature": "^0.2.4",
     "vuedraggable": "^4.1.0",
+    "vxe-gantt": "^4.7.2",
+    "vxe-pc-ui": "^4.18.1",
+    "vxe-table": "^4.21.5",
     "web-storage-cache": "^1.1.1",
     "xml-js": "^1.6.11"
   },

+ 49 - 0
pnpm-lock.yaml

@@ -182,6 +182,15 @@ importers:
       vuedraggable:
         specifier: ^4.1.0
         version: 4.1.0(vue@3.5.12(typescript@5.3.3))
+      vxe-gantt:
+        specifier: ^4.7.2
+        version: 4.7.2
+      vxe-pc-ui:
+        specifier: ^4.18.1
+        version: 4.18.1(vue@3.5.12(typescript@5.3.3))
+      vxe-table:
+        specifier: ^4.21.5
+        version: 4.21.5
       web-storage-cache:
         specifier: ^1.1.1
         version: 1.1.1
@@ -2291,6 +2300,11 @@ packages:
   '@vueuse/shared@9.13.0':
     resolution: {integrity: sha512-UrnhU+Cnufu4S6JLCPZnkWh0WwZGUp72ktOF2DFptMlOs3TOdVv8xJN53zhHGARmVOsz5KqOls09+J1NR6sBKw==}
 
+  '@vxe-ui/core@4.4.21':
+    resolution: {integrity: sha512-xaKGhQxrSU3G7+cQbxMBSzNXiWliRDgtR5GmD/FD9f5tl8e9SuZLe2SfODjItp6oBvBkKI2TXBZiCx502WftyA==}
+    peerDependencies:
+      vue: ^3.2.0
+
   '@wangeditor-next/basic-modules@1.5.46':
     resolution: {integrity: sha512-q8/al99P7+koPTJ+X79WjrEP5pDXs8+vFYo0pIbs59Oi1cM1b9CCB0384Mdcg0DQU7eZrN7dl2bHKO5Z5r7E8g==}
     peerDependencies:
@@ -3043,6 +3057,9 @@ packages:
   dom-walk@0.1.2:
     resolution: {integrity: sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==}
 
+  dom-zindex@1.0.7:
+    resolution: {integrity: sha512-cKU/h8v8IPBgdZOTPbPmq3Ib+Ac5C+kKoh9I4LbGR9BM3GwbmB16KYWKJcj5M2BavnA66EbgYzxYDLd1IytnlQ==}
+
   dom7@4.0.6:
     resolution: {integrity: sha512-emjdpPLhpNubapLFdjNL9tP06Sr+GZkrIHEXLWvOGsytACUrkbeIdjO5g77m00BrHTznnlcNqgmn7pCN192TBA==}
 
@@ -5111,6 +5128,15 @@ packages:
     peerDependencies:
       vue: ^3.0.1
 
+  vxe-gantt@4.7.2:
+    resolution: {integrity: sha512-iIfX/xtDdheEMWko2QcPtl9bYUPVCYMvVRpsYtMuknNXf3eK/BtOnk8hVYjhY1vszhOjoTPkqIdhMXeV5z7I3A==}
+
+  vxe-pc-ui@4.18.1:
+    resolution: {integrity: sha512-QIIu1gzn/cx8uyqSr1E7XQUqH9XAuFov2ikkuMJfSf8S3UaUyJ2w+wjogtITp7+o7O0WOKRW14XuN3PjBOMrDA==}
+
+  vxe-table@4.21.5:
+    resolution: {integrity: sha512-/jbJ5JZJ27nW84rQ65Xrxm7atn3f+kaSjY2TE//L2fTT/5q9/VJ9t/qg8UbceUERHi+TmxXOr7V1C9aUud79JQ==}
+
   w3c-keyname@2.2.8:
     resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
 
@@ -5171,6 +5197,9 @@ packages:
     resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==}
     engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
 
+  xe-utils@4.0.13:
+    resolution: {integrity: sha512-hIxQ3LQv/WF0DyeNsZ7VclX5+n5CKoUJ3DG76GFtuAnVQ/NaSv3MKmyZ6MD73wfSdzZBcziV8wcas8GRNSYMwg==}
+
   xml-js@1.6.11:
     resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==}
     hasBin: true
@@ -7591,6 +7620,12 @@ snapshots:
       - '@vue/composition-api'
       - vue
 
+  '@vxe-ui/core@4.4.21(vue@3.5.12(typescript@5.3.3))':
+    dependencies:
+      dom-zindex: 1.0.7
+      vue: 3.5.12(typescript@5.3.3)
+      xe-utils: 4.0.13
+
   '@wangeditor-next/basic-modules@1.5.46(@wangeditor-next/core@1.7.45(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@4.0.6)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@5.1.6)(slate@0.82.1)(snabbdom@3.6.2))(dom7@4.0.6)(lodash.throttle@4.1.1)(nanoid@5.1.6)(slate@0.82.1)(snabbdom@3.6.2)':
     dependencies:
       '@wangeditor-next/core': 1.7.45(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@4.0.6)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@5.1.6)(slate@0.82.1)(snabbdom@3.6.2)
@@ -8415,6 +8450,8 @@ snapshots:
 
   dom-walk@0.1.2: {}
 
+  dom-zindex@1.0.7: {}
+
   dom7@4.0.6:
     dependencies:
       ssr-window: 4.0.2
@@ -10570,6 +10607,16 @@ snapshots:
       sortablejs: 1.14.0
       vue: 3.5.12(typescript@5.3.3)
 
+  vxe-gantt@4.7.2: {}
+
+  vxe-pc-ui@4.18.1(vue@3.5.12(typescript@5.3.3)):
+    dependencies:
+      '@vxe-ui/core': 4.4.21(vue@3.5.12(typescript@5.3.3))
+    transitivePeerDependencies:
+      - vue
+
+  vxe-table@4.21.5: {}
+
   w3c-keyname@2.2.8: {}
 
   wangeditor@4.7.15:
@@ -10634,6 +10681,8 @@ snapshots:
       imurmurhash: 0.1.4
       signal-exit: 4.1.0
 
+  xe-utils@4.0.13: {}
+
   xml-js@1.6.11:
     dependencies:
       sax: 1.4.1

+ 9 - 1
src/components/Dialog/src/Dialog.vue

@@ -10,7 +10,7 @@ const emits = defineEmits(['update:modelValue'])
 const props = defineProps({
   modelValue: propTypes.bool.def(false),
   title: propTypes.string.def('Dialog'),
-  fullscreen: propTypes.bool.def(true),
+  fullscreen: propTypes.bool.def(false),
   width: propTypes.oneOfType([String, Number]).def('40%'),
   closeOnClickModal: propTypes.bool.def(true),
   closeOnPressEscape: propTypes.bool.def(true),
@@ -136,6 +136,9 @@ function closedHandler() {
 
   .#{$elNamespace}-dialog {
     margin: 0 !important;
+    display: flex;
+    flex-direction: column;
+    max-height: calc(100vh - 40px);
 
     &__header {
       height: 54px;
@@ -145,10 +148,15 @@ function closedHandler() {
     }
 
     &__body {
+      flex: 1;
+      min-height: 0;
+      overflow: auto;
       padding: 15px !important;
     }
 
     &__footer {
+      flex-shrink: 0;
+      background-color: var(--el-bg-color);
       border-top: 1px solid var(--el-border-color);
     }
 

+ 147 - 0
src/components/ResearchProjectDialog/src/ResearchProjectDialog.vue

@@ -0,0 +1,147 @@
+<script lang="ts" setup>
+import { propTypes } from '@/utils/propTypes'
+import { isNumber } from '@/utils/is'
+
+defineOptions({ name: 'ResearchProjectDialog' })
+
+const slots = useSlots()
+const emits = defineEmits(['update:modelValue'])
+
+const props = defineProps({
+  modelValue: propTypes.bool.def(false),
+  title: propTypes.string.def('Dialog'),
+  fullscreen: propTypes.bool.def(true),
+  width: propTypes.oneOfType([String, Number]).def('40%'),
+  closeOnClickModal: propTypes.bool.def(true),
+  closeOnPressEscape: propTypes.bool.def(true),
+  scroll: propTypes.bool.def(false),
+  maxHeight: propTypes.oneOfType([String, Number]).def('400px')
+})
+
+const getBindValue = computed(() => {
+  const delArr: string[] = ['fullscreen', 'title', 'maxHeight', 'appendToBody']
+  const attrs = useAttrs()
+  const obj = { ...attrs, ...props }
+  for (const key in obj) {
+    if (delArr.indexOf(key) !== -1) delete obj[key]
+  }
+  return obj
+})
+
+const isFullscreen = ref(props.fullscreen)
+const toggleFull = () => {
+  isFullscreen.value = !unref(isFullscreen)
+}
+const dialogHeight = ref(isNumber(props.maxHeight) ? `${props.maxHeight}px` : props.maxHeight)
+
+watch(
+  () => isFullscreen.value,
+  async (val: boolean) => {
+    await nextTick()
+    dialogHeight.value = val
+      ? `${document.documentElement.offsetHeight - 55 - 60 - (slots.footer ? 63 : 0)}px`
+      : isNumber(props.maxHeight)
+        ? `${props.maxHeight}px`
+        : props.maxHeight
+  },
+  { immediate: true }
+)
+
+const closing = ref(false)
+const closeHandler = () => {
+  emits('update:modelValue', false)
+  closing.value = true
+}
+const closedHandler = () => {
+  closing.value = false
+}
+</script>
+
+<template>
+  <ElDialog
+    v-bind="getBindValue"
+    :close-on-click-modal="closeOnClickModal"
+    :close-on-press-escape="closeOnPressEscape"
+    :fullscreen="isFullscreen"
+    :width="width"
+    destroy-on-close
+    lock-scroll
+    draggable
+    class="research-project-dialog"
+    :show-close="false"
+    @close="closeHandler"
+    @closed="closedHandler"
+  >
+    <template #header="{ close }">
+      <div class="relative h-54px flex items-center justify-between pl-15px pr-15px">
+        <slot name="title">{{ title }}</slot>
+        <div class="absolute right-15px top-[50%] h-54px flex translate-y-[-50%] items-center">
+          <Icon
+            v-if="fullscreen"
+            class="is-hover mr-10px cursor-pointer"
+            :icon="isFullscreen ? 'radix-icons:exit-full-screen' : 'radix-icons:enter-full-screen'"
+            color="var(--el-color-info)"
+            hover-color="var(--el-color-primary)"
+            @click="toggleFull"
+          />
+          <Icon
+            class="is-hover cursor-pointer"
+            icon="ep:close"
+            hover-color="var(--el-color-primary)"
+            color="var(--el-color-info)"
+            @click.stop="close"
+          />
+        </div>
+      </div>
+    </template>
+
+    <ElScrollbar v-if="scroll" :style="{ height: dialogHeight }"><slot></slot></ElScrollbar>
+    <slot v-else></slot>
+    <template v-if="slots.footer" #footer>
+      <div :style="{ 'pointer-events': closing ? 'none' : 'auto' }"
+        ><slot name="footer"></slot
+      ></div>
+    </template>
+  </ElDialog>
+</template>
+
+<style lang="scss">
+.research-project-dialog {
+  .#{$elNamespace}-overlay-dialog {
+    display: flex;
+    justify-content: center;
+    align-items: center;
+  }
+
+  .#{$elNamespace}-dialog {
+    margin: 0 !important;
+    display: flex;
+    flex-direction: column;
+    max-height: calc(100vh - 40px);
+
+    &__header {
+      height: 54px;
+      padding: 0;
+      margin-right: 0 !important;
+      border-bottom: 1px solid var(--el-border-color);
+    }
+
+    &__body {
+      flex: 1;
+      min-height: 0;
+      overflow: auto;
+      padding: 15px !important;
+    }
+
+    &__footer {
+      flex-shrink: 0;
+      background-color: var(--el-bg-color);
+      border-top: 1px solid var(--el-border-color);
+    }
+
+    &__headerbtn {
+      top: 0;
+    }
+  }
+}
+</style>

+ 8 - 0
src/main.ts

@@ -47,6 +47,13 @@ import { setupWangEditorPlugin } from '@/views/bpm/model/form/PrintTemplate'
 
 import print from 'vue3-print-nb' // 打印插件
 
+import VxeUIBase from 'vxe-pc-ui'
+import 'vxe-pc-ui/es/style.css'
+import VxeUITable from 'vxe-table'
+import 'vxe-table/es/style.css'
+import VxeGantt from 'vxe-gantt'
+import 'vxe-gantt/es/style.css'
+
 // 创建实例
 const setupAll = async () => {
   const app = createApp(App)
@@ -76,6 +83,7 @@ const setupAll = async () => {
 
   // 打印
   app.use(print)
+  app.use(VxeUIBase).use(VxeUITable).use(VxeGantt)
 
   app.mount('#app')
 }

+ 42 - 0
src/router/modules/remaining.ts

@@ -355,6 +355,48 @@ const remainingRouter: AppRouteRecordRaw[] = [
       hidden: true,
       breadcrumb: false
     }
+  },
+
+  {
+    path: '/research_project',
+    redirect: '/research_project/list',
+    name: 'ResearchProject',
+    meta: {
+      hidden: true
+    },
+    children: [
+      {
+        path: 'list',
+        component: () => import('@/views/researchProject/list/index.vue'),
+        name: 'ResearchProjectList',
+        meta: {
+          title: '科研项目',
+          noCache: true,
+          canTo: true
+        }
+      },
+      {
+        path: 'edit/:id?',
+        component: () => import('@/views/researchProject/list/edit.vue'),
+        name: 'ResearchProjectEdit',
+        meta: {
+          title: '研究项目编辑',
+          noCache: true,
+          hidden: true,
+          canTo: true
+        }
+      },
+      {
+        path: 'breakdown',
+        component: () => import('@/views/researchProject/breakdown/index.vue'),
+        name: 'ResearchProjectBreakdown',
+        meta: {
+          title: '项目分解',
+          noCache: true,
+          canTo: true
+        }
+      }
+    ]
   }
 ]
 

Разница между файлами не показана из-за своего большого размера
+ 541 - 216
src/views/expert/list/index.vue


+ 1971 - 0
src/views/expert/list/index2.vue

@@ -0,0 +1,1971 @@
+<template>
+  <div class="expert-step-table">
+    <el-card>
+      <div class="search-layout">
+        <section class="filter-panel">
+          <div class="filter-fields">
+            <el-input v-model="queryParams.expertName" placeholder="姓名 / 专业 / 业绩" clearable />
+
+            <el-select v-model="queryParams.education" placeholder="请选择专家学历" clearable>
+              <el-option
+                v-for="dict in getStrDictOptions(DICT_TYPE.RD_EXPERT_EDUCATION)"
+                :key="dict.value"
+                :label="dict.label"
+                :value="dict.value"
+              />
+            </el-select>
+
+            <el-select v-model="queryParams.echelon" placeholder="请选择专家梯队" clearable>
+              <el-option
+                v-for="dict in getStrDictOptions(DICT_TYPE.RD_EXPERT_ECHELON)"
+                :key="dict.value"
+                :label="dict.label"
+                :value="dict.value"
+              />
+            </el-select>
+
+            <div class="flex">
+              <el-button type="primary" @click="handleAdd"
+                ><Icon icon="ep:plus" class="mr-5px" />新增</el-button
+              >
+
+              <el-button type="primary" @click="getList"
+                ><Icon icon="ep:search" class="mr-5px" />搜索</el-button
+              >
+
+              <el-button type="primary" @click="handleReset"
+                ><Icon icon="ep:refresh" class="mr-5px" />重置</el-button
+              >
+            </div>
+          </div>
+        </section>
+
+        <aside class="word-cloud-panel">
+          <div class="word-cloud">
+            <button
+              v-for="word in wordCloudWords"
+              :key="word.text"
+              type="button"
+              class="cloud-word"
+              :style="{
+                left: word.left,
+                top: word.top,
+                '--rotate': `${word.rotate}deg`,
+                fontSize: `${word.size}px`,
+                color: word.color,
+                transform: `rotate(${word.rotate}deg)`
+              }"
+              @click="handleWordClick(word.text)"
+            >
+              {{ word.text }}
+            </button>
+          </div>
+        </aside>
+      </div>
+
+      <div
+        v-for="(row, rowIndex) in filteredTableData"
+        :key="row.teamName"
+        class="ladder-row"
+        :style="{ width: row.tableWidth, '--row-bg': row.backgroundColor }"
+      >
+        <div class="team-cell">
+          <span class="team-index">0{{ rowIndex + 1 }}</span>
+          <span class="team-name">{{ row.teamName }}</span>
+        </div>
+
+        <table class="content-table" :aria-label="row.teamName">
+          <colgroup>
+            <col
+              v-for="(_, index) in row.columnWidths || []"
+              :key="index"
+              :style="{ width: `${getColumnPercent(row, index)}%` }"
+            />
+          </colgroup>
+
+          <tbody>
+            <tr>
+              <td
+                v-for="(cell, index) in row.cells || []"
+                :key="index"
+                :class="['step-cell', cell.tone === 'danger' ? 'notice-cell' : '']"
+              >
+                <span class="cell-bookmark">{{ row.bookmarks[index] }}</span>
+
+                <template v-if="cell.type === 'members'">
+                  <button
+                    v-for="member in cell.members"
+                    :key="member.id"
+                    type="button"
+                    class="member-link"
+                    @click="handleMemberClick(member)"
+                  >
+                    {{ member.expertName }}
+                  </button>
+                </template>
+
+                <span v-else :class="['cell-text', cell.tone === 'danger' ? 'is-danger' : '']">
+                  {{ cell.text }}
+                </span>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
+    </el-card>
+
+    <el-drawer
+      v-model="drawerVisible"
+      size="56%"
+      :with-header="false"
+      class="expert-drawer"
+      destroy-on-close
+    >
+      <template v-if="activeExpert">
+        <div class="drawer-shell" v-loading="drawerLoading">
+          <header class="drawer-header">
+            <div>
+              <h2>{{ activeExpert.name }} - 专家详情</h2>
+              <p>
+                {{ activeExpert.role }} / {{ activeExpert.team }} /
+                {{ activeExpert.direction.join('、') }}
+              </p>
+            </div>
+          </header>
+
+          <section class="drawer-profile">
+            <div class="profile-aside">
+              <div class="profile-avatar">
+                <img :src="activeExpert.avatar" :alt="activeExpert.name" />
+                <!-- <span v-else>{{ activeExpert.name.slice(0, 1) }}</span> -->
+              </div>
+              <div class="profile-tags">
+                <span v-for="tag in activeExpert.tags" :key="tag" class="profile-tag">{{
+                  tag
+                }}</span>
+              </div>
+            </div>
+
+            <div class="profile-main">
+              <div class="info-block">
+                <span class="info-label">专家序列 / 梯队 / 学历</span>
+                <strong
+                  >{{ activeExpert.role }} / {{ activeExpert.team }} /
+                  {{ activeExpert.education }}</strong
+                >
+              </div>
+
+              <div class="info-block">
+                <span class="info-label">专业方向</span>
+                <strong>{{ activeExpert.direction.join('、') || '-' }}</strong>
+              </div>
+
+              <div class="info-block">
+                <span class="info-label">专业软件技能</span>
+                <strong>{{ activeExpert.softwareSkills.join('、') || '-' }}</strong>
+              </div>
+
+              <div class="info-block">
+                <span class="info-label">毕业院校 / 出生日期</span>
+                <strong
+                  >{{ activeExpert.school || '-' }} / {{ activeExpert.birthdayText || '-' }}</strong
+                >
+              </div>
+
+              <div class="info-block">
+                <span class="info-label">职称 / 岗位</span>
+                <strong
+                  >{{ activeExpert.professionalTitle || '-' }} /
+                  {{ activeExpert.jobTitle || '-' }}</strong
+                >
+              </div>
+
+              <div class="info-block">
+                <span class="info-label">历任岗位</span>
+                <strong>{{ activeExpert.formerPost || '-' }}</strong>
+              </div>
+
+              <div class="info-block">
+                <span class="info-label">主要业绩</span>
+                <strong>{{ activeExpert.achievement || '-' }}</strong>
+              </div>
+
+              <div class="profile-actions">
+                <el-button type="primary" size="large" @click="handleJoinTeam">加入团队</el-button>
+                <el-button size="large" type="primary" plain @click="handleEdit"
+                  >维护档案</el-button
+                >
+                <el-button size="large" plain>下载履历</el-button>
+              </div>
+            </div>
+          </section>
+
+          <section class="record-panel">
+            <div class="record-header">
+              <h3>所在团队</h3>
+            </div>
+
+            <el-table
+              :data="currentExpertDetail?.teams || []"
+              class="record-table"
+              table-layout="fixed"
+            >
+              <el-table-column prop="teamName" label="团队名称" min-width="120" align="center" />
+              <el-table-column label="团队所属公司" min-width="120" align="center">
+                <template #default="{ row }">
+                  {{ getDeptName(row.deptId) || '-' }}
+                </template>
+              </el-table-column>
+              <el-table-column label="团队 Logo" align="center" width="120">
+                <template #default="{ row }">
+                  <el-image
+                    v-if="row.avatar"
+                    :src="row.avatar"
+                    fit="cover"
+                    class="team-logo"
+                    :preview-src-list="[row.avatar]"
+                    preview-teleported
+                  />
+                  <span v-else>-</span>
+                </template>
+              </el-table-column>
+              <el-table-column
+                prop="summary"
+                label="团队概述"
+                min-width="240"
+                align="center"
+                show-overflow-tooltip
+              />
+            </el-table>
+          </section>
+        </div>
+      </template>
+    </el-drawer>
+
+    <Dialog
+      :title="dialogTitle"
+      v-model="dialogVisible"
+      :close-on-click-modal="false"
+      :close-on-press-escape="false"
+      destroy-on-close
+      @close="closeDialog"
+      width="60%"
+    >
+      <el-form
+        ref="formRef"
+        :model="formData"
+        :rules="formRules"
+        v-loading="formLoading"
+        label-width="auto"
+      >
+        <el-row :gutter="20">
+          <el-col :span="12">
+            <el-form-item label="部门" prop="deptId">
+              <el-tree-select
+                clearable
+                v-model="formData.deptId"
+                :data="deptList2"
+                :props="defaultProps"
+                :check-strictly="false"
+                node-key="id"
+                filterable
+                placeholder="请选择所在部门"
+              />
+            </el-form-item>
+          </el-col>
+
+          <el-col :span="12">
+            <el-form-item label="专家姓名" prop="expertName">
+              <el-input v-model="formData.expertName" placeholder="请输入专家姓名" />
+            </el-form-item>
+          </el-col>
+        </el-row>
+
+        <el-row :gutter="20">
+          <el-col :span="12">
+            <el-form-item label="专家序列" prop="expertRank">
+              <el-select
+                multiple
+                v-model="formData.expertRank"
+                placeholder="请选择专家序列"
+                clearable
+              >
+                <el-option
+                  v-for="dict in getStrDictOptions(DICT_TYPE.RD_EXPERT_RANK)"
+                  :key="dict.value"
+                  :label="dict.label"
+                  :value="Number(dict.value)"
+                />
+              </el-select>
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="专家梯队" prop="echelon">
+              <el-select v-model="formData.echelon" placeholder="请选择专家梯队" clearable>
+                <el-option
+                  v-for="dict in getStrDictOptions(DICT_TYPE.RD_EXPERT_ECHELON)"
+                  :key="dict.value"
+                  :label="dict.label"
+                  :value="dict.value"
+                />
+              </el-select>
+            </el-form-item>
+          </el-col>
+        </el-row>
+
+        <el-row :gutter="20">
+          <el-col :span="12">
+            <el-form-item label="学历" prop="education">
+              <el-select v-model="formData.education" placeholder="请选择专家学历" clearable>
+                <el-option
+                  v-for="dict in getStrDictOptions(DICT_TYPE.RD_EXPERT_EDUCATION)"
+                  :key="dict.value"
+                  :label="dict.label"
+                  :value="dict.value"
+                />
+              </el-select>
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="专业方向" prop="specialDirection">
+              <el-select
+                v-model="formData.specialDirection"
+                placeholder="请选择专业方向"
+                clearable
+                multiple
+                filterable
+              >
+                <el-option
+                  v-for="dict in getStrDictOptions(DICT_TYPE.RD_EXPERT_TECHNICAL)"
+                  :key="dict.value"
+                  :label="dict.label"
+                  :value="Number(dict.value)"
+                />
+              </el-select>
+            </el-form-item>
+          </el-col>
+        </el-row>
+
+        <el-row :gutter="20">
+          <el-col :span="12">
+            <el-form-item label="专业软件技能" prop="softwareSkills">
+              <el-select
+                v-model="formData.softwareSkills"
+                placeholder="请选择专业软件技能"
+                clearable
+                multiple
+                filterable
+              >
+                <el-option
+                  v-for="dict in getStrDictOptions(DICT_TYPE.RD_EXPERT_SOFTWARE)"
+                  :key="dict.value"
+                  :label="dict.label"
+                  :value="Number(dict.value)"
+                />
+              </el-select>
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="所属团队" prop="teamIds">
+              <div class="team-select-field">
+                <el-input :model-value="selectedTeamNames" placeholder="请选择所属团队" readonly />
+                <el-button type="primary" plain @click="openTeamDialog">选择团队</el-button>
+              </div>
+            </el-form-item>
+          </el-col>
+        </el-row>
+
+        <el-row :gutter="20">
+          <el-col :span="12">
+            <el-form-item label="毕业院校" prop="school">
+              <el-input
+                v-model="formData.school"
+                placeholder="请输入毕业院校"
+                style="width: 100%"
+              />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="出生日期" prop="birthday">
+              <el-date-picker
+                v-model="formData.birthday"
+                type="date"
+                value-format="x"
+                placeholder="请选择出生日期"
+                style="width: 100%"
+              />
+            </el-form-item>
+          </el-col>
+        </el-row>
+
+        <el-row :gutter="20">
+          <el-col :span="12">
+            <el-form-item label="头像" prop="avatar">
+              <UploadImg v-model="formData.avatar" height="50px" />
+            </el-form-item>
+          </el-col>
+
+          <el-col :span="12">
+            <el-form-item label="历任岗位" prop="formerPost">
+              <el-input v-model="formData.formerPost" placeholder="请输入历任岗位" />
+            </el-form-item>
+          </el-col>
+        </el-row>
+
+        <el-row :gutter="20">
+          <el-col :span="12">
+            <el-form-item label="岗位" prop="jobTitle">
+              <el-input v-model="formData.jobTitle" placeholder="请输入岗位" />
+            </el-form-item>
+          </el-col>
+
+          <el-col :span="12">
+            <el-form-item label="职称" prop="professionalTitle">
+              <el-input v-model="formData.professionalTitle" placeholder="请输入职称" />
+            </el-form-item>
+          </el-col>
+        </el-row>
+
+        <el-row :gutter="20">
+          <el-col :span="24">
+            <el-form-item label="主要业绩" prop="achievement">
+              <el-input
+                type="textarea"
+                v-model="formData.achievement"
+                placeholder="请输入主要业绩"
+              />
+            </el-form-item>
+          </el-col>
+        </el-row>
+      </el-form>
+
+      <template #footer>
+        <el-button @click="closeDialog">取 消</el-button>
+        <el-button type="primary" @click="submitExpertForm" :loading="submitLoading"
+          >确 定</el-button
+        >
+      </template>
+    </Dialog>
+
+    <Dialog v-model="teamDialogVisible" title="选择所属团队" width="70%" class="team-picker-dialog">
+      <el-form :inline="true" :model="teamQueryParams" class="-mb-15px">
+        <el-form-item label="团队名称">
+          <el-input
+            v-model="teamQueryParams.teamName"
+            placeholder="请输入团队名称"
+            clearable
+            @keyup.enter="handleTeamQuery"
+          />
+        </el-form-item>
+        <el-form-item>
+          <el-button @click="handleTeamQuery">
+            <Icon icon="ep:search" class="mr-5px" />
+            搜索
+          </el-button>
+          <el-button @click="resetTeamQuery">
+            <Icon icon="ep:refresh" class="mr-5px" />
+            重置
+          </el-button>
+        </el-form-item>
+      </el-form>
+
+      <div class="team-picker-table-wrap">
+        <el-table
+          ref="teamTableRef"
+          v-loading="teamLoading"
+          :data="teamList"
+          row-key="id"
+          height="calc(100vh - 400px)"
+          @selection-change="handleTeamSelectionChange"
+        >
+          <el-table-column type="selection" width="55" reserve-selection />
+          <el-table-column label="团队名称" prop="teamName" width="120" />
+          <el-table-column label="团队所属公司" align="center" min-width="120">
+            <template #default="{ row }">
+              {{ getDeptName(row.deptId) || '-' }}
+            </template>
+          </el-table-column>
+          <el-table-column label="团队 Logo" align="center" width="120">
+            <template #default="{ row }">
+              <el-image
+                v-if="row.avatar"
+                :src="row.avatar"
+                fit="cover"
+                class="team-logo"
+                :preview-src-list="[row.avatar]"
+                preview-teleported
+              />
+              <span v-else>-</span>
+            </template>
+          </el-table-column>
+          <el-table-column label="团队概述" prop="summary" min-width="240" show-overflow-tooltip />
+        </el-table>
+
+        <Pagination
+          v-model:page="teamQueryParams.pageNo"
+          v-model:limit="teamQueryParams.pageSize"
+          :total="teamTotal"
+          @pagination="getTeamOptionsList"
+        />
+      </div>
+
+      <template #footer>
+        <div class="team-picker-footer">
+          <el-button @click="teamDialogVisible = false">取 消</el-button>
+          <el-button type="primary" @click="confirmTeamSelection">确 定</el-button>
+        </div>
+      </template>
+    </Dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, nextTick, onMounted, reactive, ref } from 'vue'
+import {
+  createExpert,
+  getExpertDetail,
+  getExpertList,
+  getTeamList,
+  updateExpert
+} from '@/api/expert'
+import * as DeptApi from '@/api/system/dept'
+import { defaultProps } from '@/utils/tree'
+import { DICT_TYPE, getStrDictOptions } from '@/utils/dict'
+import { ElTable } from 'element-plus'
+
+const defaultFormData = () => ({
+  id: undefined,
+  expertName: '',
+  achievement: '',
+  echelon: '',
+  education: '',
+  specialDirection: [],
+  softwareSkills: [],
+  teamIds: [],
+  school: '',
+  birthday: null,
+  avatar: '',
+  deptId: '',
+  formerPost: '',
+  jobTitle: '',
+  professionalTitle: '',
+  expertRank: []
+})
+
+const deptList2 = ref<Tree[]>([]) // 树形结构
+
+const formLoading = ref(false) // 表单加载中
+const submitLoading = ref(false) // 提交按钮加载中
+const teamLoading = ref(false)
+const drawerLoading = ref(false)
+
+// 对话框相关
+const dialogVisible = ref(false)
+const dialogTitle = ref('')
+const isEdit = ref(false)
+const teamDialogVisible = ref(false)
+const teamDialogMode = ref<'form' | 'drawer'>('form')
+
+// 表单相关
+const formRef = ref()
+const formData = ref(defaultFormData())
+const teamTableRef = ref<InstanceType<typeof ElTable>>()
+const teamList = ref<any[]>([])
+const teamTotal = ref(0)
+const tempSelectedTeams = ref<any[]>([])
+const selectedTeams = ref<any[]>([])
+const tempSelectedTeamMap = ref<Record<number, any>>({})
+const syncingTeamSelection = ref(false)
+const currentExpertDetail = ref<any>(null)
+
+const formRules = {
+  expertName: [{ required: true, message: '请输入专家名称', trigger: 'blur' }],
+  achievement: [{ required: true, message: '请输入成果', trigger: 'blur' }],
+  echelon: [{ required: true, message: '请选择专家层级', trigger: 'blur' }],
+  education: [{ required: true, message: '请选择专家学历', trigger: 'blur' }],
+  specialDirection: [{ required: true, message: '请选择专业方向', trigger: 'blur' }],
+  // softwareSkills: [{ required: true, message: '请选择专业软件技能', trigger: 'blur' }],
+  // teamIds: [{ required: true, message: '请选择所属团队', trigger: 'blur' }],
+  school: [{ required: true, message: '请输入毕业院校', trigger: 'blur' }],
+  birthday: [{ required: true, message: '请选择出生日期', trigger: 'blur' }],
+  avatar: [{ required: true, message: '请上传头像', trigger: 'blur' }],
+  deptId: [{ required: true, message: '请选择部门', trigger: 'blur' }],
+  // formerPost: [{ required: true, message: '请输入历任岗位', trigger: 'blur' }],
+  // jobTitle: [{ required: true, message: '请输入岗位', trigger: 'blur' }],
+  // professionalTitle: [{ required: true, message: '请输入职称', trigger: 'blur' }],
+  expertRank: [{ required: true, message: '请选择专家序列', trigger: 'blur' }]
+}
+
+// 重置表单
+const resetForm = () => {
+  formData.value = defaultFormData()
+  selectedTeams.value = []
+  tempSelectedTeams.value = []
+  tempSelectedTeamMap.value = {}
+  formRef.value?.clearValidate()
+}
+// 关闭对话框
+const closeDialog = () => {
+  dialogVisible.value = false
+  resetForm()
+}
+
+const fillTeamSelection = (teams: any[] = []) => {
+  selectedTeams.value = [...teams]
+  tempSelectedTeams.value = [...teams]
+  tempSelectedTeamMap.value = Object.fromEntries(teams.map((team) => [team.id, team]))
+}
+
+const buildFormDataFromExpertDetail = (detail: any) => {
+  const teams = detail?.teams || []
+  fillTeamSelection(teams)
+  return {
+    id: detail?.id,
+    expertName: detail?.expertName || '',
+    achievement: detail?.achievement || '',
+    echelon: detail?.echelon || '',
+    education: detail?.education || '',
+    specialDirection: detail?.specialDirection || [],
+    softwareSkills: detail?.softwareSkills || [],
+    teamIds: teams.length ? teams.map((team) => team.id) : detail?.teamIds || [],
+    school: detail?.school || '',
+    birthday: detail?.birthday || null,
+    avatar: detail?.avatar || '',
+    deptId: detail?.deptId || '',
+    formerPost: detail?.formerPost || '',
+    jobTitle: detail?.jobTitle || '',
+    professionalTitle: detail?.professionalTitle || '',
+    expertRank: detail?.expertRank || []
+  }
+}
+
+type CellItem =
+  | {
+      type: 'members'
+      members: ExpertItem[]
+    }
+  | {
+      type: 'text'
+      text: string
+      tone?: 'normal' | 'danger'
+    }
+
+type TableRow = {
+  teamName: string
+  education: string
+  searchText: string
+  bookmarks: string[]
+  tableWidth: string
+  backgroundColor: string
+  columnWidths: number[]
+  cells: CellItem[]
+}
+
+type ExpertRecord = {
+  date: string
+  event: string
+  project: string
+  status: string
+  statusType: 'success' | 'primary'
+}
+
+type ExpertProfile = {
+  id: number
+  name: string
+  avatar: string
+  role: string
+  team: string
+  company: string
+  age: number
+  education: string
+  direction: string[]
+  softwareSkills: string[]
+  teamNames: string[]
+  teamIds: number[]
+  school: string
+  birthdayText: string
+  formerPost: string
+  jobTitle: string
+  professionalTitle: string
+  tags: string[]
+  achievement: string
+  records: ExpertRecord[]
+}
+
+type ExpertItem = {
+  id: number
+  deptId: number
+  expertName: string
+  echelon: string
+  expertRank: number[]
+  professionalTitle: string
+  jobTitle: string
+  formerPost: string
+  teamIds: number[]
+  avatar: string
+  birthday: number | null
+  school: string
+  education: string
+  specialDirection: number[]
+  achievement: string
+  softwareSkills: number[]
+  teams?: Array<{ id: number; teamName?: string }>
+}
+
+type ExpertGroup = {
+  echelonLabel: string
+  num: number
+  sort: number
+  experts: ExpertItem[]
+}
+
+const selectedMember = ref('')
+const selectedEducation = ref('')
+const selectedTeam = ref('')
+const drawerVisible = ref(false)
+const activeExpert = ref<ExpertProfile | null>(null)
+
+const resetQueryParams = () => ({
+  deptId: undefined,
+  education: undefined,
+  echelon: undefined,
+  expertName: undefined
+})
+
+const wordCloudWords = [
+  { text: '地质', size: 24, rotate: -8, color: '#7fcd72', left: '8%', top: '52%' },
+  { text: '油藏', size: 30, rotate: 6, color: '#8ea4d8', left: '26%', top: '38%' },
+  { text: '钻井', size: 27, rotate: -10, color: '#f0df62', left: '46%', top: '22%' },
+  { text: '采油工艺', size: 22, rotate: 8, color: '#b26c41', left: '60%', top: '14%' },
+  { text: '压裂', size: 21, rotate: -16, color: '#3d79a5', left: '74%', top: '48%' },
+  { text: '酸化', size: 18, rotate: 12, color: '#f1b477', left: '14%', top: '16%' },
+  { text: '修井', size: 19, rotate: -12, color: '#d26ea8', left: '36%', top: '66%' },
+  { text: '博士', size: 17, rotate: 5, color: '#7e8d64', left: '58%', top: '64%' },
+  { text: '研究生', size: 16, rotate: -5, color: '#7f85b5', left: '79%', top: '28%' }
+]
+
+const selectedTeamNames = computed(() =>
+  selectedTeams.value.map((team) => team.teamName).join('、')
+)
+
+const teamQueryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  teamName: undefined
+})
+
+const rowStylePresets = [
+  { tableWidth: '64%', backgroundColor: '#eef6ff', columnWidths: [1.5, 1.5, 1.5, 1.5, 1.5] },
+  { tableWidth: '75%', backgroundColor: '#f2faef', columnWidths: [1.8, 1.8, 1.8, 1.8, 1.8] },
+  { tableWidth: '100%', backgroundColor: '#fff8e8', columnWidths: [2, 2, 2, 2, 2] }
+]
+const rankOptions = computed(() => getStrDictOptions(DICT_TYPE.RD_EXPERT_RANK))
+
+const getDictLabel = (type: string, value: string | number | undefined | null) => {
+  if (value === undefined || value === null || value === '') return ''
+  return getStrDictOptions(type).find((item) => String(item.value) === String(value))?.label || ''
+}
+
+const getDictLabels = (type: string, values: Array<string | number> | undefined) => {
+  if (!values?.length) return []
+  return values.map((value) => getDictLabel(type, value)).filter(Boolean)
+}
+
+const teamNameMap = computed(() => {
+  const map = new Map<number, string>()
+  ;(teamList.value || []).forEach((team) => {
+    map.set(team.id, team.teamName)
+  })
+  ;(selectedTeams.value || []).forEach((team) => {
+    map.set(team.id, team.teamName)
+  })
+  return map
+})
+
+const deptNameMap = computed(() => {
+  const map = new Map<number, string>()
+  const walk = (nodes: any[]) => {
+    nodes.forEach((node) => {
+      map.set(node.id, node.name)
+      if (node.children?.length) {
+        walk(node.children)
+      }
+    })
+  }
+  walk(deptList2.value || [])
+  return map
+})
+
+const getTeamNames = (teamIds: number[] = [], teams?: Array<{ id: number; teamName?: string }>) => {
+  if (teams?.length) {
+    return teams
+      .map((team) => team.teamName || teamNameMap.value.get(team.id))
+      .filter(Boolean) as string[]
+  }
+  return teamIds.map((id) => teamNameMap.value.get(id)).filter(Boolean) as string[]
+}
+
+const getDeptName = (deptId?: number) => {
+  if (deptId === undefined || deptId === null) return ''
+  return deptNameMap.value.get(deptId) || ''
+}
+
+const getExpertAge = (birthday: number | null) => {
+  if (!birthday) return 0
+  const today = new Date()
+  const birth = new Date(birthday)
+  let age = today.getFullYear() - birth.getFullYear()
+  const monthDiff = today.getMonth() - birth.getMonth()
+  if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
+    age--
+  }
+  return Math.max(age, 0)
+}
+
+const formatBirthday = (birthday: number | null) => {
+  if (!birthday) return ''
+  const date = new Date(birthday)
+  if (Number.isNaN(date.getTime())) return ''
+  const year = date.getFullYear()
+  const month = String(date.getMonth() + 1).padStart(2, '0')
+  const day = String(date.getDate()).padStart(2, '0')
+  return `${year}-${month}-${day}`
+}
+
+const buildExpertProfile = (expert: ExpertItem, groupLabel?: string): ExpertProfile => {
+  const rankLabels = getDictLabels(DICT_TYPE.RD_EXPERT_RANK, expert.expertRank)
+  const directionLabels = getDictLabels(DICT_TYPE.RD_EXPERT_TECHNICAL, expert.specialDirection)
+  const softwareSkillLabels = getDictLabels(DICT_TYPE.RD_EXPERT_SOFTWARE, expert.softwareSkills)
+  const teamIds = expert.teams?.length ? expert.teams.map((team) => team.id) : expert.teamIds || []
+  const teamNames = getTeamNames(teamIds, expert.teams)
+  return {
+    id: expert.id,
+    name: expert.expertName,
+    avatar: expert.avatar || '',
+    role: rankLabels.join('、') || '专家',
+    team: groupLabel || getDictLabel(DICT_TYPE.RD_EXPERT_ECHELON, expert.echelon) || '未分组',
+    company: getDeptName(expert.deptId) || '-',
+    age: getExpertAge(expert.birthday),
+    education: getDictLabel(DICT_TYPE.RD_EXPERT_EDUCATION, expert.education) || '-',
+    direction: directionLabels.length ? directionLabels : ['暂无'],
+    softwareSkills: softwareSkillLabels,
+    teamNames,
+    teamIds,
+    school: expert.school || '',
+    birthdayText: formatBirthday(expert.birthday),
+    formerPost: expert.formerPost || '',
+    jobTitle: expert.jobTitle || '',
+    professionalTitle: expert.professionalTitle || '',
+    tags: [expert.professionalTitle].filter(Boolean),
+    achievement: expert.achievement || '暂无',
+    records: []
+  }
+}
+
+const getColumnPercent = (row: TableRow, index: number) => {
+  if (!row.columnWidths || row.columnWidths.length === 0) {
+    return '0'
+  }
+  const total = row.columnWidths.reduce((sum, width) => sum + width, 0)
+  const value = row.columnWidths[index] ?? 0
+  if (total === 0) {
+    return '0'
+  }
+  return ((value / total) * 100).toFixed(4)
+}
+
+const tableData = computed<TableRow[]>(() => {
+  return (list.value as ExpertGroup[]).map((group, index) => {
+    const preset = rowStylePresets[index] || rowStylePresets[rowStylePresets.length - 1]
+    const bookmarks = rankOptions.value.map((item) => item.label)
+    const columnWidths =
+      preset.columnWidths.length === rankOptions.value.length
+        ? preset.columnWidths
+        : Array(rankOptions.value.length).fill(1)
+    const educationLabels = Array.from(
+      new Set(
+        group.experts.map((expert) => getDictLabel(DICT_TYPE.RD_EXPERT_EDUCATION, expert.education))
+      )
+    ).filter(Boolean)
+    const searchText = group.experts
+      .map((expert) =>
+        [
+          expert.expertName,
+          expert.school,
+          expert.jobTitle,
+          expert.formerPost,
+          expert.achievement
+        ].join(' ')
+      )
+      .join(' ')
+      .toLowerCase()
+    const cells: CellItem[] = rankOptions.value.map((option) => ({
+      type: 'members',
+      members: group.experts.filter((expert) =>
+        (expert.expertRank || []).some((rank) => String(rank) === String(option.value))
+      )
+    }))
+
+    return {
+      teamName: group.echelonLabel,
+      education: educationLabels.join('、') || '-',
+      searchText,
+      bookmarks,
+      tableWidth: preset.tableWidth,
+      backgroundColor: preset.backgroundColor,
+      columnWidths,
+      cells
+    }
+  })
+})
+
+const filteredTableData = computed(() => {
+  return tableData.value.filter((row) => {
+    const educationMatch = selectedEducation.value
+      ? row.education === selectedEducation.value
+      : true
+    const selectedTeamMatch = selectedTeam.value ? row.teamName === selectedTeam.value : true
+
+    if (!educationMatch || !selectedTeamMatch) {
+      return false
+    }
+    return true
+  })
+})
+
+const handleMemberClick = async (member: ExpertItem) => {
+  selectedMember.value = member.expertName
+  drawerVisible.value = true
+  drawerLoading.value = true
+  try {
+    const detail = await getExpertDetail(member.id)
+    currentExpertDetail.value = detail
+    activeExpert.value = buildExpertProfile(detail)
+  } finally {
+    drawerLoading.value = false
+  }
+}
+
+const handleWordClick = (word: string) => {
+  queryParams.expertName = word
+}
+
+const handleTeamSelectionChange = (rows: any[]) => {
+  if (syncingTeamSelection.value) return
+  const currentPageIds = teamList.value.map((item) => item.id)
+  const nextMap = { ...tempSelectedTeamMap.value }
+  currentPageIds.forEach((id) => {
+    delete nextMap[id]
+  })
+  rows.forEach((row) => {
+    nextMap[row.id] = row
+  })
+  tempSelectedTeamMap.value = nextMap
+  tempSelectedTeams.value = Object.values(nextMap)
+}
+
+const syncTeamSelection = async () => {
+  await nextTick()
+  syncingTeamSelection.value = true
+  try {
+    teamTableRef.value?.clearSelection()
+    const selectedTeamIds = new Set(Object.keys(tempSelectedTeamMap.value).map((id) => Number(id)))
+    teamList.value.forEach((row) => {
+      if (selectedTeamIds.has(row.id)) {
+        teamTableRef.value?.toggleRowSelection(row, true)
+      }
+    })
+  } finally {
+    syncingTeamSelection.value = false
+  }
+}
+
+const getTeamOptionsList = async () => {
+  teamLoading.value = true
+  try {
+    const res = await getTeamList(teamQueryParams)
+    teamList.value = res.list || []
+    teamTotal.value = res.total || 0
+    await syncTeamSelection()
+  } finally {
+    teamLoading.value = false
+  }
+}
+
+const handleTeamQuery = async () => {
+  teamQueryParams.pageNo = 1
+  await getTeamOptionsList()
+}
+
+const resetTeamQuery = async () => {
+  teamQueryParams.pageNo = 1
+  teamQueryParams.teamName = undefined
+  await getTeamOptionsList()
+}
+
+const openTeamDialog = async () => {
+  teamDialogMode.value = 'form'
+  teamDialogVisible.value = true
+  tempSelectedTeams.value = [...selectedTeams.value]
+  tempSelectedTeamMap.value = Object.fromEntries(selectedTeams.value.map((team) => [team.id, team]))
+  await getTeamOptionsList()
+}
+
+const ensureSelectedTeams = async (teamIds: number[]) => {
+  if (!teamIds?.length) {
+    selectedTeams.value = []
+    tempSelectedTeams.value = []
+    tempSelectedTeamMap.value = {}
+    return
+  }
+
+  const teamMap = new Map<number, any>()
+  ;[...selectedTeams.value, ...teamList.value].forEach((team) => {
+    teamMap.set(team.id, team)
+  })
+
+  const missingIds = teamIds.filter((id) => !teamMap.has(id))
+  if (missingIds.length) {
+    const res = await getTeamList({ pageNo: 1, pageSize: 10 })
+    ;(res.list || []).forEach((team) => {
+      teamMap.set(team.id, team)
+    })
+  }
+
+  selectedTeams.value = teamIds.map((id) => teamMap.get(id)).filter(Boolean)
+  tempSelectedTeams.value = [...selectedTeams.value]
+  tempSelectedTeamMap.value = Object.fromEntries(selectedTeams.value.map((team) => [team.id, team]))
+}
+
+const handleJoinTeam = async () => {
+  if (!activeExpert.value) return
+
+  teamDialogMode.value = 'drawer'
+  const currentTeams = currentExpertDetail.value?.teams || []
+  if (currentTeams.length) {
+    selectedTeams.value = [...currentTeams]
+    tempSelectedTeams.value = [...currentTeams]
+    tempSelectedTeamMap.value = Object.fromEntries(currentTeams.map((team) => [team.id, team]))
+  } else {
+    const currentTeamIds = activeExpert.value.teamIds || []
+    await ensureSelectedTeams(currentTeamIds)
+  }
+  teamDialogVisible.value = true
+  await getTeamOptionsList()
+}
+
+const handleEdit = () => {
+  if (!currentExpertDetail.value) return
+  isEdit.value = true
+  dialogTitle.value = '编辑专家'
+  formData.value = buildFormDataFromExpertDetail(currentExpertDetail.value)
+  dialogVisible.value = true
+}
+
+const confirmTeamSelection = async () => {
+  selectedTeams.value = [...tempSelectedTeams.value]
+  const teamIds = selectedTeams.value.map((team) => team.id)
+
+  if (teamDialogMode.value === 'drawer') {
+    if (!currentExpertDetail.value || !activeExpert.value) return
+
+    await updateExpert({
+      ...currentExpertDetail.value,
+      teams: selectedTeams.value,
+      teamIds
+    })
+
+    currentExpertDetail.value = {
+      ...currentExpertDetail.value,
+      teams: selectedTeams.value,
+      teamIds
+    }
+    activeExpert.value = buildExpertProfile(currentExpertDetail.value)
+    teamDialogVisible.value = false
+    ElMessage.success('加入团队成功')
+    await getList()
+    return
+  }
+
+  formData.value.teamIds = teamIds
+  teamDialogVisible.value = false
+}
+
+// 显示新增对话框
+const handleAdd = () => {
+  isEdit.value = false
+  dialogTitle.value = '新增专家'
+  resetForm()
+  dialogVisible.value = true
+}
+
+let loading = ref(false)
+const list = ref([]) // 列表的数据
+const queryParams = reactive({
+  deptId: undefined,
+  education: undefined,
+  echelon: undefined,
+  expertName: undefined
+})
+
+const handleReset = async () => {
+  Object.assign(queryParams, resetQueryParams())
+  await getList()
+}
+
+const submitExpertForm = async () => {
+  if (!formRef.value) return
+
+  const valid = await formRef.value.validate().catch(() => false)
+  if (!valid) return
+
+  submitLoading.value = true
+  try {
+    const payload = {
+      ...formData.value,
+      avatar:
+        typeof formData.value.avatar === 'string'
+          ? formData.value.avatar
+          : formData.value.avatar?.path || ''
+    }
+
+    if (isEdit.value) {
+      await updateExpert(payload)
+      ElMessage.success('修改成功')
+      if (payload.id) {
+        const detail = await getExpertDetail(payload.id)
+        currentExpertDetail.value = detail
+        activeExpert.value = buildExpertProfile(detail)
+      }
+    } else {
+      await createExpert(payload)
+      ElMessage.success('新增成功')
+    }
+
+    closeDialog()
+    await getList()
+  } catch (error) {
+    console.error(error)
+  } finally {
+    submitLoading.value = false
+  }
+}
+
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await getExpertList({
+      ...queryParams,
+      expertName: queryParams.expertName?.trim() || undefined
+    })
+    list.value = data
+  } finally {
+    loading.value = false
+  }
+}
+
+onMounted(async () => {
+  getList()
+
+  deptList2.value = await DeptApi.getSimpleDeptList()
+})
+</script>
+
+<style scoped lang="scss">
+.expert-step-table {
+  --ink: #182536;
+  --muted-ink: #718092;
+  --line: rgba(28, 44, 62, 0.18);
+  width: 100%;
+  max-width: none;
+  margin: 0 auto;
+  // padding: 34px;
+  overflow: hidden;
+  box-sizing: border-box;
+
+  color: var(--ink);
+}
+
+.expert-step-table el-card {
+  width: 100%;
+}
+
+.team-select-field {
+  display: flex;
+  width: 100%;
+  gap: 12px;
+}
+
+.team-select-field :deep(.el-input) {
+  flex: 1;
+}
+
+// .team-picker-table-wrap {
+//   margin-top: 8px;
+//   margin-bottom: 12px;
+// }
+
+.team-picker-pagination-wrap {
+  margin-top: 16px;
+}
+
+:deep(.team-picker-dialog .el-dialog__footer) {
+  border-top: none;
+  padding-top: 8px;
+}
+
+:deep(.team-picker-table) {
+  margin-bottom: 0;
+}
+
+:deep(.team-picker-pagination) {
+  margin-top: 0;
+}
+
+.team-picker-footer {
+  display: flex;
+  justify-content: flex-end;
+  gap: 12px;
+  width: 100%;
+  padding-top: 12px;
+}
+
+.search-layout {
+  display: grid;
+  grid-template-columns: minmax(220px, 0.34fr) minmax(0, 1fr);
+  gap: 18px;
+  margin-bottom: 15px;
+}
+
+.filter-panel,
+.word-cloud-panel {
+  min-width: 0;
+  padding: 12px;
+  border: 1px solid rgba(28, 44, 62, 0.1);
+  border-radius: 8px;
+  box-sizing: border-box;
+}
+
+.filter-panel {
+  background: linear-gradient(160deg, rgba(237, 245, 249, 0.95), rgba(247, 249, 248, 0.9)), #f4f7f8;
+}
+
+.word-cloud-panel {
+  position: relative;
+  overflow: hidden;
+  background: radial-gradient(circle at 84% 20%, rgba(177, 205, 210, 0.3), transparent 32%), #f8f7f2;
+}
+
+.word-cloud-panel::after {
+  position: absolute;
+  right: -70px;
+  bottom: -90px;
+  width: 220px;
+  height: 220px;
+  border: 1px solid rgba(161, 94, 63, 0.16);
+  border-radius: 50%;
+  content: '';
+  box-shadow:
+    0 0 0 20px rgba(161, 94, 63, 0.04),
+    0 0 0 42px rgba(161, 94, 63, 0.03);
+  pointer-events: none;
+}
+
+.panel-kicker {
+  margin-bottom: 8px;
+  color: #8a98a5;
+  font-family: 'Courier New', monospace;
+  font-size: 10px;
+  font-weight: 700;
+  letter-spacing: 0.14em;
+}
+
+.filter-panel h2,
+.word-cloud-panel h2 {
+  margin: 0;
+  color: var(--ink);
+  font-size: 20px;
+  font-weight: 700;
+  letter-spacing: -0.03em;
+}
+
+.panel-description {
+  margin: 8px 0 18px;
+  color: var(--muted-ink);
+  font-size: 12px;
+  line-height: 1.65;
+}
+
+.filter-fields {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+
+.filter-fields :deep(.el-input__wrapper),
+.filter-fields :deep(.el-select__wrapper) {
+  min-height: 38px;
+  border-radius: 7px;
+  background: rgba(255, 255, 255, 0.78);
+  box-shadow: 0 0 0 1px rgba(28, 44, 62, 0.09) inset;
+}
+
+.filter-fields :deep(.el-input__wrapper.is-focus),
+.filter-fields :deep(.el-select__wrapper.is-focused) {
+  box-shadow: 0 0 0 1px #467da4 inset;
+}
+
+.filter-status {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-top: 18px;
+  padding-top: 14px;
+  border-top: 1px dashed rgba(28, 44, 62, 0.15);
+  color: #718092;
+  font-size: 11px;
+}
+
+.status-dot {
+  width: 6px;
+  height: 6px;
+  border-radius: 50%;
+  background: #6f9878;
+  box-shadow: 0 0 0 4px rgba(111, 152, 120, 0.13);
+}
+
+.cloud-heading {
+  position: relative;
+  z-index: 1;
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 20px;
+}
+
+.cloud-hint {
+  padding-top: 18px;
+  color: #9aa6b3;
+  font-size: 11px;
+}
+
+.word-cloud {
+  position: relative;
+  z-index: 1;
+  min-height: 180px;
+  margin-top: 4px;
+  border-radius: 12px;
+  background: radial-gradient(circle at 24% 20%, rgba(255, 255, 255, 0.72), transparent 28%),
+    linear-gradient(180deg, rgba(255, 255, 255, 0.55), rgba(255, 255, 255, 0.18));
+  overflow: hidden;
+}
+
+.cloud-word {
+  position: absolute;
+  border: 0;
+  padding: 0;
+  background: transparent;
+  cursor: pointer;
+  // font-weight: 600;
+  letter-spacing: -0.03em;
+  line-height: 1;
+  white-space: nowrap;
+  transform-origin: center;
+  transition:
+    transform 180ms ease,
+    opacity 180ms ease,
+    text-shadow 180ms ease;
+}
+
+.cloud-word:hover {
+  opacity: 0.72;
+  text-shadow: 0 5px 12px rgba(49, 91, 115, 0.18);
+  transform: scale(1.06) rotate(var(--rotate, 0deg));
+}
+
+.cloud-word:focus-visible {
+  outline: 2px solid #a15e3f;
+  outline-offset: 5px;
+}
+
+.table-divider {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin: 4px 2px 14px;
+  color: var(--ink);
+  font-size: 14px;
+  font-weight: 700;
+}
+
+.result-count {
+  color: #9aa6b3;
+  font-size: 10px;
+  letter-spacing: 0.1em;
+  text-transform: uppercase;
+}
+
+.table-intro {
+  display: flex;
+  align-items: flex-end;
+  justify-content: space-between;
+  gap: 32px;
+  margin-bottom: 28px;
+  padding: 0 4px 22px;
+  border-bottom: 1px solid rgba(28, 44, 62, 0.16);
+}
+
+.eyebrow {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-bottom: 12px;
+  color: #758496;
+  font-size: 10px;
+  font-weight: 700;
+  letter-spacing: 0.18em;
+}
+
+.eyebrow-dot,
+.legend-dot {
+  display: inline-block;
+  width: 7px;
+  height: 7px;
+  border-radius: 50%;
+  background: #b26d42;
+  box-shadow: 0 0 0 4px rgba(178, 109, 66, 0.13);
+}
+
+.table-intro h1 {
+  margin: 0;
+  color: var(--ink);
+  font-size: clamp(30px, 4vw, 46px);
+  font-weight: 500;
+  letter-spacing: -0.045em;
+  line-height: 1;
+}
+
+.table-intro p {
+  max-width: 420px;
+  margin: 12px 0 0;
+  color: var(--muted-ink);
+  font-size: 13px;
+  line-height: 1.7;
+}
+
+.intro-meta {
+  display: flex;
+  align-items: center;
+  gap: 20px;
+  padding-bottom: 4px;
+  color: #647285;
+  font-size: 11px;
+  font-weight: 700;
+  letter-spacing: 0.08em;
+}
+
+.legend-item {
+  display: inline-flex;
+  align-items: center;
+  gap: 9px;
+  letter-spacing: 0;
+}
+
+.legend-dot {
+  width: 6px;
+  height: 6px;
+  background: #467da4;
+  box-shadow: 0 0 0 4px rgba(70, 125, 164, 0.13);
+}
+
+.tier-count {
+  color: #9aa6b3;
+}
+
+.table-list {
+  width: 100%;
+  overflow: hidden;
+}
+
+.ladder-row {
+  display: flex;
+  width: 100%;
+  max-width: 100%;
+  min-width: 0;
+  min-height: 78px;
+
+  transform: translateX(0);
+  animation: row-in 0.55s both;
+  transition:
+    transform 220ms ease,
+    filter 220ms ease;
+}
+
+.ladder-row:nth-child(2) {
+  animation-delay: 80ms;
+}
+
+.ladder-row:nth-child(3) {
+  animation-delay: 160ms;
+}
+
+.ladder-row:hover {
+  transform: translateX(5px);
+  filter: saturate(1.04);
+}
+
+.ladder-row + .ladder-row {
+  margin-top: -1px;
+}
+
+.team-cell {
+  display: flex;
+  flex: 0 0 104px;
+  align-items: center;
+  justify-content: flex-start;
+  gap: 10px;
+  min-height: 78px;
+  padding: 12px 14px;
+  border: 1px solid var(--line);
+  box-sizing: border-box;
+  background-color: var(--row-bg);
+  color: var(--ink);
+  font-size: 13px;
+  font-weight: 700;
+}
+
+.team-index {
+  color: rgba(24, 37, 54, 0.38);
+  font-size: 10px;
+  font-weight: 700;
+  letter-spacing: 0.08em;
+}
+
+.team-name {
+  white-space: nowrap;
+}
+
+.content-table {
+  flex: 1 1 auto;
+  width: 0;
+  min-width: 0;
+  table-layout: fixed;
+  border-collapse: collapse;
+  border-spacing: 0;
+  background: transparent;
+}
+
+.content-table td {
+  height: 78px;
+  padding: 10px 14px 10px 26px;
+  border: 1px solid var(--line);
+  box-sizing: border-box;
+  background-color: var(--row-bg);
+}
+
+.step-cell {
+  position: relative;
+  text-align: center;
+  vertical-align: middle;
+  overflow-wrap: anywhere;
+  word-break: break-all;
+}
+
+.cell-bookmark {
+  position: absolute;
+  top: 10px;
+  left: -1px;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  min-width: 18px;
+  height: 48px;
+  padding: 4px 2px;
+  border: 1px solid rgba(70, 125, 164, 0.28);
+  border-left: 0;
+  border-radius: 0 6px 6px 0;
+  background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(230, 238, 244, 0.92));
+  box-shadow: 0 6px 14px rgba(24, 37, 54, 0.08);
+  color: #47657e;
+  font-size: 10px;
+  font-weight: 700;
+  letter-spacing: 0.08em;
+  line-height: 1.1;
+  writing-mode: vertical-rl;
+  text-orientation: upright;
+}
+
+.notice-cell {
+  background-image: linear-gradient(135deg, rgba(178, 109, 66, 0.09), transparent 60%);
+}
+
+.member-link {
+  display: inline-flex;
+  align-items: center;
+  margin: 3px 2px;
+  padding: 4px 7px;
+  border: 1px solid rgba(70, 125, 164, 0.24);
+  border-radius: 4px;
+  background: rgba(255, 255, 255, 0.42);
+  color: #356783;
+  cursor: pointer;
+  font-size: 12px;
+  font-weight: 600;
+  line-height: 1.2;
+  transition:
+    transform 180ms ease,
+    border-color 180ms ease,
+    background-color 180ms ease,
+    color 180ms ease;
+}
+
+.member-link:hover {
+  border-color: #467da4;
+  background: #467da4;
+  color: #fff;
+  transform: translateY(-2px);
+}
+
+.member-link:focus-visible {
+  outline: 2px solid #b26d42;
+  outline-offset: 2px;
+}
+
+.cell-text {
+  color: #687789;
+  font-size: 12px;
+  line-height: 1.65;
+}
+
+.is-danger {
+  color: #a95f3c;
+  font-weight: 600;
+}
+
+.member-detail {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  margin-top: 22px;
+  padding: 12px 16px;
+  border: 1px solid rgba(70, 125, 164, 0.2);
+  border-left: 3px solid #467da4;
+  background: rgba(255, 255, 255, 0.62);
+  color: #637285;
+  font-size: 12px;
+}
+
+.member-detail strong {
+  color: var(--ink);
+  font-size: 14px;
+}
+
+.detail-label {
+  color: #467da4;
+  font-size: 10px;
+  font-weight: 700;
+  letter-spacing: 0.12em;
+}
+
+.detail-copy {
+  color: #8b97a5;
+}
+
+@keyframes row-in {
+  from {
+    opacity: 0;
+    transform: translateY(10px);
+  }
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+
+@media (max-width: 700px) {
+  .expert-step-table {
+    padding: 22px 14px;
+  }
+
+  .table-intro {
+    align-items: flex-start;
+    flex-direction: column;
+    gap: 18px;
+  }
+
+  .search-layout {
+    grid-template-columns: 1fr;
+  }
+
+  .word-cloud {
+    min-height: 160px;
+  }
+
+  .intro-meta {
+    width: 100%;
+    justify-content: space-between;
+  }
+
+  .team-cell {
+    flex-basis: 86px;
+    padding: 10px;
+  }
+
+  .content-table td {
+    padding: 8px 6px 8px 22px;
+  }
+
+  .member-link {
+    padding: 4px 5px;
+    font-size: 11px;
+  }
+
+  .cell-bookmark {
+    top: 8px;
+    min-width: 16px;
+    height: 42px;
+    font-size: 9px;
+  }
+
+  .member-detail {
+    flex-wrap: wrap;
+    gap: 8px;
+  }
+}
+
+:deep(.expert-drawer) {
+  --el-drawer-padding-primary: 0;
+}
+
+:deep(.expert-drawer.el-drawer.rtl) {
+  overflow: hidden;
+}
+
+:deep(.el-drawer__wrapper) {
+  overflow: hidden !important;
+}
+
+:deep(.el-overlay) {
+  overflow: hidden !important;
+}
+
+:deep(.expert-drawer .el-drawer__body) {
+  padding: 0;
+  background: #eef2ef;
+  overflow-x: hidden !important;
+}
+
+.drawer-shell {
+  min-height: 100%;
+  background: #eef2ef;
+  overflow-x: hidden !important;
+}
+
+.drawer-header {
+  padding: 16px 22px 14px;
+  border-bottom: 1px solid rgba(24, 37, 54, 0.08);
+  background: #fff;
+}
+
+.drawer-header h2 {
+  margin: 0;
+  color: #102234;
+  font-size: 18px;
+  font-weight: 800;
+}
+
+.drawer-header p {
+  margin: 6px 0 0;
+  color: #6f7f8e;
+  font-size: 13px;
+  font-weight: 600;
+}
+
+.drawer-profile {
+  display: grid;
+  grid-template-columns: 150px minmax(0, 1fr);
+  gap: 16px;
+  margin: 18px;
+  padding: 16px;
+  border: 1px solid rgba(28, 44, 62, 0.08);
+  border-radius: 14px;
+  background: #fff;
+  box-shadow: 0 8px 30px rgba(28, 44, 62, 0.06);
+}
+
+.profile-aside {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+
+.profile-avatar {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  min-height: 150px;
+  max-height: 150px;
+  border-radius: 14px;
+  background: linear-gradient(145deg, #b7d3cd, #e8f0ee);
+  color: #0f5b58;
+  font-size: 46px;
+  font-weight: 800;
+  letter-spacing: -0.08em;
+  overflow: hidden;
+}
+
+.profile-avatar img {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+  display: block;
+}
+
+.profile-tags {
+  display: flex;
+  gap: 8px;
+  flex-wrap: wrap;
+}
+
+.profile-tag {
+  display: inline-flex;
+  align-items: center;
+  padding: 6px 10px;
+  border-radius: 999px;
+  background: #f1f4f2;
+  color: #6e7d7a;
+  font-size: 12px;
+  font-weight: 700;
+}
+
+.profile-main {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+
+.info-block {
+  padding: 12px 14px;
+  border: 1px solid #dbe3df;
+  border-radius: 12px;
+  background: #fdfefe;
+}
+
+.info-label {
+  display: block;
+  margin-bottom: 5px;
+  color: #7a8a89;
+  font-size: 12px;
+  // font-weight: 700;
+}
+
+.info-block strong {
+  color: #203130;
+  font-size: 13px;
+  font-weight: 800;
+  line-height: 1.55;
+}
+
+.profile-actions {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: 10px;
+}
+
+.profile-actions :deep(.el-button) {
+  height: 42px;
+  border-radius: 10px;
+  font-size: 14px;
+  font-weight: 700;
+}
+
+.profile-actions :deep(.el-button--success) {
+  background: #237b63;
+  border-color: #237b63;
+}
+
+.record-panel {
+  margin: 0 26px 26px;
+  border: 1px solid rgba(28, 44, 62, 0.08);
+  border-radius: 16px;
+  overflow: hidden;
+  background: #fff;
+  box-shadow: 0 8px 30px rgba(28, 44, 62, 0.05);
+}
+
+.record-header {
+  padding: 22px 24px 18px;
+  border-bottom: 1px solid rgba(28, 44, 62, 0.08);
+}
+
+.record-header h3 {
+  margin: 0;
+  color: #1e2d3a;
+  font-size: 18px;
+  font-weight: 800;
+}
+
+.record-header p {
+  margin: 6px 0 0;
+  color: #8a97a3;
+  font-size: 13px;
+}
+
+.record-table :deep(.el-table__header th) {
+  background: #f7faf8;
+  color: #556574;
+  font-weight: 700;
+}
+
+.record-table :deep(.el-table__inner-wrapper) {
+  overflow-x: hidden;
+}
+
+.record-table :deep(.el-table__row td) {
+  padding-top: 14px;
+  padding-bottom: 14px;
+}
+
+.record-badge {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  min-width: 88px;
+  padding: 7px 12px;
+  border-radius: 999px;
+  font-size: 13px;
+  font-weight: 700;
+}
+
+.record-badge.success {
+  background: #e4f4ec;
+  color: #1f8a61;
+}
+
+.record-badge.primary {
+  background: #e8f0fb;
+  color: #3d6ea8;
+}
+
+@media (max-width: 900px) {
+  .drawer-profile {
+    grid-template-columns: 1fr;
+  }
+
+  .profile-avatar {
+    min-height: 180px;
+    font-size: 64px;
+  }
+
+  .profile-actions {
+    grid-template-columns: 1fr;
+  }
+}
+
+.team-logo {
+  width: 52px;
+  height: 52px;
+  border-radius: 8px;
+  border: 1px solid #e5e7eb;
+}
+</style>

+ 444 - 0
src/views/researchProject/breakdown/index.vue

@@ -0,0 +1,444 @@
+<template>
+  <ContentWrap>
+    <!-- <div class="page-heading">
+      <div>
+        <h2>项目分解</h2>
+        <p>项目生效后,项目责任人可将项目拆解为多个子任务,每个子任务最多分解三个孙子任务。</p>
+      </div>
+      <el-tag type="success" effect="light"><Icon icon="ep:circle-check" /> 项目已生效</el-tag>
+    </div> -->
+
+    <el-form ref="queryFormRef" :model="queryParams" inline class="project-filter">
+      <el-form-item label="项目名称" prop="projectName">
+        <el-input
+          v-model="queryParams.projectName"
+          clearable
+          placeholder="请输入项目名称"
+          class="!w-240px"
+          @keyup.enter="handleQuery"
+        />
+      </el-form-item>
+      <el-form-item label="项目负责人" prop="projectLeader">
+        <el-input
+          v-model="queryParams.projectLeader"
+          clearable
+          placeholder="请输入负责人"
+          class="!w-200px"
+          @keyup.enter="handleQuery"
+        />
+      </el-form-item>
+      <el-form-item>
+        <el-button type="primary" @click="handleQuery"><Icon icon="ep:search" /> 搜索</el-button>
+        <el-button @click="resetQuery"><Icon icon="ep:refresh" /> 重置</el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <ContentWrap>
+    <zm-table
+      v-loading="loading"
+      :data="filteredProjects"
+      row-key="id"
+      highlight-current-row
+      class="project-table"
+      @current-change="selectProject"
+    >
+      <zm-table-column label="项目名称" prop="projectName" min-width="280" show-overflow-tooltip />
+      <zm-table-column label="项目负责人" prop="projectLeader" width="140" />
+      <zm-table-column label="项目类型" prop="projectType" min-width="180" />
+      <zm-table-column label="生效日期" prop="effectiveDate" width="130" />
+      <zm-table-column label="任务数" width="90" align="center"
+        ><template #default="{ row }">{{ countTasks(row.tasks) }}</template></zm-table-column
+      >
+      <zm-table-column label="操作" width="120" fixed="right" align="center"
+        ><template #default="{ row }"
+          ><zm-button link type="primary" @click="selectProject(row)">分解任务</zm-button></template
+        ></zm-table-column
+      >
+    </zm-table>
+    <Pagination
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      :total="filteredProjects.length"
+    />
+  </ContentWrap>
+
+  <ContentWrap v-if="selectedProject" class="task-panel">
+    <div class="task-heading">
+      <div>
+        <div class="task-title-row"
+          ><h3>{{ selectedProject.projectName }}</h3
+          ><el-tag type="success" size="small">已生效</el-tag></div
+        >
+        <span
+          >项目负责人:{{ selectedProject.projectLeader }} · 已分解
+          {{ countTasks(selectedProject.tasks) }} 个任务</span
+        >
+      </div>
+      <el-button type="primary" @click="openTaskForm()"
+        ><Icon icon="ep:plus" /> 新增一级任务</el-button
+      >
+    </div>
+
+    <el-empty v-if="!selectedProject.tasks.length" description="暂未分解任务"
+      ><el-button type="primary" @click="openTaskForm()"
+        ><Icon icon="ep:plus" /> 新增一级任务</el-button
+      ></el-empty
+    >
+    <zm-table v-else :data="selectedProject.tasks" row-key="id" default-expand-all border>
+      <zm-table-column label="任务名称" min-width="250" tree-node>
+        <template #default="{ row }"
+          ><div class="task-name"
+            ><span>{{ row.name }}</span
+            ><el-tag v-if="row.level === 3" type="info" effect="plain" size="small"
+              >末级</el-tag
+            ></div
+          ></template
+        >
+      </zm-table-column>
+      <zm-table-column label="开始时间" prop="startDate" width="130" />
+      <zm-table-column label="结束时间" prop="endDate" width="130" />
+      <zm-table-column label="责任人" prop="owner" width="120" />
+      <zm-table-column label="优先级" width="100" align="center"
+        ><template #default="{ row }"
+          ><el-tag :type="priorityTagType(row.priority)">{{ row.priority }}</el-tag></template
+        ></zm-table-column
+      >
+      <zm-table-column label="任务描述" prop="description" min-width="220" show-overflow-tooltip />
+      <zm-table-column label="操作" width="220" fixed="right" align="center">
+        <template #default="{ row }">
+          <el-button
+            v-if="row.level === 1"
+            link
+            type="primary"
+            :disabled="row.children.length >= MAX_CHILDREN"
+            @click="openTaskForm(row)"
+            >新增子任务</el-button
+          >
+          <el-button v-else link type="info" disabled>不可再分解</el-button>
+          <el-button link type="primary" @click="openTaskForm(row, true)">编辑</el-button>
+          <el-button link type="danger" @click="removeTask(row)">删除</el-button>
+        </template>
+      </zm-table-column>
+    </zm-table>
+  </ContentWrap>
+
+  <el-dialog v-model="taskDialogVisible" :title="taskDialogTitle" width="720px" destroy-on-close>
+    <el-alert v-if="parentTask" type="info" :closable="false" class="parent-alert"
+      >当前父任务:{{ parentTask.name }},最多可添加 {{ MAX_CHILDREN }} 个孙子任务(已添加
+      {{ parentTask.children.length }} 个)</el-alert
+    >
+    <el-form ref="taskFormRef" :model="taskForm" :rules="taskRules" label-width="92px">
+      <el-form-item label="任务名称" prop="name"
+        ><el-input
+          v-model="taskForm.name"
+          maxlength="100"
+          show-word-limit
+          placeholder="请输入任务名称"
+      /></el-form-item>
+      <el-row :gutter="18">
+        <el-col :span="12"
+          ><el-form-item label="开始时间" prop="startDate"
+            ><el-date-picker
+              v-model="taskForm.startDate"
+              type="date"
+              value-format="YYYY-MM-DD"
+              placeholder="请选择开始时间"
+              class="w-full" /></el-form-item
+        ></el-col>
+        <el-col :span="12"
+          ><el-form-item label="结束时间" prop="endDate"
+            ><el-date-picker
+              v-model="taskForm.endDate"
+              type="date"
+              value-format="YYYY-MM-DD"
+              placeholder="请选择结束时间"
+              class="w-full" /></el-form-item
+        ></el-col>
+      </el-row>
+      <el-row :gutter="18">
+        <el-col :span="12"
+          ><el-form-item label="责任人" prop="owner"
+            ><el-input v-model="taskForm.owner" placeholder="请输入责任人" /></el-form-item
+        ></el-col>
+        <el-col :span="12"
+          ><el-form-item label="任务优先级" prop="priority"
+            ><el-radio-group v-model="taskForm.priority"
+              ><el-radio-button v-for="item in priorities" :key="item" :label="item">{{
+                item
+              }}</el-radio-button></el-radio-group
+            ></el-form-item
+          ></el-col
+        >
+      </el-row>
+      <el-form-item label="任务描述" prop="description"
+        ><el-input
+          v-model="taskForm.description"
+          type="textarea"
+          :rows="4"
+          maxlength="500"
+          show-word-limit
+          placeholder="请输入任务描述"
+      /></el-form-item>
+    </el-form>
+    <template #footer
+      ><el-button @click="taskDialogVisible = false">取消</el-button
+      ><el-button type="primary" :loading="submitLoading" @click="submitTask"
+        >保存任务</el-button
+      ></template
+    >
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+import { computed, reactive, ref } from 'vue'
+import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
+
+defineOptions({ name: 'ResearchProjectBreakdown' })
+const MAX_CHILDREN = 3
+const priorities = ['高', '中', '低'] as const
+type Priority = (typeof priorities)[number]
+type Task = {
+  id: number
+  name: string
+  startDate: string
+  endDate: string
+  owner: string
+  description: string
+  priority: Priority
+  level: number
+  children: Task[]
+}
+type Project = {
+  id: number
+  projectName: string
+  projectLeader: string
+  projectType: string
+  effectiveDate: string
+  tasks: Task[]
+}
+
+const createTask = (level = 1, data: Partial<Task> = {}): Task => ({
+  id: Date.now() + Math.floor(Math.random() * 1000),
+  name: '',
+  startDate: '',
+  endDate: '',
+  owner: '',
+  description: '',
+  priority: '中',
+  level,
+  children: [],
+  ...data
+})
+const projects = ref<Project[]>([
+  {
+    id: 1,
+    projectName: '高温高压井下工具可靠性研究',
+    projectLeader: '张明远',
+    projectType: '产品、工具和设备研发类',
+    effectiveDate: '2026-03-01',
+    tasks: [
+      createTask(1, {
+        id: 101,
+        name: '完成高温高压工况分析',
+        startDate: '2026-03-05',
+        endDate: '2026-04-10',
+        owner: '李强',
+        description: '梳理典型工况并输出分析报告。',
+        priority: '高',
+        children: [
+          createTask(2, {
+            id: 102,
+            name: '收集现场运行数据',
+            startDate: '2026-03-05',
+            endDate: '2026-03-20',
+            owner: '王璐',
+            description: '整理近三年现场运行数据。',
+            priority: '中',
+            children: []
+          })
+        ]
+      })
+    ]
+  },
+  {
+    id: 2,
+    projectName: '智能钻井参数优化方法研究',
+    projectLeader: '赵海宁',
+    projectType: '基础研究类',
+    effectiveDate: '2026-04-15',
+    tasks: []
+  }
+])
+const loading = ref(false)
+const queryFormRef = ref<FormInstance>()
+const queryParams = reactive({ pageNo: 1, pageSize: 10, projectName: '', projectLeader: '' })
+const filteredProjects = computed(() =>
+  projects.value.filter(
+    (project) =>
+      (!queryParams.projectName || project.projectName.includes(queryParams.projectName)) &&
+      (!queryParams.projectLeader || project.projectLeader.includes(queryParams.projectLeader))
+  )
+)
+const selectedProject = ref<Project | undefined>(projects.value[0])
+const taskDialogVisible = ref(false)
+const taskDialogTitle = ref('新增一级任务')
+const taskFormRef = ref<FormInstance>()
+const submitLoading = ref(false)
+const editingTask = ref<Task>()
+const parentTask = ref<Task>()
+const taskForm = reactive(createTask())
+const taskRules: FormRules = {
+  name: [{ required: true, message: '请输入任务名称', trigger: 'blur' }],
+  startDate: [{ required: true, message: '请选择开始时间', trigger: 'change' }],
+  endDate: [{ required: true, message: '请选择结束时间', trigger: 'change' }],
+  owner: [{ required: true, message: '请输入责任人', trigger: 'blur' }],
+  priority: [{ required: true, message: '请选择任务优先级', trigger: 'change' }],
+  description: [{ required: true, message: '请输入任务描述', trigger: 'blur' }]
+}
+
+const countTasks = (tasks: Task[]): number =>
+  tasks.reduce((total, task) => total + 1 + countTasks(task.children), 0)
+const handleQuery = () => (queryParams.pageNo = 1)
+const resetQuery = () => {
+  queryFormRef.value?.resetFields()
+  handleQuery()
+}
+const selectProject = (project?: Project) => {
+  if (project) selectedProject.value = project
+}
+const priorityTagType = (priority: Priority): 'danger' | 'warning' | 'info' =>
+  (({ 高: 'danger', 中: 'warning', 低: 'info' }) as const)[priority]
+const resetTaskForm = (task: Task) => {
+  Object.assign(taskForm, task, { children: [] })
+}
+const openTaskForm = (task?: Task, editing = false) => {
+  if (!selectedProject.value) return
+  if (!editing && task && (task.level !== 1 || task.children.length >= MAX_CHILDREN)) {
+    ElMessage.warning(
+      task.level !== 1 ? '孙子任务不能继续分解' : `每个子任务最多添加 ${MAX_CHILDREN} 个孙子任务`
+    )
+    return
+  }
+  editingTask.value = editing ? task : undefined
+  parentTask.value = !editing && task ? task : undefined
+  taskDialogTitle.value = editing ? '编辑任务' : task ? '新增孙子任务' : '新增一级任务'
+  resetTaskForm(editing && task ? task : createTask(task ? 2 : 1))
+  taskDialogVisible.value = true
+}
+const submitTask = async () => {
+  const valid = await taskFormRef.value?.validate().catch(() => false)
+  if (!valid) return
+  if (!taskForm.startDate || !taskForm.endDate || taskForm.startDate > taskForm.endDate) {
+    ElMessage.warning('结束时间不能早于开始时间')
+    return
+  }
+  if (!selectedProject.value) return
+  submitLoading.value = true
+  if (editingTask.value) Object.assign(editingTask.value, taskForm)
+  else {
+    if (
+      parentTask.value &&
+      (parentTask.value.level !== 1 || parentTask.value.children.length >= MAX_CHILDREN)
+    ) {
+      submitLoading.value = false
+      ElMessage.warning(
+        parentTask.value.level !== 1
+          ? '孙子任务不能继续分解'
+          : `每个子任务最多添加 ${MAX_CHILDREN} 个孙子任务`
+      )
+      return
+    }
+    const task = createTask(taskForm.level, { ...taskForm, id: Date.now() })
+    if (parentTask.value) parentTask.value.children.push(task)
+    else selectedProject.value.tasks.push(task)
+  }
+  submitLoading.value = false
+  taskDialogVisible.value = false
+  ElMessage.success(editingTask.value ? '任务已更新' : '任务已添加')
+}
+const removeTask = async (task: Task) => {
+  if (!selectedProject.value) return
+  const confirmed = await ElMessageBox.confirm(
+    task.children.length
+      ? '删除任务将同时删除其全部下级任务,确定继续吗?'
+      : '确定删除这个任务吗?',
+    '删除确认',
+    { type: 'warning', confirmButtonText: '确定删除', cancelButtonText: '取消' }
+  )
+    .then(() => true)
+    .catch(() => false)
+  if (!confirmed) return
+  const remove = (tasks: Task[]): boolean => {
+    const index = tasks.findIndex((item) => item.id === task.id)
+    if (index >= 0) {
+      tasks.splice(index, 1)
+      return true
+    }
+    return tasks.some((item) => remove(item.children))
+  }
+  remove(selectedProject.value.tasks)
+  ElMessage.success('任务已删除')
+}
+</script>
+
+<style scoped lang="scss">
+.page-heading,
+.task-heading,
+.task-title-row {
+  display: flex;
+  align-items: center;
+}
+.page-heading,
+.task-heading {
+  justify-content: space-between;
+}
+.page-heading {
+  margin-bottom: 20px;
+}
+.page-heading h2,
+.task-heading h3 {
+  margin: 0;
+  color: var(--el-text-color-primary);
+}
+.page-heading p,
+.task-heading span {
+  margin: 6px 0 0;
+  color: var(--el-text-color-secondary);
+  font-size: 13px;
+}
+.page-heading :deep(.el-tag .iconify) {
+  margin-right: 4px;
+}
+.project-filter {
+  margin-bottom: -15px;
+}
+.task-panel {
+  margin-top: 16px;
+}
+.task-heading {
+  margin-bottom: 18px;
+}
+.task-title-row {
+  gap: 10px;
+}
+.task-title-row h3 {
+  font-size: 18px;
+}
+.task-name {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+.parent-alert {
+  margin-bottom: 18px;
+}
+@media (max-width: 900px) {
+  .page-heading,
+  .task-heading {
+    align-items: flex-start;
+    flex-direction: column;
+    gap: 14px;
+  }
+}
+</style>

+ 5 - 0
src/views/researchProject/list/edit.vue

@@ -0,0 +1,5 @@
+<template>
+  <div>编辑页面</div>
+</template>
+
+<script setup lang="ts"></script>

+ 482 - 0
src/views/researchProject/list/index.vue

@@ -0,0 +1,482 @@
+<template>
+  <ContentWrap>
+    <el-form ref="queryFormRef" :model="queryParams" inline class="project-filter">
+      <el-form-item label="项目名称" prop="projectName">
+        <el-input
+          v-model="queryParams.projectName"
+          clearable
+          placeholder="请输入项目名称"
+          class="!w-220px"
+          @keyup.enter="handleQuery"
+        />
+      </el-form-item>
+      <el-form-item label="项目类型" prop="projectType">
+        <el-select
+          v-model="queryParams.projectType"
+          clearable
+          placeholder="请选择项目类型"
+          class="!w-220px"
+        >
+          <el-option v-for="item in projectTypes" :key="item" :label="item" :value="item" />
+        </el-select>
+      </el-form-item>
+      <el-form-item>
+        <el-button type="primary" @click="handleQuery"
+          ><Icon icon="ep:search" class="mr-5px" />搜索</el-button
+        >
+        <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" />重置</el-button>
+        <el-button type="primary" plain @click="openForm()"
+          ><Icon icon="ep:plus" class="mr-5px" />新增项目</el-button
+        >
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <ContentWrap>
+    <div class="project-table-heading">
+      <div><h2>科研项目</h2><span>维护项目申报信息、预算及阶段性计划</span></div>
+      <el-tag type="info" effect="plain">共 {{ filteredList.length }} 个项目</el-tag>
+    </div>
+    <zm-table v-loading="loading" :data="filteredList" row-key="id">
+      <zm-table-column label="项目名称" prop="projectName" min-width="250" show-overflow-tooltip />
+      <zm-table-column label="项目级别" prop="projectLevel" min-width="180" />
+      <zm-table-column
+        label="执行单位"
+        prop="executingUnit"
+        min-width="210"
+        show-overflow-tooltip
+      />
+      <zm-table-column label="项目负责人" prop="projectLeader" width="130" />
+      <zm-table-column label="项目类型" prop="projectType" min-width="180" />
+      <zm-table-column label="状态" width="110" align="center"
+        ><template #default="{ row }"
+          ><el-tag :type="row.status === '进行中' ? 'success' : 'info'">{{
+            row.status
+          }}</el-tag></template
+        ></zm-table-column
+      >
+      <zm-table-column label="操作" width="240" fixed="right" align="center"
+        ><template #default="{ row }">
+          <el-button link type="primary" @click="openForm(row)">提交</el-button>
+          <el-button link type="primary" @click="openForm(row)">修改申请</el-button>
+          <el-button link type="primary" @click="openForm(row)">编辑</el-button
+          ><el-button link type="danger" @click="removeProject(row.id)">删除</el-button></template
+        ></zm-table-column
+      >
+    </zm-table>
+    <Pagination
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      :total="filteredList.length"
+    />
+  </ContentWrap>
+
+  <ResearchProjectDialog
+    v-model="dialogVisible"
+    :title="dialogTitle"
+    width="100%"
+    :fullscreen="true"
+    destroy-on-close
+  >
+    <el-form
+      ref="formRef"
+      v-loading="formLoading"
+      :model="formData"
+      :rules="formRules"
+      label-width="112px"
+      class="project-form"
+    >
+      <el-divider content-position="left">项目基本信息</el-divider>
+      <el-row :gutter="24">
+        <el-col :span="12"
+          ><el-form-item label="项目级别" prop="projectLevel"
+            ><el-select v-model="formData.projectLevel" class="w-full" placeholder="请选择项目级别"
+              ><el-option
+                v-for="item in projectLevels"
+                :key="item"
+                :label="item"
+                :value="item" /></el-select></el-form-item
+        ></el-col>
+        <el-col :span="12"
+          ><el-form-item label="执行单位" prop="executingUnit"
+            ><el-input
+              v-model="formData.executingUnit"
+              placeholder="请输入执行单位" /></el-form-item
+        ></el-col>
+        <el-col :span="12"
+          ><el-form-item label="项目名称" prop="projectName"
+            ><el-input v-model="formData.projectName" placeholder="请输入项目名称" /></el-form-item
+        ></el-col>
+        <el-col :span="12"
+          ><el-form-item label="项目负责人" prop="projectLeader"
+            ><el-input
+              v-model="formData.projectLeader"
+              placeholder="请输入项目负责人" /></el-form-item
+        ></el-col>
+        <el-col :span="12"
+          ><el-form-item label="项目组成员"
+            ><el-input
+              v-model="formData.projectMembers"
+              placeholder="请输入成员姓名,多个成员用逗号分隔" /></el-form-item
+        ></el-col>
+        <el-col :span="12"
+          ><el-form-item label="项目类型" prop="projectType"
+            ><el-select v-model="formData.projectType" class="w-full" placeholder="请选择项目类型"
+              ><el-option
+                v-for="item in projectTypes"
+                :key="item"
+                :label="item"
+                :value="item" /></el-select></el-form-item
+        ></el-col>
+      </el-row>
+      <el-divider content-position="left">项目内容</el-divider>
+      <el-form-item label="目标和意义" prop="purpose"
+        ><el-input
+          v-model="formData.purpose"
+          type="textarea"
+          :rows="3"
+          maxlength="500"
+          show-word-limit
+          placeholder="请输入项目目标和意义"
+      /></el-form-item>
+      <el-form-item label="主要研究内容" prop="researchContent"
+        ><el-input
+          v-model="formData.researchContent"
+          type="textarea"
+          :rows="4"
+          maxlength="2000"
+          show-word-limit
+          placeholder="请输入主要研究内容"
+      /></el-form-item>
+      <el-form-item label="创新点" prop="innovation"
+        ><el-input
+          v-model="formData.innovation"
+          type="textarea"
+          :rows="3"
+          maxlength="500"
+          show-word-limit
+          placeholder="请输入项目创新点"
+      /></el-form-item>
+      <el-form-item label="进度安排" prop="schedule">
+        <div class="schedule-editor">
+          <div class="schedule-toolbar"
+            ><span>按阶段填写工作计划与截止时间</span
+            ><el-button type="primary" plain size="small" @click="addSchedule"
+              ><Icon icon="ep:plus" class="mr-5px" />增加工作</el-button
+            ></div
+          >
+          <el-table :data="formData.schedule" border class="schedule-table">
+            <el-table-column type="index" label="#" width="56" align="center" />
+            <el-table-column label="工作计划" min-width="300"
+              ><template #default="{ row }"
+                ><el-input v-model="row.workPlan" placeholder="请输入工作计划" /></template
+            ></el-table-column>
+            <el-table-column label="截止时间" width="190"
+              ><template #default="{ row }"
+                ><el-date-picker
+                  v-model="row.deadline"
+                  type="date"
+                  value-format="YYYY-MM-DD"
+                  placeholder="选择日期"
+                  class="w-full" /></template
+            ></el-table-column>
+            <el-table-column label="操作" width="90" align="center"
+              ><template #default="{ $index }"
+                ><el-button link type="danger" @click="removeSchedule($index)"
+                  ><Icon icon="ep:delete" /></el-button></template
+            ></el-table-column>
+          </el-table>
+        </div>
+      </el-form-item>
+      <el-divider content-position="left">费用预算(元)</el-divider>
+      <el-row :gutter="18" class="budget-grid"
+        ><el-col v-for="item in budgetItems" :key="item.key" :span="6"
+          ><el-form-item :label="item.label"
+            ><el-input-number
+              v-model="formData.budget[item.key]"
+              :min="0"
+              :precision="2"
+              controls-position="right"
+              class="w-full" /></el-form-item></el-col
+      ></el-row>
+      <div class="budget-total"
+        ><span>预算合计</span><strong>{{ budgetTotal.toFixed(2) }}</strong
+        ><span>元</span></div
+      >
+      <el-divider content-position="left">附件上传</el-divider>
+      <el-form-item label="立项申请表" required
+        ><UploadFile
+          v-model="formData.attachments.application"
+          :return-object="true"
+          :auto-upload="false"
+          :limit="1"
+          :file-type="['doc', 'docx', 'pdf']"
+      /></el-form-item>
+      <el-form-item label="开题报告" required
+        ><UploadFile
+          v-model="formData.attachments.report"
+          :return-object="true"
+          :auto-upload="false"
+          :limit="1"
+          :file-type="['doc', 'docx', 'pdf']"
+      /></el-form-item>
+      <el-form-item label="其他文件"
+        ><UploadFile
+          v-model="formData.attachments.others"
+          :return-object="true"
+          :auto-upload="false"
+          :limit="3"
+          :file-type="['doc', 'docx', 'pdf', 'xls', 'xlsx', 'jpg', 'png']"
+      /></el-form-item>
+    </el-form>
+    <template #footer>
+      <div
+        style="
+          position: fixed;
+          bottom: 0;
+          left: 0;
+          right: 0;
+          background: #fff;
+          padding: 10px;
+          z-index: 100;
+          /* border-top: 1px solid #ccc; */
+          box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
+        "
+      >
+        <el-button @click="dialogVisible = false">取消</el-button
+        ><el-button type="primary" :loading="submitLoading" @click="submitForm">保存项目</el-button>
+      </div>
+    </template>
+  </ResearchProjectDialog>
+</template>
+
+<script setup lang="ts">
+import { computed, reactive, ref } from 'vue'
+import { ElMessage, ElMessageBox, type FormRules } from 'element-plus'
+
+defineOptions({ name: 'ResearchProjectList' })
+type ScheduleItem = { workPlan: string; deadline: string }
+type Project = {
+  id: number
+  projectLevel: string
+  executingUnit: string
+  projectName: string
+  projectLeader: string
+  projectMembers: string
+  projectType: string
+  purpose: string
+  researchContent: string
+  innovation: string
+  schedule: ScheduleItem[]
+  budget: Record<string, number>
+  attachments: { application: any[]; report: any[]; others: any[] }
+  status: string
+}
+const projectLevels = ['专业公司级/石油技术公司级', '集团公司级', '国家级']
+const projectTypes = [
+  '基础研究类',
+  '原理方法类',
+  '产品、工具和设备研发类',
+  '成熟工艺技术配套及推广类',
+  '情报调研类'
+]
+const budgetItems = [
+  { key: 'labor', label: '人工' },
+  { key: 'materials', label: '直接材料投入' },
+  { key: 'supplies', label: '劳保用品' },
+  { key: 'fuel', label: '燃料' },
+  { key: 'waterElectricity', label: '水电费' },
+  { key: 'inspection', label: '检验费' },
+  { key: 'transport', label: '搬运运输费' },
+  { key: 'depreciation', label: '折旧' },
+  { key: 'rental', label: '租赁' },
+  { key: 'design', label: '设计费' },
+  { key: 'recruitment', label: '招待费' },
+  { key: 'travel', label: '差旅费' },
+  { key: 'technical', label: '技术服务' },
+  { key: 'other', label: '其他' }
+]
+const createSchedule = (): ScheduleItem => ({ workPlan: '', deadline: '' })
+const createBudget = () =>
+  Object.fromEntries(budgetItems.map(({ key }) => [key, 0])) as Record<string, number>
+const createForm = () => ({
+  projectLevel: '',
+  executingUnit: '',
+  projectName: '',
+  projectLeader: '',
+  projectMembers: '',
+  projectType: '',
+  purpose: '',
+  researchContent: '',
+  innovation: '',
+  schedule: [createSchedule(), createSchedule(), createSchedule()],
+  budget: createBudget(),
+  attachments: { application: [], report: [], others: [] }
+})
+const list = ref<Project[]>([
+  {
+    id: 1,
+    projectLevel: '专业公司级/石油技术公司级',
+    executingUnit: '瑞恒兴仪 / 四川瑞都',
+    projectName: '高温高压井下工具可靠性研究',
+    projectLeader: '张明远',
+    projectMembers: '李强,王璐',
+    projectType: '产品、工具和设备研发类',
+    purpose: '提升复杂工况下工具运行可靠性。',
+    researchContent: '开展材料、结构及工艺适应性研究。',
+    innovation: '建立高温高压工况下的评价方法。',
+    schedule: [
+      { workPlan: '完成方案设计', deadline: '2026-06-30' },
+      { workPlan: '完成样机试制', deadline: '2026-10-31' }
+    ],
+    budget: createBudget(),
+    attachments: { application: [], report: [], others: [] },
+    status: '进行中'
+  }
+])
+const loading = ref(false)
+const queryFormRef = ref()
+const queryParams = reactive({ pageNo: 1, pageSize: 10, projectName: '', projectType: '' })
+const filteredList = computed(() =>
+  list.value.filter(
+    (item) =>
+      (!queryParams.projectName || item.projectName.includes(queryParams.projectName)) &&
+      (!queryParams.projectType || item.projectType === queryParams.projectType)
+  )
+)
+const dialogVisible = ref(false)
+const dialogTitle = ref('新增科研项目')
+const formLoading = ref(false)
+const submitLoading = ref(false)
+const formRef = ref()
+const editingId = ref<number>()
+const formData = ref(createForm())
+const formRules = reactive<FormRules>({
+  projectLevel: [{ required: true, message: '请选择项目级别', trigger: 'change' }],
+  executingUnit: [{ required: true, message: '请输入执行单位', trigger: 'blur' }],
+  projectName: [{ required: true, message: '请输入项目名称', trigger: 'blur' }],
+  projectLeader: [{ required: true, message: '请输入项目负责人', trigger: 'blur' }],
+  projectType: [{ required: true, message: '请选择项目类型', trigger: 'change' }],
+  purpose: [{ required: true, message: '请输入目标和意义', trigger: 'blur' }],
+  researchContent: [{ required: true, message: '请输入主要研究内容', trigger: 'blur' }],
+  innovation: [{ required: true, message: '请输入创新点', trigger: 'blur' }]
+})
+const budgetTotal = computed(() =>
+  Object.values(formData.value.budget).reduce((sum, value) => sum + Number(value || 0), 0)
+)
+const handleQuery = () => {
+  queryParams.pageNo = 1
+}
+const resetQuery = () => {
+  queryFormRef.value?.resetFields()
+  handleQuery()
+}
+const cloneAttachments = (attachments: Project['attachments']) => ({
+  application:
+    attachments?.application?.map((file) =>
+      file && typeof file === 'object' ? { ...file } : file
+    ) || [],
+  report:
+    attachments?.report?.map((file) => (file && typeof file === 'object' ? { ...file } : file)) ||
+    [],
+  others:
+    attachments?.others?.map((file) => (file && typeof file === 'object' ? { ...file } : file)) ||
+    []
+})
+const cloneProjectForm = (project: Project) => ({
+  projectLevel: project.projectLevel,
+  executingUnit: project.executingUnit,
+  projectName: project.projectName,
+  projectLeader: project.projectLeader,
+  projectMembers: project.projectMembers,
+  projectType: project.projectType,
+  purpose: project.purpose,
+  researchContent: project.researchContent,
+  innovation: project.innovation,
+  schedule: project.schedule.map((item) => ({ ...item })),
+  budget: { ...project.budget },
+  attachments: cloneAttachments(project.attachments)
+})
+const openForm = (project?: Project) => {
+  editingId.value = project?.id
+  dialogTitle.value = project ? '编辑科研项目' : '新增科研项目'
+  formData.value = project ? cloneProjectForm(project) : createForm()
+  dialogVisible.value = true
+}
+const addSchedule = () => formData.value.schedule.push(createSchedule())
+const removeSchedule = (index: number) => formData.value.schedule.splice(index, 1)
+const submitForm = async () => {
+  const valid = await formRef.value?.validate().catch(() => false)
+  if (!valid) return
+  submitLoading.value = true
+  const project = {
+    ...formData.value,
+    id: editingId.value || Date.now(),
+    status: '进行中'
+  } as Project
+  const index = list.value.findIndex((item) => item.id === project.id)
+  if (index === -1) list.value.unshift(project)
+  else list.value[index] = project
+  submitLoading.value = false
+  dialogVisible.value = false
+  ElMessage.success('项目已保存')
+}
+const removeProject = async (id: number) => {
+  const confirmed = await ElMessageBox.confirm('确定删除这个科研项目吗?', '提示', {
+    type: 'warning',
+    confirmButtonText: '确定',
+    cancelButtonText: '取消'
+  })
+    .then(() => true)
+    .catch(() => false)
+  if (confirmed) {
+    list.value = list.value.filter((item) => item.id !== id)
+    ElMessage.success('删除成功')
+  }
+}
+</script>
+
+<style scoped lang="scss">
+.project-filter {
+  margin-bottom: -15px;
+}
+.project-table-heading {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 16px;
+}
+.project-table-heading h2 {
+  margin: 0 0 4px;
+  color: var(--el-text-color-primary);
+  font-size: 18px;
+}
+.project-table-heading span {
+  color: var(--el-text-color-secondary);
+  font-size: 13px;
+}
+.project-form {
+  padding: 0 8px;
+}
+.project-form :deep(.el-divider__text) {
+  color: var(--el-color-primary);
+  font-weight: 600;
+}
+.schedule-editor {
+  width: 100%;
+}
+.schedule-toolbar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 10px;
+  color: var(--el-text-color-secondary);
+  font-size: 13px;
+}
+@media (max-width: 900px) {
+  .budget-grid :deep(.el-col) {
+    width: 50%;
+    flex: 0 0 50%;
+  }
+}
+</style>

+ 576 - 0
src/views/researchProject/progress/index.vue

@@ -0,0 +1,576 @@
+<template>
+  <ContentWrap>
+    <el-form ref="queryFormRef" :model="queryParams" inline class="project-filter">
+      <el-form-item label="项目名称" prop="projectName">
+        <el-input
+          v-model="queryParams.projectName"
+          clearable
+          placeholder="请输入项目名称"
+          class="!w-220px"
+          @keyup.enter="handleQuery"
+        />
+      </el-form-item>
+      <el-form-item label="项目类型" prop="projectType">
+        <el-select
+          v-model="queryParams.projectType"
+          clearable
+          placeholder="请选择项目类型"
+          class="!w-220px"
+        >
+          <el-option v-for="item in projectTypes" :key="item" :label="item" :value="item" />
+        </el-select>
+      </el-form-item>
+      <el-form-item>
+        <el-button type="primary" @click="handleQuery"
+          ><Icon icon="ep:search" class="mr-5px" />搜索</el-button
+        >
+        <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" />重置</el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <ContentWrap>
+    <div class="project-table-heading">
+      <div><h2>科研项目</h2><span>维护项目申报信息、预算及阶段性计划</span></div>
+      <el-tag type="info" effect="plain">共 {{ filteredList.length }} 个项目</el-tag>
+    </div>
+    <zm-table :loading="loading" :data="filteredList" row-key="id">
+      <zm-table-column label="项目名称" prop="projectName" min-width="250" show-overflow-tooltip />
+      <zm-table-column label="项目级别" prop="projectLevel" min-width="180" />
+      <zm-table-column
+        label="执行单位"
+        prop="executingUnit"
+        min-width="210"
+        show-overflow-tooltip
+      />
+      <zm-table-column label="项目负责人" prop="projectLeader" width="130" />
+      <zm-table-column label="项目类型" prop="projectType" min-width="180" />
+      <zm-table-column label="状态" width="110" align="center"
+        ><template #default="{ row }"
+          ><el-tag :type="row.status === '进行中' ? 'success' : 'info'">{{
+            row.status
+          }}</el-tag></template
+        ></zm-table-column
+      >
+      <zm-table-column label="操作" width="120" fixed="right" align="center"
+        ><template #default="{ row }">
+          <el-button link type="primary" @click="openForm(row)">填报</el-button></template
+        ></zm-table-column
+      >
+    </zm-table>
+    <Pagination
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      :total="filteredList.length"
+    />
+  </ContentWrap>
+
+  <ResearchProjectDialog
+    v-model="dialogVisible"
+    :title="dialogTitle"
+    width="100%"
+    :fullscreen="true"
+    destroy-on-close
+  >
+    <el-form
+      ref="formRef"
+      v-loading="formLoading"
+      :model="formData"
+      :rules="formRules"
+      label-width="112px"
+      class="project-form"
+    >
+      <el-divider content-position="left">项目基本信息</el-divider>
+      <el-row :gutter="24">
+        <el-col :span="12"
+          ><el-form-item label="项目级别" prop="projectLevel"
+            ><el-select
+              disabled
+              v-model="formData.projectLevel"
+              class="w-full"
+              placeholder="请选择项目级别"
+              ><el-option
+                v-for="item in projectLevels"
+                :key="item"
+                :label="item"
+                :value="item" /></el-select></el-form-item
+        ></el-col>
+        <el-col :span="12"
+          ><el-form-item label="执行单位" prop="executingUnit"
+            ><el-input
+              disabled
+              v-model="formData.executingUnit"
+              placeholder="请输入执行单位" /></el-form-item
+        ></el-col>
+        <el-col :span="12"
+          ><el-form-item label="项目名称" prop="projectName"
+            ><el-input
+              disabled
+              v-model="formData.projectName"
+              placeholder="请输入项目名称" /></el-form-item
+        ></el-col>
+        <el-col :span="12"
+          ><el-form-item label="项目负责人" prop="projectLeader"
+            ><el-input
+              disabled
+              v-model="formData.projectLeader"
+              placeholder="请输入项目负责人" /></el-form-item
+        ></el-col>
+        <el-col :span="12"
+          ><el-form-item label="项目组成员"
+            ><el-input
+              disabled
+              v-model="formData.projectMembers"
+              placeholder="请输入成员姓名,多个成员用逗号分隔" /></el-form-item
+        ></el-col>
+        <el-col :span="12"
+          ><el-form-item label="项目类型" prop="projectType"
+            ><el-select
+              disabled
+              v-model="formData.projectType"
+              class="w-full"
+              placeholder="请选择项目类型"
+              ><el-option
+                v-for="item in projectTypes"
+                :key="item"
+                :label="item"
+                :value="item" /></el-select></el-form-item
+        ></el-col>
+      </el-row>
+      <el-divider content-position="left">项目内容</el-divider>
+      <el-form-item label="目标和意义" prop="purpose"
+        ><el-input
+          disabled
+          v-model="formData.purpose"
+          type="textarea"
+          :rows="3"
+          maxlength="500"
+          show-word-limit
+          placeholder="请输入项目目标和意义"
+      /></el-form-item>
+      <el-form-item label="主要研究内容" prop="researchContent"
+        ><el-input
+          disabled
+          v-model="formData.researchContent"
+          type="textarea"
+          :rows="4"
+          maxlength="2000"
+          show-word-limit
+          placeholder="请输入主要研究内容"
+      /></el-form-item>
+      <el-form-item label="创新点" prop="innovation"
+        ><el-input
+          disabled
+          v-model="formData.innovation"
+          type="textarea"
+          :rows="3"
+          maxlength="500"
+          show-word-limit
+          placeholder="请输入项目创新点"
+      /></el-form-item>
+      <el-form-item label="进度安排" prop="schedule">
+        <div class="schedule-editor">
+          <div class="schedule-toolbar"><span>按阶段填写工作计划与截止时间</span></div>
+          <el-table :data="formData.schedule" border class="schedule-table">
+            <el-table-column type="index" label="#" width="56" align="center" />
+            <el-table-column label="工作计划" min-width="300"
+              ><template #default="{ row }"
+                ><el-input disabled v-model="row.workPlan" placeholder="请输入工作计划" /></template
+            ></el-table-column>
+            <el-table-column label="截止时间" width="190"
+              ><template #default="{ row }"
+                ><el-date-picker
+                  disabled
+                  v-model="row.deadline"
+                  type="date"
+                  value-format="YYYY-MM-DD"
+                  placeholder="选择日期"
+                  class="w-full" /></template
+            ></el-table-column>
+            <el-table-column label="实际完成情况" min-width="300"
+              ><template #default="{ row }"
+                ><el-input v-model="row.result" placeholder="请输入实际完成情况" /></template
+            ></el-table-column>
+            <!-- <el-table-column label="操作" width="90" align="center"
+              ><template #default="{ $index }"
+                ><el-button link type="danger" @click="removeSchedule($index)"
+                  ><Icon icon="ep:delete" /></el-button></template
+            ></el-table-column> -->
+          </el-table>
+        </div>
+      </el-form-item>
+      <el-divider content-position="left">费用预算(元)</el-divider>
+      <el-table
+        :data="budgetItems"
+        border
+        class="budget-table"
+        :header-cell-style="{ background: '#f3f6fd', color: '#555' }"
+      >
+        <el-table-column
+          prop="label"
+          label="费用预算"
+          min-width="420"
+          align="center"
+          header-align="center"
+        />
+        <el-table-column label="预算" min-width="220" align="center" header-align="center">
+          <template #default="{ row }">
+            <el-input-number
+              disabled
+              v-model="formData.budget[row.key]"
+              :min="0"
+              :precision="2"
+              controls-position="right"
+              class="budget-input"
+            />
+          </template>
+        </el-table-column>
+        <el-table-column
+          label="实际支出"
+          min-width="220"
+          align="center"
+          header-align="center"
+          class-name="actual-spend-column"
+        >
+          <template #default="{ row }">
+            <el-input-number
+              v-model="formData.actualSpend[row.key]"
+              :min="0"
+              :precision="2"
+              controls-position="right"
+              class="budget-input"
+            />
+          </template>
+        </el-table-column>
+      </el-table>
+      <div class="budget-total budget-total-row">
+        <strong>总计</strong>
+        <strong>{{ budgetTotal.toFixed(2) }}</strong>
+        <strong>{{ actualSpendTotal.toFixed(2) }}</strong>
+        <span>元</span>
+      </div>
+      <el-divider content-position="left">附件上传</el-divider>
+      <el-form-item label="立项申请表" required
+        ><UploadFile
+          :disabled="true"
+          v-model="formData.attachments.application"
+          :return-object="true"
+          :auto-upload="false"
+          :limit="1"
+          :file-type="['doc', 'docx', 'pdf']"
+      /></el-form-item>
+      <el-form-item label="开题报告" required
+        ><UploadFile
+          :disabled="true"
+          v-model="formData.attachments.report"
+          :return-object="true"
+          :auto-upload="false"
+          :limit="1"
+          :file-type="['doc', 'docx', 'pdf']"
+      /></el-form-item>
+
+      <el-form-item label="进度报告" required
+        ><UploadFile
+          v-model="formData.attachments.report"
+          :return-object="true"
+          :auto-upload="false"
+          :limit="1"
+          :file-type="['doc', 'docx', 'pdf']"
+      /></el-form-item>
+      <el-form-item label="其他文件"
+        ><UploadFile
+          v-model="formData.attachments.others"
+          :return-object="true"
+          :auto-upload="false"
+          :limit="3"
+          :file-type="['doc', 'docx', 'pdf', 'xls', 'xlsx', 'jpg', 'png']"
+      /></el-form-item>
+    </el-form>
+    <template #footer>
+      <div
+        style="
+          position: fixed;
+          bottom: 0;
+          left: 0;
+          right: 0;
+          background: #fff;
+          padding: 10px;
+          z-index: 100;
+          /* border-top: 1px solid #ccc; */
+          box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
+        "
+      >
+        <el-button @click="dialogVisible = false">取消</el-button
+        ><el-button type="primary" :loading="submitLoading" @click="submitForm">保存项目</el-button>
+      </div>
+    </template>
+  </ResearchProjectDialog>
+</template>
+
+<script setup lang="ts">
+import { computed, reactive, ref } from 'vue'
+import { ElMessage, ElMessageBox, type FormRules } from 'element-plus'
+
+defineOptions({ name: 'ResearchProjectList' })
+type ScheduleItem = { workPlan: string; deadline: string }
+type Project = {
+  id: number
+  projectLevel: string
+  executingUnit: string
+  projectName: string
+  projectLeader: string
+  projectMembers: string
+  projectType: string
+  purpose: string
+  researchContent: string
+  innovation: string
+  schedule: ScheduleItem[]
+  budget: Record<string, number>
+  actualSpend: Record<string, number>
+  attachments: { application: any[]; report: any[]; others: any[] }
+  status: string
+}
+const projectLevels = ['专业公司级/石油技术公司级', '集团公司级', '国家级']
+const projectTypes = [
+  '基础研究类',
+  '原理方法类',
+  '产品、工具和设备研发类',
+  '成熟工艺技术配套及推广类',
+  '情报调研类'
+]
+const budgetItems = [
+  { key: 'labor', label: '人工(包含工资、福利、社保、奖金等)' },
+  { key: 'materials', label: '直接材料投入' },
+  { key: 'supplies', label: '劳保用品' },
+  { key: 'fuel', label: '燃料' },
+  { key: 'waterElectricity', label: '水电费' },
+  { key: 'inspection', label: '检验费' },
+  { key: 'transport', label: '搬运运输费' },
+  { key: 'depreciation', label: '折旧' },
+  { key: 'rental', label: '租赁' },
+  { key: 'design', label: '设计费' },
+  { key: 'recruitment', label: '招待费' },
+  { key: 'travel', label: '差旅费' },
+  { key: 'technical', label: '技术服务' },
+  { key: 'other', label: '其他' }
+]
+const createSchedule = (): ScheduleItem => ({ workPlan: '', deadline: '' })
+const createBudget = () =>
+  Object.fromEntries(budgetItems.map(({ key }) => [key, 0])) as Record<string, number>
+const createForm = () => ({
+  projectLevel: '',
+  executingUnit: '',
+  projectName: '',
+  projectLeader: '',
+  projectMembers: '',
+  projectType: '',
+  purpose: '',
+  researchContent: '',
+  innovation: '',
+  schedule: [createSchedule(), createSchedule(), createSchedule()],
+  budget: createBudget(),
+  actualSpend: createBudget(),
+  attachments: { application: [], report: [], others: [] }
+})
+const list = ref<Project[]>([
+  {
+    id: 1,
+    projectLevel: '专业公司级/石油技术公司级',
+    executingUnit: '瑞恒兴仪 / 四川瑞都',
+    projectName: '高温高压井下工具可靠性研究',
+    projectLeader: '张明远',
+    projectMembers: '李强,王璐',
+    projectType: '产品、工具和设备研发类',
+    purpose: '提升复杂工况下工具运行可靠性。',
+    researchContent: '开展材料、结构及工艺适应性研究。',
+    innovation: '建立高温高压工况下的评价方法。',
+    schedule: [
+      { workPlan: '完成方案设计', deadline: '2026-06-30' },
+      { workPlan: '完成样机试制', deadline: '2026-10-31' }
+    ],
+    budget: createBudget(),
+    actualSpend: createBudget(),
+    attachments: { application: [], report: [], others: [] },
+    status: '进行中'
+  }
+])
+const loading = ref(false)
+const queryFormRef = ref()
+const queryParams = reactive({ pageNo: 1, pageSize: 10, projectName: '', projectType: '' })
+const filteredList = computed(() =>
+  list.value.filter(
+    (item) =>
+      (!queryParams.projectName || item.projectName.includes(queryParams.projectName)) &&
+      (!queryParams.projectType || item.projectType === queryParams.projectType)
+  )
+)
+const dialogVisible = ref(false)
+const dialogTitle = ref('新增科研项目')
+const formLoading = ref(false)
+const submitLoading = ref(false)
+const formRef = ref()
+const editingId = ref<number>()
+const formData = ref(createForm())
+const formRules = reactive<FormRules>({
+  projectLevel: [{ required: true, message: '请选择项目级别', trigger: 'change' }],
+  executingUnit: [{ required: true, message: '请输入执行单位', trigger: 'blur' }],
+  projectName: [{ required: true, message: '请输入项目名称', trigger: 'blur' }],
+  projectLeader: [{ required: true, message: '请输入项目负责人', trigger: 'blur' }],
+  projectType: [{ required: true, message: '请选择项目类型', trigger: 'change' }],
+  purpose: [{ required: true, message: '请输入目标和意义', trigger: 'blur' }],
+  researchContent: [{ required: true, message: '请输入主要研究内容', trigger: 'blur' }],
+  innovation: [{ required: true, message: '请输入创新点', trigger: 'blur' }]
+})
+const budgetTotal = computed(() =>
+  Object.values(formData.value.budget).reduce((sum, value) => sum + Number(value || 0), 0)
+)
+const actualSpendTotal = computed(() =>
+  Object.values(formData.value.actualSpend).reduce((sum, value) => sum + Number(value || 0), 0)
+)
+const handleQuery = () => {
+  queryParams.pageNo = 1
+}
+const resetQuery = () => {
+  queryFormRef.value?.resetFields()
+  handleQuery()
+}
+const cloneAttachments = (attachments: Project['attachments']) => ({
+  application:
+    attachments?.application?.map((file) =>
+      file && typeof file === 'object' ? { ...file } : file
+    ) || [],
+  report:
+    attachments?.report?.map((file) => (file && typeof file === 'object' ? { ...file } : file)) ||
+    [],
+  others:
+    attachments?.others?.map((file) => (file && typeof file === 'object' ? { ...file } : file)) ||
+    []
+})
+const cloneProjectForm = (project: Project) => ({
+  projectLevel: project.projectLevel,
+  executingUnit: project.executingUnit,
+  projectName: project.projectName,
+  projectLeader: project.projectLeader,
+  projectMembers: project.projectMembers,
+  projectType: project.projectType,
+  purpose: project.purpose,
+  researchContent: project.researchContent,
+  innovation: project.innovation,
+  schedule: project.schedule.map((item) => ({ ...item })),
+  budget: { ...project.budget },
+  actualSpend: { ...project.actualSpend },
+  attachments: cloneAttachments(project.attachments)
+})
+const openForm = (project?: Project) => {
+  editingId.value = project?.id
+  dialogTitle.value = project ? '编辑科研项目' : '新增科研项目'
+  formData.value = project ? cloneProjectForm(project) : createForm()
+  dialogVisible.value = true
+}
+const addSchedule = () => formData.value.schedule.push(createSchedule())
+const removeSchedule = (index: number) => formData.value.schedule.splice(index, 1)
+const submitForm = async () => {
+  const valid = await formRef.value?.validate().catch(() => false)
+  if (!valid) return
+  submitLoading.value = true
+  const project = {
+    ...formData.value,
+    id: editingId.value || Date.now(),
+    status: '进行中'
+  } as Project
+  const index = list.value.findIndex((item) => item.id === project.id)
+  if (index === -1) list.value.unshift(project)
+  else list.value[index] = project
+  submitLoading.value = false
+  dialogVisible.value = false
+  ElMessage.success('项目已保存')
+}
+const removeProject = async (id: number) => {
+  const confirmed = await ElMessageBox.confirm('确定删除这个科研项目吗?', '提示', {
+    type: 'warning',
+    confirmButtonText: '确定',
+    cancelButtonText: '取消'
+  })
+    .then(() => true)
+    .catch(() => false)
+  if (confirmed) {
+    list.value = list.value.filter((item) => item.id !== id)
+    ElMessage.success('删除成功')
+  }
+}
+</script>
+
+<style scoped lang="scss">
+.project-filter {
+  margin-bottom: -15px;
+}
+.project-table-heading {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 16px;
+}
+.project-table-heading h2 {
+  margin: 0 0 4px;
+  color: var(--el-text-color-primary);
+  font-size: 18px;
+}
+.project-table-heading span {
+  color: var(--el-text-color-secondary);
+  font-size: 13px;
+}
+.project-form {
+  padding: 0 8px;
+}
+.project-form :deep(.el-divider__text) {
+  color: var(--el-color-primary);
+  font-weight: 600;
+}
+.schedule-editor {
+  width: 100%;
+}
+.schedule-toolbar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 10px;
+  color: var(--el-text-color-secondary);
+  font-size: 13px;
+}
+.budget-table {
+  width: 100%;
+}
+.budget-table :deep(.el-input-number) {
+  width: 100%;
+}
+.budget-table :deep(.actual-spend-column) {
+  background-color: #fff900;
+}
+.budget-total-row {
+  display: grid;
+  grid-template-columns: minmax(420px, 1fr) minmax(220px, 1fr) minmax(220px, 1fr) auto;
+  align-items: center;
+  gap: 0;
+  min-height: 48px;
+  border: 1px solid var(--el-border-color);
+  border-top: 0;
+  color: var(--el-text-color-primary);
+  text-align: center;
+}
+.budget-total-row strong,
+.budget-total-row span {
+  padding: 12px;
+  font-variant-numeric: tabular-nums;
+}
+.budget-total-row strong:first-child {
+  text-align: left;
+}
+.budget-total-row strong:not(:first-child) {
+  border-left: 1px solid var(--el-border-color);
+}
+@media (max-width: 900px) {
+  .budget-total-row {
+    grid-template-columns: minmax(220px, 1fr) minmax(150px, 1fr) minmax(150px, 1fr) auto;
+  }
+}
+</style>

+ 562 - 0
src/views/researchProject/wbs/index.vue

@@ -0,0 +1,562 @@
+<template>
+  <div>
+    <vxe-switch v-model="treeConfig.showIcon" />
+
+    <vxe-gantt v-bind="ganttOptions" />
+  </div>
+</template>
+
+<script setup>
+import { reactive } from 'vue'
+import { VxeGanttTaskType } from 'vxe-gantt'
+
+const treeConfig = reactive({
+  transform: true, // 自动将列表转为树结构
+  rowField: 'newId', // 自定义树节点关联的主键
+  parentField: 'newParentId', // 自定义树节点关联的父节点的字段名
+  showIcon: true // 是否显示展开按钮
+})
+const ganttOptions = reactive({
+  border: true,
+  height: 600,
+  columnConfig: {
+    resizable: true
+  },
+  treeConfig,
+  taskConfig: {
+    titleField: 'newTitle', // 自定义标题字段名
+    startField: 'start', // 自定义开始日期字段名
+    endField: 'end', // 自定义结束期字段名
+    progressField: 'progress', // 自定义进度值字段名
+    typeField: 'type' // 自定义渲染类型字段名
+  },
+  taskBarSubviewConfig: {
+    showOverview: true, // 是否显示任务总览,当子任务被展开后自动显示任务总览
+    // 自定义子任务视图任务条的样式
+    barStyle({ row }) {
+      if (row.flag === 1) {
+        return {
+          top: '-12px', // 偏移位置,计划于实际不重叠
+          backgroundColor: '#40d9eff'
+        }
+      }
+      if (row.flag === 2) {
+        return {
+          top: '12px', // 偏移位置,计划于实际不重叠
+          backgroundColor: '#31d231'
+        }
+      }
+    }
+  },
+  taskBarConfig: {
+    showContent: true, // 是否在任务条显示内容
+    moveable: true, // 是否允许拖拽任务移动日期
+    resizable: true, // 是否允许拖拽任务调整日期
+    barStyle({ row }) {
+      return {
+        round: true, // 圆角
+        bgColor: row.flag === 1 ? '#40d9eff' : '#31d231' // 已完成部分任务条的背景颜色
+      }
+    }
+  },
+  taskViewConfig: {
+    tableStyle: {
+      width: 200
+    },
+    viewStyle: {
+      border: 'inner'
+    }
+  },
+  columns: [
+    { field: 'newTitle', title: '任务名称', minWidth: 160, treeNode: true },
+    { field: 'planStartDate', title: '计划开始日期', width: 140 },
+    { field: 'planEndDate', title: '实际 结束日期', width: 140 },
+    { field: 'actualStartDate', title: '计划开始日期', width: 140 },
+    { field: 'actualEndDate', title: '实际结束日期', width: 140 }
+  ],
+  data: []
+})
+
+// 将实际和计划一分二
+const handleMultiTask = (list) => {
+  const multiRest = []
+  list.forEach((item) => {
+    if (item.hasChild) {
+      const groupRow = {
+        ...item,
+        newTitle: `${item.title}`,
+        flag: 3,
+        newId: `g${item.id}`,
+        newParentId: item.parentId ? `g${item.parentId}` : null,
+        start: '',
+        end: '',
+        progress: 0,
+        type: VxeGanttTaskType.Subview
+      }
+      multiRest.push(groupRow)
+    } else {
+      // 计划
+      const planRow = {
+        ...item,
+        newTitle: `计划:${item.title}`,
+        flag: 1,
+        newId: `p${item.id}`,
+        newParentId: item.parentId ? `g${item.parentId}` : null,
+        start: item.planStartDate,
+        end: item.planEndDate,
+        progress: 0
+      }
+      // 实际
+      const actualRow = {
+        ...item,
+        newTitle: `实际:${item.title}`,
+        flag: 2,
+        newId: `a${item.id}`,
+        newParentId: item.parentId ? `g${item.parentId}` : null,
+        start: item.actualStartDate,
+        end: item.actualEndDate,
+        progress: 0
+      }
+      multiRest.push(planRow, actualRow)
+    }
+  })
+  return multiRest
+}
+
+const loadList = () => {
+  const list = [
+    {
+      id: 10001,
+      parentId: null,
+      title: '任务1',
+      planStartDate: '',
+      planEndDate: '',
+      actualStartDate: '',
+      actualEndDate: '',
+      planProgress: 90,
+      actualProgress: 80,
+      hasChild: true
+    },
+    {
+      id: 10002,
+      parentId: 10001,
+      title: '任务2',
+      planStartDate: '2024-02-27',
+      planEndDate: '2024-03-03',
+      actualStartDate: '2024-03-01',
+      actualEndDate: '2024-03-02',
+      planProgress: 90,
+      actualProgress: 80
+    },
+    {
+      id: 10003,
+      parentId: null,
+      title: '任务3',
+      planStartDate: '',
+      planEndDate: '',
+      actualStartDate: '',
+      actualEndDate: '',
+      planProgress: 90,
+      actualProgress: 80,
+      hasChild: true
+    },
+    {
+      id: 10004,
+      parentId: 10003,
+      title: '任务4',
+      planStartDate: '2024-03-01',
+      planEndDate: '2024-03-02',
+      actualStartDate: '2024-03-03',
+      actualEndDate: '2024-03-04',
+      planProgress: 90,
+      actualProgress: 80
+    },
+    {
+      id: 10005,
+      parentId: 10003,
+      title: '任务5',
+      planStartDate: '2024-03-03',
+      planEndDate: '2024-03-05',
+      actualStartDate: '2024-03-04',
+      actualEndDate: '2024-03-07',
+      planProgress: 90,
+      actualProgress: 80,
+      hasChild: true
+    },
+    {
+      id: 10006,
+      parentId: 10003,
+      title: '任务6',
+      planStartDate: '2024-03-19',
+      planEndDate: '2024-03-22',
+      actualStartDate: '2024-03-17',
+      actualEndDate: '2024-03-20',
+      planProgress: 90,
+      actualProgress: 80
+    },
+    {
+      id: 10007,
+      parentId: 10005,
+      title: '任务7',
+      planStartDate: '2024-03-17',
+      planEndDate: '2024-03-20',
+      actualStartDate: '2024-03-19',
+      actualEndDate: '2024-03-22',
+      planProgress: 90,
+      actualProgress: 80
+    },
+    {
+      id: 10008,
+      parentId: null,
+      title: '任务8',
+      planStartDate: '',
+      planEndDate: '',
+      actualStartDate: '',
+      actualEndDate: '',
+      planProgress: 90,
+      actualProgress: 80,
+      hasChild: true
+    },
+    {
+      id: 10009,
+      parentId: 10008,
+      title: '任务9',
+      planStartDate: '2024-03-02',
+      planEndDate: '2024-03-03',
+      actualStartDate: '2024-03-02',
+      actualEndDate: '2024-03-03',
+      planProgress: 90,
+      actualProgress: 80
+    },
+    {
+      id: 10010,
+      parentId: 10008,
+      title: '任务10',
+      planStartDate: '2024-03-05',
+      planEndDate: '2024-03-07',
+      actualStartDate: '2024-03-05',
+      actualEndDate: '2024-03-09',
+      planProgress: 90,
+      actualProgress: 80
+    },
+    {
+      id: 10011,
+      parentId: 10008,
+      title: '任务11',
+      planStartDate: '2024-03-08',
+      planEndDate: '2024-03-11',
+      actualStartDate: '2024-03-11',
+      actualEndDate: '2024-03-15',
+      planProgress: 90,
+      actualProgress: 80
+    },
+    {
+      id: 10012,
+      parentId: null,
+      title: '任务12',
+      planStartDate: '',
+      planEndDate: '',
+      actualStartDate: '',
+      actualEndDate: '',
+      planProgress: 90,
+      actualProgress: 80,
+      hasChild: true
+    },
+    {
+      id: 10013,
+      parentId: 10012,
+      title: '任务13',
+      planStartDate: '2024-03-04',
+      planEndDate: '2024-03-05',
+      actualStartDate: '2024-03-06',
+      actualEndDate: '2024-03-08',
+      planProgress: 90,
+      actualProgress: 80
+    },
+    {
+      id: 10014,
+      parentId: 10012,
+      title: '任务14',
+      planStartDate: '2024-03-06',
+      planEndDate: '2024-03-07',
+      actualStartDate: '2024-03-03',
+      actualEndDate: '2024-03-05',
+      planProgress: 90,
+      actualProgress: 80
+    },
+    {
+      id: 10015,
+      parentId: 10012,
+      title: '任务15',
+      planStartDate: '2024-03-09',
+      planEndDate: '2024-03-12',
+      actualStartDate: '2024-03-10',
+      actualEndDate: '2024-03-11',
+      planProgress: 90,
+      actualProgress: 80,
+      hasChild: true
+    },
+    {
+      id: 10016,
+      parentId: 10012,
+      title: '任务16',
+      planStartDate: '2024-03-13',
+      planEndDate: '2024-03-15',
+      actualStartDate: '2024-03-17',
+      actualEndDate: '2024-03-18',
+      planProgress: 90,
+      actualProgress: 80,
+      hasChild: true
+    },
+    {
+      id: 10017,
+      parentId: 10012,
+      title: '任务17',
+      planStartDate: '2024-03-17',
+      planEndDate: '2024-03-21',
+      actualStartDate: '2024-03-18',
+      actualEndDate: '2024-03-19',
+      planProgress: 90,
+      actualProgress: 80
+    },
+    {
+      id: 10018,
+      parentId: null,
+      title: '任务18',
+      planStartDate: '2024-03-11',
+      planEndDate: '2024-03-12',
+      actualStartDate: '2024-03-12',
+      actualEndDate: '2024-03-15',
+      planProgress: 90,
+      actualProgress: 80
+    },
+    {
+      id: 10019,
+      parentId: null,
+      title: '任务19',
+      planStartDate: '2024-03-15',
+      planEndDate: '2024-03-17',
+      actualStartDate: '2024-03-17',
+      actualEndDate: '2024-03-20',
+      planProgress: 90,
+      actualProgress: 80
+    },
+    {
+      id: 10020,
+      parentId: null,
+      title: '任务20',
+      planStartDate: '',
+      planEndDate: '',
+      actualStartDate: '',
+      actualEndDate: '',
+      planProgress: 90,
+      actualProgress: 80,
+      hasChild: true
+    },
+    {
+      id: 10021,
+      parentId: 10020,
+      title: '任务21',
+      planStartDate: '2024-03-18',
+      planEndDate: '2024-03-21',
+      actualStartDate: '2024-03-19',
+      actualEndDate: '2024-03-21',
+      planProgress: 90,
+      actualProgress: 80
+    },
+    {
+      id: 10022,
+      parentId: 10020,
+      title: '任务22',
+      planStartDate: '2024-03-23',
+      planEndDate: '2024-03-25',
+      actualStartDate: '2024-03-23',
+      actualEndDate: '2024-03-25',
+      planProgress: 90,
+      actualProgress: 80
+    },
+    {
+      id: 10023,
+      parentId: 10020,
+      title: '任务23',
+      planStartDate: '2024-03-27',
+      planEndDate: '2024-04-02',
+      actualStartDate: '2024-03-27',
+      actualEndDate: '2024-03-28',
+      planProgress: 90,
+      actualProgress: 80
+    },
+    {
+      id: 10024,
+      parentId: 10020,
+      title: '任务24',
+      planStartDate: '2024-04-04',
+      planEndDate: '2024-04-06',
+      actualStartDate: '2024-04-02',
+      actualEndDate: '2024-04-05',
+      planProgress: 90,
+      actualProgress: 80
+    },
+    {
+      id: 10025,
+      parentId: 10020,
+      title: '任务25',
+      planStartDate: '2024-04-07',
+      planEndDate: '2024-04-11',
+      actualStartDate: '2024-04-08',
+      actualEndDate: '2024-04-13',
+      planProgress: 90,
+      actualProgress: 80
+    },
+    {
+      id: 10026,
+      parentId: null,
+      title: '任务26',
+      planStartDate: '2024-04-05',
+      planEndDate: '2024-04-08',
+      actualStartDate: '2024-04-07',
+      actualEndDate: '2024-04-10',
+      planProgress: 90,
+      actualProgress: 80
+    },
+    {
+      id: 10027,
+      parentId: null,
+      title: '任务27',
+      planStartDate: '',
+      planEndDate: '',
+      actualStartDate: '',
+      actualEndDate: '',
+      planProgress: 90,
+      actualProgress: 80,
+      hasChild: true
+    },
+    {
+      id: 10028,
+      parentId: 10027,
+      title: '任务28',
+      planStartDate: '2024-03-10',
+      planEndDate: '2024-03-13',
+      actualStartDate: '2024-03-13',
+      actualEndDate: '2024-03-14',
+      planProgress: 90,
+      actualProgress: 80
+    },
+    {
+      id: 10029,
+      parentId: 10027,
+      title: '任务29',
+      planStartDate: '2024-03-14',
+      planEndDate: '2024-03-18',
+      actualStartDate: '2024-03-16',
+      actualEndDate: '2024-03-18',
+      planProgress: 90,
+      actualProgress: 80
+    },
+    {
+      id: 10030,
+      parentId: 10027,
+      title: '任务30',
+      planStartDate: '2024-03-19',
+      planEndDate: '2024-03-25',
+      actualStartDate: '2024-03-20',
+      actualEndDate: '2024-03-26',
+      planProgress: 90,
+      actualProgress: 80
+    },
+    {
+      id: 10031,
+      parentId: null,
+      title: '任务31',
+      planStartDate: '2024-03-22',
+      planEndDate: '2024-03-24',
+      actualStartDate: '2024-03-19',
+      actualEndDate: '2024-03-20',
+      planProgress: 90,
+      actualProgress: 80
+    },
+    {
+      id: 10032,
+      parentId: null,
+      title: '任务32',
+      planStartDate: '',
+      planEndDate: '',
+      actualStartDate: '',
+      actualEndDate: '',
+      planProgress: 90,
+      actualProgress: 80,
+      hasChild: true
+    },
+    {
+      id: 10033,
+      parentId: 10032,
+      title: '任务33',
+      planStartDate: '2024-03-09',
+      planEndDate: '2024-03-14',
+      actualStartDate: '2024-03-11',
+      actualEndDate: '2024-03-15',
+      planProgress: 90,
+      actualProgress: 80
+    },
+    {
+      id: 10034,
+      parentId: 10032,
+      title: '任务34',
+      planStartDate: '2024-03-15',
+      planEndDate: '2024-03-21',
+      actualStartDate: '2024-03-16',
+      actualEndDate: '2024-03-19',
+      planProgress: 90,
+      actualProgress: 80
+    },
+    {
+      id: 10035,
+      parentId: 10015,
+      title: '任务35',
+      planStartDate: '2024-03-16',
+      planEndDate: '2024-03-18',
+      actualStartDate: '2024-03-17',
+      actualEndDate: '2024-03-19',
+      planProgress: 90,
+      actualProgress: 80
+    },
+    {
+      id: 10036,
+      parentId: 10015,
+      title: '任务36',
+      planStartDate: '2024-03-12',
+      planEndDate: '2024-03-14',
+      actualStartDate: '2024-03-13',
+      actualEndDate: '2024-03-15',
+      planProgress: 90,
+      actualProgress: 80
+    },
+    {
+      id: 10037,
+      parentId: 10016,
+      title: '任务37',
+      planStartDate: '2024-03-19',
+      planEndDate: '2024-03-22',
+      actualStartDate: '2024-03-21',
+      actualEndDate: '2024-03-23',
+      planProgress: 90,
+      actualProgress: 80
+    },
+    {
+      id: 10038,
+      parentId: 10016,
+      title: '任务38',
+      planStartDate: '2024-03-23',
+      planEndDate: '2024-03-25',
+      actualStartDate: '2024-03-24',
+      actualEndDate: '2024-03-27',
+      planProgress: 90,
+      actualProgress: 80
+    }
+  ]
+  const multiRest = handleMultiTask(list)
+  ganttOptions.data = multiRest
+}
+
+loadList()
+</script>

Некоторые файлы не были показаны из-за большого количества измененных файлов