Przeglądaj źródła

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

yanghao 1 dzień temu
rodzic
commit
3fe9761272

+ 1 - 1
.env.local

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

+ 10 - 0
src/api/expert/index.ts

@@ -15,6 +15,16 @@ export const updateExpert = (data: any) => {
   return request.put({ url: '/rd/expert-pool/update', data })
 }
 
+// 删除专家
+export const deleteExpert = (id) => {
+  return request.delete({ url: `/rd/expert-pool/delete?id=${id}` })
+}
+
+// 专家排序
+export const exchangeExpert = (data) => {
+  return request.put({ url: `/rd/expert-pool/changeSort`, data })
+}
+
 // 专家详情
 export const getExpertDetail = (id: number) => {
   return request.get({ url: `/rd/expert-pool/get?id=${id}` })

+ 203 - 61
src/views/expert/list/index.vue

@@ -129,14 +129,17 @@
     </section>
 
     <div
-      v-for="(row, rowIndex) in filteredTableData"
+      v-for="row 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>
+        <!-- <span class="team-index">0{{ rowIndex + 1 }}</span> -->
+        <div class="team-details">
+          <span class="team-name">{{ row.teamName }}</span>
+          <span class="team-count">{{ row.memberCount }}人</span>
+        </div>
       </div>
 
       <table class="content-table" :aria-label="row.teamName">
@@ -155,18 +158,29 @@
               :key="index"
               :class="['step-cell', cell.tone === 'danger' ? 'notice-cell' : '']"
             >
-              <span class="cell-bookmark">{{ row.bookmarks[index] }}</span>
+              <span class="cell-bookmark">
+                <span>{{ row.bookmarks[index] }}</span>
+                <span class="bookmark-count">
+                  {{ cell.type === 'members' ? cell.members.length : 0 }}人
+                </span>
+              </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)"
+                <draggable
+                  :list="cell.members"
+                  item-key="id"
+                  class="member-list"
+                  :animation="180"
+                  ghost-class="member-ghost"
+                  :group="{ name: `experts-${row.teamName}-${index}`, pull: false, put: false }"
+                  @end="handleExpertDragEnd($event, row, index, cell.members)"
                 >
-                  {{ member.expertName }}
-                </button>
+                  <template #item="{ element: member }">
+                    <button type="button" class="member-link" @click="handleMemberClick(member)">
+                      {{ member.expertName }}
+                    </button>
+                  </template>
+                </draggable>
               </template>
 
               <span v-else :class="['cell-text', cell.tone === 'danger' ? 'is-danger' : '']">
@@ -200,19 +214,25 @@
               </div>
               <div class="profile-summary">
                 <div class="summary-item">
-                  <span class="summary-label">技术序列</span>
+                  <span class="summary-label"
+                    ><Icon icon="ep:medal" color="#1e3a8a" />技术序列</span
+                  >
                   <strong>{{ activeExpert.role || '-' }}</strong>
                 </div>
                 <div class="summary-item">
-                  <span class="summary-label">梯队</span>
+                  <span class="summary-label"><Icon icon="ep:sort" />梯队</span>
                   <strong>{{ activeExpert.team || '-' }}</strong>
                 </div>
                 <div class="summary-item">
-                  <span class="summary-label">目前所在单位</span>
+                  <span class="summary-label"><Icon icon="ep:compass" />专业方向</span>
+                  <strong>{{ activeExpert.direction?.join('、') || '-' }}</strong>
+                </div>
+                <div class="summary-item">
+                  <span class="summary-label"><Icon icon="ep:office-building" />目前所在单位</span>
                   <strong>{{ activeExpert.company || '-' }}</strong>
                 </div>
                 <div class="summary-item">
-                  <span class="summary-label">联系方式</span>
+                  <span class="summary-label"><Icon icon="ep:phone" />联系方式</span>
                   <strong>{{ activeExpert.contact || '-' }}</strong>
                 </div>
               </div>
@@ -229,6 +249,16 @@
                 <strong>{{ activeExpert.age || '-' }}</strong>
               </div>
 
+              <div class="info-block">
+                <span class="info-label">毕业院校</span>
+                <strong>{{ activeExpert.school || '-' }}</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
@@ -252,7 +282,9 @@
                 <el-button size="large" type="primary" plain @click="handleEdit"
                   >维护档案</el-button
                 >
-                <el-button size="large" plain>下载履历</el-button>
+                <el-button size="large" plain type="danger" @click="handleDeleteExpert"
+                  >删除专家</el-button
+                >
               </div>
             </div>
           </section>
@@ -424,11 +456,8 @@
             </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 label="岗位" prop="jobTitle">
+              <el-input v-model="formData.jobTitle" placeholder="请输入岗位" />
             </el-form-item>
           </el-col>
         </el-row>
@@ -464,22 +493,20 @@
           </el-col>
 
           <el-col :span="12">
-            <el-form-item label="历任岗位" prop="formerPost">
-              <el-input v-model="formData.formerPost" placeholder="请输入历任岗位" />
+            <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="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-col :span="24">
+            <el-form-item label="工作经历" prop="formerPost">
+              <el-input
+                type="textarea"
+                v-model="formData.formerPost"
+                placeholder="请输入工作经历"
+              />
             </el-form-item>
           </el-col>
         </el-row>
@@ -584,12 +611,15 @@ import {
   getExpertDetail,
   getExpertList,
   getTeamList,
-  updateExpert
+  updateExpert,
+  deleteExpert,
+  exchangeExpert
 } 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'
+import draggable from 'vuedraggable'
 
 const defaultFormData = () => ({
   id: undefined,
@@ -638,19 +668,10 @@ 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' }]
 }
 
@@ -662,6 +683,7 @@ const resetForm = () => {
   tempSelectedTeamMap.value = {}
   formRef.value?.clearValidate()
 }
+
 // 关闭对话框
 const closeDialog = () => {
   dialogVisible.value = false
@@ -710,6 +732,7 @@ type CellItem =
 
 type TableRow = {
   teamName: string
+  memberCount: number
   education: string
   searchText: string
   bookmarks: string[]
@@ -761,6 +784,7 @@ type ExpertItem = {
   professionalTitle: string
   jobTitle: string
   formerPost: string
+  softwareSkillsLabel: string
   teamIds: number[]
   avatar: string
   birthday: number | null
@@ -774,6 +798,7 @@ type ExpertItem = {
   mobile?: string
   softwareSkills: number[]
   teams?: Array<{ id: number; teamName?: string }>
+  sort?: number // 同梯队同序列内的排序值
 }
 
 type ExpertGroup = {
@@ -914,6 +939,7 @@ const buildExpertProfile = (expert: ExpertItem, groupLabel?: string): ExpertProf
     name: expert.expertName,
     avatar: expert.avatar || '',
     role: rankLabels.join('、') || '专家',
+    softwareSkillsLabel: softwareSkillLabels || '暂无',
     team: groupLabel || getDictLabel(DICT_TYPE.RD_EXPERT_ECHELON, expert.echelon) || '未分组',
     company: getDeptName(expert.deptId) || '-',
     age: getExpertAge(expert.birthday),
@@ -972,15 +998,26 @@ const tableData = computed<TableRow[]>(() => {
       )
       .join(' ')
       .toLowerCase()
-    const cells: CellItem[] = rankOptions.value.map((option) => ({
-      type: 'members',
-      members: group.experts.filter((expert) =>
+
+    const cells: CellItem[] = rankOptions.value.map((option) => {
+      // 过滤出属于当前序列的专家
+      const members = group.experts.filter((expert) =>
         (expert.expertRank || []).some((rank) => String(rank) === String(option.value))
       )
-    }))
+
+      // 同一梯队同一序列内,按 sort 字段升序排序
+      members.sort((left, right) => {
+        const leftSort = left.sort ?? Number.MAX_SAFE_INTEGER
+        const rightSort = right.sort ?? Number.MAX_SAFE_INTEGER
+        return leftSort - rightSort
+      })
+
+      return { type: 'members', members }
+    })
 
     return {
       teamName: group.echelonLabel,
+      memberCount: group.experts.length,
       education: educationLabels.join('、') || '-',
       searchText,
       bookmarks,
@@ -992,6 +1029,50 @@ const tableData = computed<TableRow[]>(() => {
   })
 })
 
+/**
+ * 拖拽结束处理:前端完成视觉交换后,调用后端接口持久化排序
+ */
+const handleExpertDragEnd = async (
+  event: { oldIndex?: number; newIndex?: number },
+  row: TableRow,
+  cellIndex: number,
+  members: ExpertItem[]
+) => {
+  const { oldIndex, newIndex } = event
+  if (oldIndex === undefined || newIndex === undefined || oldIndex === newIndex) return
+
+  // 还原成仅交换两个专家(vuedraggable 默认是插入式移动,会打乱其他人的下标)
+  const originalMembers = [...members]
+  const [draggedMember] = originalMembers.splice(newIndex, 1)
+  originalMembers.splice(oldIndex, 0, draggedMember)
+  ;[originalMembers[oldIndex], originalMembers[newIndex]] = [
+    originalMembers[newIndex],
+    originalMembers[oldIndex]
+  ]
+
+  const sourceMember = originalMembers[oldIndex]
+  const targetMember = originalMembers[newIndex]
+  if (!sourceMember || !targetMember) return
+
+  const rankValue = rankOptions.value[cellIndex]?.value
+
+  try {
+    await exchangeExpert({
+      sourceId: sourceMember.id,
+      targetId: targetMember.id,
+      echelon: row.teamName,
+      expertRank: rankValue
+    })
+    ElMessage.success('排序已保存')
+  } catch (error) {
+    console.error('排序保存失败:', error)
+    ElMessage.error('排序保存失败,请重试')
+  } finally {
+    // 无论成功失败都重新拉取,确保前端展示与后端 sort 一致
+    await getList()
+  }
+}
+
 const filteredTableData = computed(() => {
   return tableData.value.filter((row) => {
     const educationMatch = selectedEducation.value
@@ -1135,6 +1216,30 @@ const handleEdit = () => {
   dialogVisible.value = true
 }
 
+const handleDeleteExpert = async () => {
+  const expertId = currentExpertDetail.value?.id ?? activeExpert.value?.id
+  if (!expertId) return
+
+  try {
+    await ElMessageBox.confirm('确定删除该专家吗?', '提示', {
+      confirmButtonText: '确定',
+      cancelButtonText: '取消',
+      type: 'warning'
+    })
+
+    await deleteExpert(expertId)
+    ElMessage.success('删除成功')
+    drawerVisible.value = false
+    activeExpert.value = null
+    currentExpertDetail.value = null
+    await getList()
+  } catch (error) {
+    if (error !== 'cancel') {
+      console.error(error)
+    }
+  }
+}
+
 const confirmTeamSelection = async () => {
   selectedTeams.value = [...tempSelectedTeams.value]
   const teamIds = selectedTeams.value.map((team) => team.id)
@@ -1350,7 +1455,7 @@ onMounted(async () => {
   display: flex;
   width: max-content;
   gap: 14px;
-  animation: expert-carousel-scroll 32s linear infinite;
+  animation: expert-carousel-scroll 100s linear infinite;
 }
 
 .carousel-track:hover {
@@ -1485,16 +1590,20 @@ onMounted(async () => {
   box-shadow: 0 0 0 1px #234395 inset;
 }
 
-.filter-fields :deep(.el-input__inner),
-.filter-fields :deep(.el-select__placeholder),
-.filter-fields :deep(.el-select__selected-item) {
-  color: #365379;
-  font-size: 16px;
-}
+// .filter-fields :deep(.el-input__inner) {
+//   color: #365379;
+//   font-size: 16px;
+// }
 
-.filter-fields :deep(.el-input__inner::placeholder) {
-  color: #9aabc2;
-}
+// .filter-fields :deep(.el-select__selected-item) {
+//   color: #000 !important;
+//   font-size: 16px;
+// }
+
+// .filter-fields :deep(.el-select__placeholder) {
+//   color: #ccc !important;
+//   font-size: 16px;
+// }
 
 .filter-fields :deep(.el-input__prefix) {
   color: #91a5c0;
@@ -1809,6 +1918,20 @@ onMounted(async () => {
   white-space: nowrap;
 }
 
+.team-details {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+  min-width: 0;
+}
+
+.team-count {
+  color: rgba(24, 37, 54, 0.58);
+  font-size: 11px;
+  font-weight: 500;
+  white-space: nowrap;
+}
+
 .content-table {
   flex: 1 1 auto;
   width: 0;
@@ -1855,8 +1978,19 @@ onMounted(async () => {
   font-weight: 700;
   letter-spacing: 0.08em;
   line-height: 1.1;
-  writing-mode: vertical-rl;
+  writing-mode: vertical-lr;
   text-orientation: upright;
+  user-select: none;
+}
+
+.bookmark-count {
+  margin-top: 4px;
+  padding-top: 4px;
+  border-top: 1px solid rgba(70, 125, 164, 0.22);
+  color: #234395;
+  font-size: 9px;
+  letter-spacing: 0;
+  user-select: none;
 }
 
 .notice-cell {
@@ -2077,7 +2211,7 @@ onMounted(async () => {
   margin: 18px;
   padding: 16px;
   border: 1px solid rgba(28, 44, 62, 0.08);
-  border-radius: 14px;
+  border-radius: 8px;
   background: #fff;
   box-shadow: 0 8px 30px rgba(28, 44, 62, 0.06);
 }
@@ -2128,11 +2262,19 @@ onMounted(async () => {
 }
 
 .summary-label {
+  display: inline-flex;
+  align-items: center;
+  gap: 5px;
   color: #7a8a89;
   font-size: 11px;
   line-height: 1.3;
 }
 
+.summary-label :deep(.iconify) {
+  color: #5f8982;
+  font-size: 14px;
+}
+
 .summary-item strong {
   color: #203130;
   font-size: 12px;
@@ -2210,9 +2352,9 @@ onMounted(async () => {
 }
 
 .record-panel {
-  margin: 0 26px 26px;
+  margin: 0 20px 26px;
   border: 1px solid rgba(28, 44, 62, 0.08);
-  border-radius: 14px;
+  border-radius: 8px;
   overflow: hidden;
   background: #fff;
   // box-shadow: 0 8px 30px rgba(28, 44, 62, 0.05);

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

@@ -1,1971 +0,0 @@
-<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>

+ 2 - 2
src/views/expert/team/index.vue

@@ -1,5 +1,5 @@
 <template>
-  <ContentWrap>
+  <ContentWrap style="border: none">
     <el-form
       ref="queryFormRef"
       :inline="true"
@@ -46,7 +46,7 @@
     </el-form>
   </ContentWrap>
 
-  <ContentWrap>
+  <ContentWrap style="border: none">
     <zm-table :loading="loading" :data="list" row-key="id" @row-click="handleRowClick">
       <zm-table-column label="团队所属公司" align="center" min-width="180">
         <template #default="{ row }">

+ 849 - 0
src/views/researchProject/approval/index.vue

@@ -0,0 +1,849 @@
+<template>
+  <div class="approval-page">
+    <div class="approval-filter">
+      <el-form :model="queryParams" inline>
+        <el-form-item label="项目名称"
+          ><el-input
+            v-model="queryParams.projectName"
+            clearable
+            placeholder="请输入项目名称"
+            class="!w-240px"
+        /></el-form-item>
+        <el-form-item label="审批状态"
+          ><el-select
+            v-model="queryParams.status"
+            clearable
+            placeholder="请选择审批状态"
+            class="!w-210px"
+            ><el-option
+              v-for="item in statusTabs.slice(1)"
+              :key="item.value"
+              :label="item.label"
+              :value="item.value" /></el-select
+        ></el-form-item>
+        <el-form-item label="提交时间"
+          ><el-date-picker
+            v-model="queryParams.submitDate"
+            type="date"
+            value-format="YYYY-MM-DD"
+            clearable
+            placeholder="请选择日期"
+        /></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>
+    </div>
+
+    <div class="approval-layout">
+      <ContentWrap class="approval-list" style="border: none">
+        <div class="list-title"
+          ><div><h2>立项审批</h2><p>按流程完成项目立项汇报及审批</p></div
+          ><el-tag type="warning">待处理 {{ pendingCount }}</el-tag></div
+        >
+        <div class="status-tabs"
+          ><button
+            v-for="item in statusTabs"
+            :key="item.value"
+            :class="{ active: activeStatus === item.value }"
+            @click="changeStatus(item.value)"
+            >{{ item.label }} <span>{{ statusCount(item.value) }}</span></button
+          ></div
+        >
+        <div class="project-list">
+          <button
+            v-for="project in pagedProjects"
+            :key="project.id"
+            class="project-item"
+            :class="{ selected: selectedProject?.id === project.id }"
+            @click="selectProject(project)"
+          >
+            <div class="project-item-top"
+              ><strong>{{ project.projectName }}</strong
+              ><el-tag :type="statusType(project.status)" size="small">{{
+                project.status
+              }}</el-tag></div
+            >
+            <div class="project-meta"
+              >{{ project.projectLeader }} · {{ project.executingUnit }}</div
+            >
+            <div class="project-item-bottom"
+              ><span>{{ project.projectNo }}</span
+              ><span>提交于 {{ project.submitDate }}</span></div
+            >
+          </button>
+          <el-empty v-if="!filteredProjects.length" description="暂无审批项目" :image-size="72" />
+        </div>
+        <Pagination
+          v-if="filteredProjects.length"
+          v-model:page="pagination.pageNo"
+          v-model:limit="pagination.pageSize"
+          :total="filteredProjects.length"
+          :page-sizes="[5, 10, 20]"
+          layout="total, prev, pager, next"
+          @pagination="handlePagination"
+        />
+      </ContentWrap>
+
+      <ContentWrap v-if="selectedProject" class="approval-detail" style="border: none">
+        <div class="detail-heading"
+          ><div
+            ><div class="eyebrow">立项审批 · {{ selectedProject.projectNo }}</div
+            ><h1>{{ selectedProject.projectName }}</h1
+            ><p
+              >{{ selectedProject.projectType }} · {{ selectedProject.projectLeader }} ·
+              {{ selectedProject.executingUnit }}</p
+            ></div
+          ><el-tag :type="statusType(selectedProject.status)" size="large">{{
+            selectedProject.status
+          }}</el-tag></div
+        >
+        <div class="workflow"
+          ><div
+            v-for="(step, index) in selectedProject.process"
+            :key="step.name"
+            class="workflow-step"
+            :class="step.state"
+            ><div class="node"
+              ><Icon
+                :icon="
+                  step.state === 'done'
+                    ? 'ep:check'
+                    : step.state === 'current'
+                      ? 'ep:clock'
+                      : 'ep:more'
+                " /></div
+            ><div class="node-text"
+              ><strong>{{ step.name }}</strong
+              ><span
+                >{{ step.handler }}<template v-if="step.date"> · {{ step.date }}</template></span
+              ></div
+            ><i v-if="index < selectedProject.process.length - 1"></i></div
+        ></div>
+
+        <el-tabs v-model="activeTab">
+          <el-tab-pane label="立项汇报" name="report"
+            ><el-descriptions :column="2" border
+              ><el-descriptions-item label="汇报主题" :span="2">{{
+                selectedProject.reportTitle
+              }}</el-descriptions-item
+              ><el-descriptions-item label="汇报人">{{
+                selectedProject.projectLeader
+              }}</el-descriptions-item
+              ><el-descriptions-item label="汇报时间">{{
+                selectedProject.reportDate
+              }}</el-descriptions-item
+              ><el-descriptions-item label="项目目标" :span="2">{{
+                selectedProject.purpose
+              }}</el-descriptions-item
+              ><el-descriptions-item label="汇报内容" :span="2">{{
+                selectedProject.content
+              }}</el-descriptions-item></el-descriptions
+            ></el-tab-pane
+          >
+          <el-tab-pane label="项目资料" name="info"
+            ><el-descriptions :column="2" border
+              ><el-descriptions-item label="项目名称" :span="2">{{
+                selectedProject.projectName
+              }}</el-descriptions-item
+              ><el-descriptions-item label="项目级别">{{
+                selectedProject.projectLevel
+              }}</el-descriptions-item
+              ><el-descriptions-item label="项目类型">{{
+                selectedProject.projectType
+              }}</el-descriptions-item
+              ><el-descriptions-item label="项目负责人">{{
+                selectedProject.projectLeader
+              }}</el-descriptions-item
+              ><el-descriptions-item label="执行单位">{{
+                selectedProject.executingUnit
+              }}</el-descriptions-item
+              ><el-descriptions-item label="项目周期">{{
+                selectedProject.period
+              }}</el-descriptions-item
+              ><el-descriptions-item label="申报部门">{{
+                selectedProject.department
+              }}</el-descriptions-item></el-descriptions
+            ></el-tab-pane
+          >
+          <el-tab-pane label="预算明细" name="budget"
+            ><div class="budget-summary"
+              ><div
+                ><span>预算总额</span
+                ><strong>¥ {{ formatAmount(selectedProject.budgetTotal) }}</strong></div
+              ><div
+                ><span>预算科目</span><strong>{{ selectedProject.budgets.length }} 项</strong></div
+              ><div><span>预算状态</span><strong class="success-text">已审核</strong></div></div
+            ><el-table :data="selectedProject.budgets" border
+              ><el-table-column type="index" label="#" width="56" align="center" /><el-table-column
+                prop="subject"
+                label="财务科目"
+                min-width="220" /><el-table-column label="预算金额(元)" width="180" align="right"
+                ><template #default="{ row }">{{
+                  formatAmount(row.amount)
+                }}</template></el-table-column
+              ><el-table-column
+                prop="remark"
+                label="预算说明"
+                min-width="260"
+                show-overflow-tooltip /></el-table
+          ></el-tab-pane>
+          <el-tab-pane label="附件材料" name="attachments"
+            ><div class="attachment-tip"
+              ><Icon icon="ep:info-filled" /> 支持预览和下载立项汇报、方案及审批材料。</div
+            ><div class="attachment-list"
+              ><div
+                v-for="file in selectedProject.attachments"
+                :key="file.name"
+                class="attachment-item"
+                ><div class="file-icon" :class="file.kind"><Icon :icon="fileIcon(file.kind)" /></div
+                ><div class="file-info"
+                  ><strong>{{ file.name }}</strong
+                  ><span>{{ file.size }} · {{ file.uploadedBy }} · {{ file.uploadedAt }}</span></div
+                ><el-button link type="primary" @click="previewFile(file)">预览</el-button
+                ><el-button link type="primary" @click="downloadFile(file)">下载</el-button></div
+              ></div
+            ></el-tab-pane
+          >
+          <el-tab-pane label="审批记录" name="history"
+            ><el-timeline
+              ><el-timeline-item
+                v-for="record in selectedProject.records"
+                :key="record.id"
+                :timestamp="record.date"
+                :type="record.type"
+                ><strong>{{ record.action }}</strong
+                ><p
+                  >{{ record.operator }}:{{ record.comment || '暂无审批意见' }}</p
+                ></el-timeline-item
+              ></el-timeline
+            ></el-tab-pane
+          >
+        </el-tabs>
+
+        <div class="approval-action" v-if="isCurrentApproval"
+          ><div
+            ><strong>审批意见</strong
+            ><small>请结合立项汇报、项目资料、预算和附件进行审批</small></div
+          ><el-input
+            v-model="approvalComment"
+            type="textarea"
+            :rows="3"
+            maxlength="500"
+            show-word-limit
+            :placeholder="`请输入${selectedProject.currentNode}意见,退回时必须填写`"
+          /><div class="action-buttons"
+            ><el-button type="danger" plain @click="submitApproval('退回修改')"
+              ><Icon icon="ep:back" class="mr-5px" />退回修改</el-button
+            ><el-button type="primary" @click="submitApproval('审批通过')"
+              ><Icon icon="ep:circle-check" class="mr-5px" />{{
+                selectedProject.currentNode
+              }}通过</el-button
+            ></div
+          ></div
+        >
+        <el-alert
+          v-else
+          type="success"
+          :closable="false"
+          title="当前项目已完成本节点审批"
+          class="finished-alert"
+        />
+      </ContentWrap>
+      <ContentWrap v-else class="approval-detail empty-panel"
+        ><el-empty description="请选择需要审批的项目"
+      /></ContentWrap>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, reactive, ref } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+
+defineOptions({ name: 'ResearchProjectApproval' })
+type Status = '待专业公司审批' | '待科技发展部审批' | '已立项' | '退回修改'
+type FileKind = 'pdf' | 'word' | 'excel'
+type Attachment = {
+  name: string
+  kind: FileKind
+  size: string
+  uploadedBy: string
+  uploadedAt: string
+  url?: string
+}
+type ProcessStep = {
+  name: string
+  handler: string
+  date: string
+  state: 'done' | 'current' | 'pending'
+}
+type RecordItem = {
+  id: number
+  action: string
+  operator: string
+  date: string
+  comment: string
+  type: 'success' | 'warning' | 'danger' | 'primary'
+}
+type Project = {
+  id: number
+  projectNo: string
+  projectName: string
+  projectType: string
+  projectLevel: string
+  projectLeader: string
+  executingUnit: string
+  department: string
+  period: string
+  purpose: string
+  content: string
+  reportTitle: string
+  reportDate: string
+  submitDate: string
+  status: Status
+  currentNode: '专业公司审批' | '科技发展部审批'
+  budgetTotal: number
+  budgets: { subject: string; amount: number; remark: string }[]
+  attachments: Attachment[]
+  process: ProcessStep[]
+  records: RecordItem[]
+}
+const statusTabs = [
+  { label: '全部', value: 'all' },
+  { label: '待专业公司审批', value: '待专业公司审批' },
+  { label: '待科技发展部审批', value: '待科技发展部审批' },
+  { label: '已立项', value: '已立项' },
+  { label: '已退回', value: '退回修改' }
+]
+const baseFiles: Attachment[] = [
+  {
+    name: '立项汇报材料.pdf',
+    kind: 'pdf',
+    size: '2.4 MB',
+    uploadedBy: '张明远',
+    uploadedAt: '2026-09-16'
+  },
+  {
+    name: '项目实施方案.docx',
+    kind: 'word',
+    size: '856 KB',
+    uploadedBy: '张明远',
+    uploadedAt: '2026-09-16'
+  },
+  {
+    name: '项目预算明细.xlsx',
+    kind: 'excel',
+    size: '128 KB',
+    uploadedBy: '张明远',
+    uploadedAt: '2026-09-16'
+  }
+]
+const createProject = (data: Partial<Project> = {}): Project => ({
+  id: 1,
+  projectNo: 'XM202603001',
+  projectName: '高温高压井下工具可靠性研究',
+  projectType: '产品、工具和设备研发类',
+  projectLevel: '专业公司级/石油技术公司级',
+  projectLeader: '张明远',
+  executingUnit: '瑞恒兴仪 / 四川瑞都',
+  department: '科技发展部',
+  period: '2026-03 至 2027-03',
+  purpose: '提升复杂工况下工具运行可靠性,形成可推广的井下工具评价方法。',
+  content: '开展材料、结构及工艺适应性研究,完成样机试制、环境测试和现场验证。',
+  reportTitle: '高温高压井下工具可靠性研究立项汇报',
+  reportDate: '2026-09-16',
+  submitDate: '2026-09-16',
+  status: '待专业公司审批',
+  currentNode: '专业公司审批',
+  budgetTotal: 275000,
+  budgets: [
+    { subject: '直接材料投入', amount: 180000, remark: '样机材料及加工费用' },
+    { subject: '检验费', amount: 65000, remark: '高温高压环境测试' },
+    { subject: '差旅费', amount: 30000, remark: '现场调研与试验支持' }
+  ],
+  attachments: baseFiles,
+  process: [
+    { name: '立项汇报', handler: '张明远', date: '2026-09-16', state: 'done' },
+    { name: '项目负责人提交', handler: '张明远', date: '2026-09-16', state: 'done' },
+    { name: '专业公司审批', handler: '当前节点', date: '', state: 'current' },
+    { name: '科技发展部审批', handler: '待处理', date: '', state: 'pending' }
+  ],
+  records: [
+    {
+      id: 1,
+      action: '项目负责人提交立项',
+      operator: '张明远',
+      date: '2026-09-16 14:30',
+      comment: '申请立项,请审批。',
+      type: 'primary'
+    }
+  ],
+  ...data
+})
+const projects = ref<Project[]>([
+  createProject(),
+  createProject({
+    id: 2,
+    projectNo: 'XM202603002',
+    projectName: '智能钻井参数优化方法研究',
+    projectType: '基础研究类',
+    projectLevel: '集团公司级',
+    projectLeader: '赵海宁',
+    department: '技术中心',
+    status: '待科技发展部审批',
+    currentNode: '科技发展部审批',
+    submitDate: '2026-09-15',
+    process: [
+      { name: '立项汇报', handler: '赵海宁', date: '2026-09-12', state: 'done' },
+      { name: '项目负责人提交', handler: '赵海宁', date: '2026-09-15', state: 'done' },
+      { name: '专业公司审批', handler: '专业公司', date: '2026-09-16', state: 'done' },
+      { name: '科技发展部审批', handler: '当前节点', date: '', state: 'current' }
+    ]
+  }),
+  createProject({
+    id: 3,
+    projectNo: 'XM202602018',
+    projectName: '油气田节能降碳技术应用研究',
+    projectLeader: '陈立',
+    status: '已立项',
+    currentNode: '科技发展部审批',
+    submitDate: '2026-09-10',
+    process: [
+      { name: '立项汇报', handler: '陈立', date: '2026-09-01', state: 'done' },
+      { name: '项目负责人提交', handler: '陈立', date: '2026-09-05', state: 'done' },
+      { name: '专业公司审批', handler: '专业公司', date: '2026-09-07', state: 'done' },
+      { name: '科技发展部审批', handler: '科技发展部', date: '2026-09-10', state: 'done' }
+    ]
+  })
+])
+const queryParams = reactive({ projectName: '', status: '', submitDate: '' })
+const pagination = reactive({ pageNo: 1, pageSize: 5 })
+const activeStatus = ref('all')
+const selectedProject = ref<Project>(projects.value[0])
+const activeTab = ref('report')
+const approvalComment = ref('')
+const filteredProjects = computed(() =>
+  projects.value.filter(
+    (item) =>
+      (!queryParams.projectName || item.projectName.includes(queryParams.projectName)) &&
+      (!queryParams.status || item.status === queryParams.status) &&
+      (!queryParams.submitDate || item.submitDate === queryParams.submitDate) &&
+      (activeStatus.value === 'all' || item.status === activeStatus.value)
+  )
+)
+const pagedProjects = computed(() =>
+  filteredProjects.value.slice(
+    (pagination.pageNo - 1) * pagination.pageSize,
+    pagination.pageNo * pagination.pageSize
+  )
+)
+const pendingCount = computed(
+  () =>
+    projects.value.filter(
+      (item) => item.status === '待专业公司审批' || item.status === '待科技发展部审批'
+    ).length
+)
+const isCurrentApproval = computed(
+  () =>
+    selectedProject.value &&
+    (selectedProject.value.status === '待专业公司审批' ||
+      selectedProject.value.status === '待科技发展部审批')
+)
+const statusCount = (status: string) =>
+  status === 'all'
+    ? projects.value.length
+    : projects.value.filter((item) => item.status === status).length
+const statusType = (status: Status) =>
+  status === '已立项' ? 'success' : status === '退回修改' ? 'danger' : 'warning'
+const changeStatus = (status: string) => {
+  activeStatus.value = status
+  pagination.pageNo = 1
+  syncSelected()
+}
+const selectProject = (project: Project) => {
+  selectedProject.value = project
+  activeTab.value = 'report'
+  approvalComment.value = ''
+}
+const syncSelected = () => {
+  if (!filteredProjects.value.includes(selectedProject.value))
+    selectedProject.value = pagedProjects.value[0]
+}
+const handleQuery = () => {
+  pagination.pageNo = 1
+  syncSelected()
+}
+const resetQuery = () => {
+  queryParams.projectName = ''
+  queryParams.status = ''
+  queryParams.submitDate = ''
+  activeStatus.value = 'all'
+  pagination.pageNo = 1
+  syncSelected()
+}
+const handlePagination = () => {
+  const maxPage = Math.max(1, Math.ceil(filteredProjects.value.length / pagination.pageSize))
+  if (pagination.pageNo > maxPage) pagination.pageNo = maxPage
+  syncSelected()
+}
+const formatAmount = (amount: number) =>
+  amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
+const fileIcon = (kind: FileKind) =>
+  kind === 'pdf' ? 'ep:document' : kind === 'word' ? 'ep:reading' : 'ep:files'
+const previewFile = (file: Attachment) =>
+  file.url
+    ? window.open(file.url, '_blank')
+    : ElMessage.info(`前端演示:${file.name}暂无在线文件地址`)
+const downloadFile = (file: Attachment) => {
+  if (!file.url) {
+    ElMessage.info(`前端演示:${file.name}暂无下载地址`)
+    return
+  }
+  const link = document.createElement('a')
+  link.href = file.url
+  link.download = file.name
+  link.click()
+}
+const submitApproval = async (result: '审批通过' | '退回修改') => {
+  if (result === '退回修改' && !approvalComment.value.trim()) {
+    ElMessage.warning('退回修改时必须填写审批意见')
+    return
+  }
+  const confirmed = await ElMessageBox.confirm(`确定${result}该项目吗?`, '提交审批结果', {
+    type: result === '审批通过' ? 'success' : 'warning',
+    confirmButtonText: '确定',
+    cancelButtonText: '取消'
+  })
+    .then(() => true)
+    .catch(() => false)
+  if (!confirmed) return
+  const project = selectedProject.value
+  if (result === '退回修改') {
+    project.status = result
+    project.records.unshift({
+      id: Date.now(),
+      action: `${project.currentNode}退回修改`,
+      operator: project.currentNode,
+      date: '2026-09-18',
+      comment: approvalComment.value,
+      type: 'danger'
+    })
+  } else if (project.status === '待专业公司审批') {
+    project.status = '待科技发展部审批'
+    project.currentNode = '科技发展部审批'
+    project.process = project.process.map((step) =>
+      step.name === '专业公司审批'
+        ? { ...step, state: 'done', handler: '专业公司', date: '2026-09-18' }
+        : step.name === '科技发展部审批'
+          ? { ...step, state: 'current', handler: '当前节点' }
+          : step
+    )
+    project.records.unshift({
+      id: Date.now(),
+      action: '专业公司审批通过',
+      operator: '专业公司',
+      date: '2026-09-18',
+      comment: approvalComment.value,
+      type: 'success'
+    })
+  } else {
+    project.status = '已立项'
+    project.process = project.process.map((step) => ({
+      ...step,
+      state: 'done',
+      handler: step.name === '科技发展部审批' ? '科技发展部' : step.handler,
+      date: step.date || '2026-09-18'
+    }))
+    project.records.unshift({
+      id: Date.now(),
+      action: '科技发展部审批通过,项目已立项',
+      operator: '科技发展部',
+      date: '2026-09-18',
+      comment: approvalComment.value,
+      type: 'success'
+    })
+  }
+  approvalComment.value = ''
+  ElMessage.success(`${result}成功`)
+}
+</script>
+
+<style scoped lang="scss">
+.approval-page {
+  min-height: 100%;
+  color: var(--el-text-color-primary);
+}
+.approval-filter {
+  display: flex;
+  align-items: center;
+  margin-bottom: 16px;
+  padding: 20px 20px 0;
+  background: #fff;
+}
+.approval-layout {
+  display: grid;
+  grid-template-columns: 360px minmax(0, 1fr);
+  gap: 16px;
+  align-items: start;
+}
+.approval-list,
+.approval-detail {
+  min-height: 720px;
+}
+.list-title,
+.detail-heading,
+.project-item-top,
+.project-item-bottom,
+.action-buttons {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+.list-title h2,
+.detail-heading h1 {
+  margin: 0;
+}
+.list-title p,
+.detail-heading p {
+  margin: 7px 0 0;
+  color: var(--el-text-color-secondary);
+  font-size: 13px;
+}
+.status-tabs {
+  display: flex;
+  gap: 3px;
+  margin: 22px -8px 8px;
+  overflow-x: auto;
+  border-bottom: 1px solid var(--el-border-color-lighter);
+}
+.status-tabs button {
+  flex: 0 0 auto;
+  padding: 10px 8px;
+  border: 0;
+  border-bottom: 2px solid transparent;
+  background: transparent;
+  color: var(--el-text-color-secondary);
+  cursor: pointer;
+  font-size: 12px;
+}
+.status-tabs button.active {
+  border-bottom-color: var(--el-color-primary);
+  color: var(--el-color-primary);
+}
+.status-tabs span {
+  margin-left: 3px;
+}
+.project-list {
+  display: grid;
+  gap: 8px;
+}
+.project-item {
+  padding: 14px;
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: 4px;
+  background: var(--el-bg-color);
+  text-align: left;
+  cursor: pointer;
+}
+.project-item:hover,
+.project-item.selected {
+  border-color: var(--el-color-primary-light-5);
+  background: var(--el-color-primary-light-9);
+}
+.project-item strong {
+  max-width: 215px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.project-meta,
+.project-item-bottom,
+.file-info span,
+.workflow-step span {
+  color: var(--el-text-color-secondary);
+  font-size: 12px;
+}
+.project-meta {
+  margin: 10px 0;
+}
+.project-item-bottom {
+  font-size: 12px;
+}
+.eyebrow {
+  margin-bottom: 8px;
+  color: var(--el-color-primary);
+  font-size: 12px;
+}
+.detail-heading h1 {
+  font-size: 22px;
+}
+.workflow {
+  display: flex;
+  align-items: flex-start;
+  margin: 28px 0 22px;
+  padding: 18px 20px;
+  background: var(--el-fill-color-light);
+}
+.workflow-step {
+  display: flex;
+  flex: 1;
+  align-items: center;
+  gap: 8px;
+  color: var(--el-text-color-secondary);
+}
+.workflow-step.current,
+.workflow-step.done {
+  color: var(--el-text-color-primary);
+}
+.node {
+  display: grid;
+  width: 28px;
+  height: 28px;
+  flex: 0 0 28px;
+  place-items: center;
+  border-radius: 50%;
+  background: var(--el-border-color);
+  color: #fff;
+}
+.workflow-step.done .node {
+  background: var(--el-color-success);
+}
+.workflow-step.current .node {
+  background: var(--el-color-primary);
+}
+.node-text {
+  display: grid;
+  gap: 4px;
+}
+.workflow-step i {
+  flex: 1;
+  height: 1px;
+  margin: 0 10px;
+  background: var(--el-border-color);
+}
+.budget-summary {
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  gap: 12px;
+  margin: 8px 0 18px;
+}
+.budget-summary div {
+  padding: 16px;
+  border: 1px solid var(--el-border-color-lighter);
+}
+.budget-summary span,
+.budget-summary strong {
+  display: block;
+}
+.budget-summary span {
+  color: var(--el-text-color-secondary);
+  font-size: 13px;
+}
+.budget-summary strong {
+  margin-top: 8px;
+  font-size: 20px;
+}
+.success-text {
+  color: var(--el-color-success);
+}
+.attachment-tip {
+  padding: 10px 12px;
+  background: var(--el-color-primary-light-9);
+  color: var(--el-color-primary);
+  font-size: 13px;
+}
+.attachment-list {
+  margin-top: 14px;
+}
+.attachment-item {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  padding: 14px 4px;
+  border-bottom: 1px solid var(--el-border-color-lighter);
+}
+.file-icon {
+  display: grid;
+  width: 38px;
+  height: 38px;
+  place-items: center;
+  border-radius: 4px;
+  font-size: 20px;
+}
+.file-icon.pdf {
+  background: #fef0f0;
+  color: #f56c6c;
+}
+.file-icon.word {
+  background: #ecf5ff;
+  color: #409eff;
+}
+.file-icon.excel {
+  background: #f0f9eb;
+  color: #67c23a;
+}
+.file-info {
+  display: grid;
+  flex: 1;
+  gap: 5px;
+  min-width: 0;
+}
+.file-info strong {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.approval-action {
+  margin-top: 18px;
+  padding-top: 18px;
+  border-top: 1px solid var(--el-border-color-lighter);
+}
+.approval-action > div:first-child {
+  display: grid;
+  gap: 5px;
+  margin-bottom: 10px;
+}
+.approval-action small {
+  color: var(--el-text-color-secondary);
+}
+.action-buttons {
+  justify-content: flex-end;
+  margin-top: 12px;
+}
+.finished-alert {
+  margin-top: 18px;
+}
+.empty-panel {
+  display: grid;
+  place-items: center;
+}
+@media (max-width: 1100px) {
+  .approval-layout {
+    grid-template-columns: 300px minmax(0, 1fr);
+  }
+  .workflow {
+    min-width: 720px;
+    overflow-x: auto;
+  }
+}
+@media (max-width: 800px) {
+  .approval-layout {
+    grid-template-columns: 1fr;
+  }
+  .approval-list,
+  .approval-detail {
+    min-height: auto;
+  }
+  .budget-summary {
+    grid-template-columns: 1fr;
+  }
+  .detail-heading h1 {
+    font-size: 18px;
+  }
+}
+</style>

+ 2 - 2
src/views/researchProject/breakdown/index.vue

@@ -128,7 +128,7 @@
       >当前父任务:{{ parentTask.name }},最多可添加 {{ MAX_CHILDREN }} 个孙子任务(已添加
       {{ parentTask.children.length }} 个)</el-alert
     >
-    <el-form ref="taskFormRef" :model="taskForm" :rules="taskRules" label-width="92px">
+    <el-form ref="taskFormRef" :model="taskForm" :rules="taskRules" label-width="auto">
       <el-form-item label="任务名称" prop="name"
         ><el-input
           v-model="taskForm.name"
@@ -322,7 +322,7 @@ const openTaskForm = (task?: Task, editing = false) => {
   }
   editingTask.value = editing ? task : undefined
   parentTask.value = !editing && task ? task : undefined
-  taskDialogTitle.value = editing ? '编辑任务' : task ? '新增子任务' : '新增一级任务'
+  taskDialogTitle.value = editing ? '编辑任务' : task ? '新增子任务' : '新增一级任务'
   resetTaskForm(editing && task ? task : createTask(task ? 2 : 1))
   taskDialogVisible.value = true
 }

+ 0 - 0
src/views/researchProject/budget/index.vue


+ 132 - 18
src/views/researchProject/list/index.vue

@@ -1,5 +1,5 @@
 <template>
-  <ContentWrap>
+  <ContentWrap style="border: none">
     <el-form ref="queryFormRef" :model="queryParams" inline class="project-filter">
       <el-form-item label="项目名称" prop="projectName">
         <el-input
@@ -32,7 +32,7 @@
     </el-form>
   </ContentWrap>
 
-  <ContentWrap>
+  <ContentWrap style="border: none">
     <div class="project-table-heading">
       <div><h2>科研项目</h2><span>维护项目申报信息、预算及阶段性计划</span></div>
       <el-tag type="info" effect="plain">共 {{ filteredList.length }} 个项目</el-tag>
@@ -188,21 +188,69 @@
           </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>
+      <div class="budget-editor">
+        <div class="budget-toolbar">
+          <span>请按财务科目录入项目预算,金额需填写到元。</span>
+          <el-button type="primary" plain size="small" @click="addBudgetDetail">
+            <Icon icon="ep:plus" class="mr-5px" />新增预算科目
+          </el-button>
+        </div>
+        <el-table :data="formData.budgetDetails" border class="budget-table">
+          <el-table-column type="index" label="#" width="56" align="center" />
+          <el-table-column label="财务科目" min-width="220">
+            <template #default="{ row }">
+              <el-select
+                v-model="row.subject"
+                filterable
+                allow-create
+                default-first-option
+                class="w-full"
+                placeholder="请选择或输入财务科目"
+              >
+                <el-option
+                  v-for="item in budgetItems"
+                  :key="item.key"
+                  :label="item.label"
+                  :value="item.label"
+                />
+              </el-select>
+            </template>
+          </el-table-column>
+          <el-table-column label="预算金额(元)" width="220" align="right">
+            <template #default="{ row }">
+              <el-input-number
+                v-model="row.amount"
+                :min="0"
+                :precision="2"
+                controls-position="right"
+                class="w-full"
+              />
+            </template>
+          </el-table-column>
+          <el-table-column label="预算说明" min-width="260">
+            <template #default="{ row }">
+              <el-input v-model="row.remark" maxlength="100" placeholder="请输入该科目的预算用途" />
+            </template>
+          </el-table-column>
+          <el-table-column label="操作" width="90" align="center">
+            <template #default="{ $index }">
+              <el-button
+                link
+                type="danger"
+                :disabled="formData.budgetDetails.length === 1"
+                @click="removeBudgetDetail($index)"
+              >
+                <Icon icon="ep:delete" />
+              </el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+        <div class="budget-total">
+          <span>预算合计</span><strong>{{ budgetTotal.toFixed(2) }}</strong
+          ><span>元</span>
+        </div>
+      </div>
       <el-divider content-position="left">附件上传</el-divider>
       <el-form-item label="立项申请表" required
         ><UploadFile
@@ -256,6 +304,7 @@ import { ElMessage, ElMessageBox, type FormRules } from 'element-plus'
 
 defineOptions({ name: 'ResearchProjectList' })
 type ScheduleItem = { workPlan: string; deadline: string }
+type BudgetDetail = { subject: string; amount: number; remark: string }
 type Project = {
   id: number
   projectLevel: string
@@ -268,6 +317,7 @@ type Project = {
   researchContent: string
   innovation: string
   schedule: ScheduleItem[]
+  budgetDetails: BudgetDetail[]
   budget: Record<string, number>
   attachments: { application: any[]; report: any[]; others: any[] }
   status: string
@@ -297,6 +347,12 @@ const budgetItems = [
   { key: 'other', label: '其他' }
 ]
 const createSchedule = (): ScheduleItem => ({ workPlan: '', deadline: '' })
+const createBudgetDetail = (data: Partial<BudgetDetail> = {}): BudgetDetail => ({
+  subject: '',
+  amount: 0,
+  remark: '',
+  ...data
+})
 const createBudget = () =>
   Object.fromEntries(budgetItems.map(({ key }) => [key, 0])) as Record<string, number>
 const createForm = () => ({
@@ -310,6 +366,7 @@ const createForm = () => ({
   researchContent: '',
   innovation: '',
   schedule: [createSchedule(), createSchedule(), createSchedule()],
+  budgetDetails: [createBudgetDetail()],
   budget: createBudget(),
   attachments: { application: [], report: [], others: [] }
 })
@@ -329,6 +386,11 @@ const list = ref<Project[]>([
       { workPlan: '完成方案设计', deadline: '2026-06-30' },
       { workPlan: '完成样机试制', deadline: '2026-10-31' }
     ],
+    budgetDetails: [
+      createBudgetDetail({ subject: '直接材料投入', amount: 180000, remark: '样机材料及加工费用' }),
+      createBudgetDetail({ subject: '检验费', amount: 65000, remark: '高温高压环境测试' }),
+      createBudgetDetail({ subject: '差旅费', amount: 30000, remark: '现场调研与试验支持' })
+    ],
     budget: createBudget(),
     attachments: { application: [], report: [], others: [] },
     status: '进行中'
@@ -362,7 +424,7 @@ const formRules = reactive<FormRules>({
   innovation: [{ required: true, message: '请输入创新点', trigger: 'blur' }]
 })
 const budgetTotal = computed(() =>
-  Object.values(formData.value.budget).reduce((sum, value) => sum + Number(value || 0), 0)
+  formData.value.budgetDetails.reduce((sum, item) => sum + Number(item.amount || 0), 0)
 )
 const handleQuery = () => {
   queryParams.pageNo = 1
@@ -394,6 +456,14 @@ const cloneProjectForm = (project: Project) => ({
   researchContent: project.researchContent,
   innovation: project.innovation,
   schedule: project.schedule.map((item) => ({ ...item })),
+  budgetDetails:
+    project.budgetDetails?.length > 0
+      ? project.budgetDetails.map((item) => ({ ...item }))
+      : budgetItems
+          .filter(({ key }) => Number(project.budget?.[key] || 0) > 0)
+          .map(({ label, key }) =>
+            createBudgetDetail({ subject: label, amount: project.budget[key] })
+          ),
   budget: { ...project.budget },
   attachments: cloneAttachments(project.attachments)
 })
@@ -405,12 +475,24 @@ const openForm = (project?: Project) => {
 }
 const addSchedule = () => formData.value.schedule.push(createSchedule())
 const removeSchedule = (index: number) => formData.value.schedule.splice(index, 1)
+const addBudgetDetail = () => formData.value.budgetDetails.push(createBudgetDetail())
+const removeBudgetDetail = (index: number) => formData.value.budgetDetails.splice(index, 1)
 const submitForm = async () => {
   const valid = await formRef.value?.validate().catch(() => false)
   if (!valid) return
+  if (formData.value.budgetDetails.some((item) => !item.subject.trim())) {
+    ElMessage.warning('请完善预算科目后再保存')
+    return
+  }
   submitLoading.value = true
   const project = {
     ...formData.value,
+    budget: Object.fromEntries(
+      formData.value.budgetDetails.map((item, index) => [
+        item.subject || `subject_${index}`,
+        item.amount
+      ])
+    ),
     id: editingId.value || Date.now(),
     status: '进行中'
   } as Project
@@ -473,10 +555,42 @@ const removeProject = async (id: number) => {
   color: var(--el-text-color-secondary);
   font-size: 13px;
 }
+.budget-editor {
+  width: 100%;
+}
+.budget-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-total {
+  display: flex;
+  justify-content: flex-end;
+  align-items: baseline;
+  gap: 8px;
+  padding: 14px 8px 0;
+  color: var(--el-text-color-secondary);
+}
+.budget-total strong {
+  color: var(--el-color-primary);
+  font-size: 22px;
+}
 @media (max-width: 900px) {
   .budget-grid :deep(.el-col) {
     width: 50%;
     flex: 0 0 50%;
   }
+  .budget-table {
+    min-width: 760px;
+  }
+  .budget-editor {
+    overflow-x: auto;
+  }
 }
 </style>

+ 725 - 0
src/views/researchProject/review/index.vue

@@ -0,0 +1,725 @@
+<template>
+  <div class="review-page">
+    <div class="review-filter" style="border: none">
+      <el-form :model="queryParams" inline>
+        <el-form-item label="项目名称">
+          <el-input
+            v-model="queryParams.projectName"
+            clearable
+            placeholder="请输入项目名称"
+            class="!w-240px"
+          />
+        </el-form-item>
+        <el-form-item label="项目类型">
+          <el-select
+            v-model="queryParams.projectType"
+            clearable
+            placeholder="请选择项目类型"
+            class="!w-210px"
+          >
+            <el-option v-for="item in projectTypes" :key="item" :label="item" :value="item" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="提交时间">
+          <el-date-picker
+            v-model="queryParams.submitDate"
+            type="date"
+            value-format="YYYY-MM-DD"
+            clearable
+            placeholder="请选择日期"
+          />
+        </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>
+    </div>
+
+    <div class="review-layout">
+      <ContentWrap class="project-list-panel" style="border: none">
+        <div class="status-tabs">
+          <button
+            v-for="item in statusTabs"
+            :key="item.value"
+            :class="{ active: activeStatus === item.value }"
+            @click="changeStatus(item.value)"
+          >
+            {{ item.label }} <span>{{ statusCount(item.value) }}</span>
+          </button>
+        </div>
+        <div class="project-list">
+          <button
+            v-for="project in pagedProjects"
+            :key="project.id"
+            class="project-item"
+            :class="{ selected: selectedProject?.id === project.id }"
+            @click="selectProject(project)"
+          >
+            <div class="project-item-top">
+              <strong>{{ project.projectName }}</strong>
+              <el-tag :type="statusType(project.status)" size="small">{{ project.status }}</el-tag>
+            </div>
+            <div class="project-meta">{{ project.projectType }} · {{ project.projectLeader }}</div>
+            <div class="project-item-bottom"
+              ><span>提交于 {{ project.submitDate }}</span
+              ><span>{{ project.attachments.length }} 个附件</span></div
+            >
+          </button>
+          <el-empty v-if="!filteredProjects.length" description="暂无待审项目" :image-size="72" />
+        </div>
+        <Pagination
+          v-if="filteredProjects.length"
+          v-model:page="pagination.pageNo"
+          v-model:limit="pagination.pageSize"
+          :total="filteredProjects.length"
+          :page-sizes="[5, 10, 20]"
+          layout="total, prev, pager, next"
+          @pagination="handlePagination"
+        />
+      </ContentWrap>
+
+      <ContentWrap v-if="selectedProject" class="review-detail-panel" style="border: none">
+        <!-- <div class="process-strip">
+          <div
+            v-for="(step, index) in selectedProject.process"
+            :key="step.name"
+            class="process-step"
+            :class="step.state"
+          >
+            <div class="step-icon"
+              ><Icon
+                :icon="
+                  step.state === 'done'
+                    ? 'ep:check'
+                    : step.state === 'current'
+                      ? 'ep:clock'
+                      : 'ep:more'
+                "
+            /></div>
+            <div
+              ><strong>{{ step.name }}</strong
+              ><span
+                >{{ step.handler }}<template v-if="step.date"> · {{ step.date }}</template></span
+              ></div
+            >
+            <i v-if="index < selectedProject.process.length - 1"></i>
+          </div>
+        </div> -->
+
+        <el-tabs v-model="activeTab" class="detail-tabs">
+          <el-tab-pane label="项目基本信息" name="info">
+            <el-descriptions :column="2" border class="info-table">
+              <el-descriptions-item label="项目名称" :span="2">{{
+                selectedProject.projectName
+              }}</el-descriptions-item>
+              <el-descriptions-item label="项目级别">{{
+                selectedProject.projectLevel
+              }}</el-descriptions-item>
+              <el-descriptions-item label="项目类型">{{
+                selectedProject.projectType
+              }}</el-descriptions-item>
+              <el-descriptions-item label="执行单位">{{
+                selectedProject.executingUnit
+              }}</el-descriptions-item>
+              <el-descriptions-item label="项目负责人">{{
+                selectedProject.projectLeader
+              }}</el-descriptions-item>
+              <el-descriptions-item label="项目周期">{{
+                selectedProject.period
+              }}</el-descriptions-item>
+              <el-descriptions-item label="申报部门">{{
+                selectedProject.department
+              }}</el-descriptions-item>
+              <el-descriptions-item label="项目目标" :span="2">{{
+                selectedProject.purpose
+              }}</el-descriptions-item>
+              <el-descriptions-item label="主要研究内容" :span="2">{{
+                selectedProject.content
+              }}</el-descriptions-item>
+            </el-descriptions>
+          </el-tab-pane>
+          <el-tab-pane label="预算摘要" name="budget">
+            <div class="budget-summary">
+              <div
+                ><span>预算总额</span
+                ><strong>¥ {{ formatAmount(selectedProject.budgetTotal) }}</strong></div
+              >
+              <div
+                ><span>预算科目</span><strong>{{ selectedProject.budgets.length }} 项</strong></div
+              >
+              <div><span>预算完整性</span><strong class="success-text">已填写</strong></div>
+            </div>
+            <el-table :data="selectedProject.budgets" border>
+              <el-table-column type="index" label="#" width="56" align="center" />
+              <el-table-column prop="subject" label="财务科目" min-width="220" />
+              <el-table-column prop="amount" label="预算金额(元)" width="180" align="right"
+                ><template #default="{ row }">{{
+                  formatAmount(row.amount)
+                }}</template></el-table-column
+              >
+              <el-table-column
+                prop="remark"
+                label="预算说明"
+                min-width="260"
+                show-overflow-tooltip
+              />
+            </el-table>
+          </el-tab-pane>
+          <el-tab-pane label="附件材料" name="attachments">
+            <div class="attachment-tip"
+              ><Icon icon="ep:info-filled" />
+              支持在线查看文件信息,点击“预览”将在新窗口打开文件。</div
+            >
+            <div class="attachment-list">
+              <div
+                v-for="file in selectedProject.attachments"
+                :key="file.name"
+                class="attachment-item"
+              >
+                <div class="file-icon" :class="file.kind"><Icon :icon="fileIcon(file.kind)" /></div>
+                <div class="file-info"
+                  ><strong>{{ file.name }}</strong
+                  ><span>{{ file.size }} · {{ file.uploadedBy }} · {{ file.uploadedAt }}</span></div
+                >
+                <el-button link type="primary" @click="previewFile(file)">预览</el-button>
+                <el-button link type="primary" @click="downloadFile(file)">下载</el-button>
+              </div>
+            </div>
+          </el-tab-pane>
+        </el-tabs>
+
+        <div class="review-action">
+          <div
+            ><span>预审意见</span><small>请核对项目目标、研究内容、预算及附件的完整性</small></div
+          >
+          <el-input
+            v-model="reviewComment"
+            type="textarea"
+            :rows="3"
+            maxlength="500"
+            show-word-limit
+            placeholder="请输入审批意见,退回时必须填写"
+          />
+          <div class="action-buttons">
+            <el-button type="danger" plain @click="submitReview('退回修改')"
+              ><Icon icon="ep:back" class="mr-5px" />退回修改</el-button
+            >
+            <el-button type="primary" @click="submitReview('预审通过')"
+              ><Icon icon="ep:circle-check" class="mr-5px" />预审通过</el-button
+            >
+          </div>
+        </div>
+      </ContentWrap>
+      <ContentWrap v-else class="review-detail-panel empty-panel"
+        ><el-empty description="请选择需要预审的项目"
+      /></ContentWrap>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, reactive, ref } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+
+defineOptions({ name: 'ResearchProjectReview' })
+type Status = '待预审' | '预审通过' | '退回修改'
+type Attachment = {
+  name: string
+  kind: 'pdf' | 'word' | 'excel'
+  size: string
+  uploadedBy: string
+  uploadedAt: string
+  url?: string
+}
+type ProcessStep = {
+  name: string
+  handler: string
+  date: string
+  state: 'done' | 'current' | 'pending'
+}
+type Project = {
+  id: number
+  projectNo: string
+  projectName: string
+  projectType: string
+  projectLevel: string
+  projectLeader: string
+  executingUnit: string
+  department: string
+  period: string
+  purpose: string
+  content: string
+  submitDate: string
+  status: Status
+  budgetTotal: number
+  budgets: { subject: string; amount: number; remark: string }[]
+  attachments: Attachment[]
+  process: ProcessStep[]
+}
+const projectTypes = [
+  '基础研究类',
+  '原理方法类',
+  '产品、工具和设备研发类',
+  '成熟工艺技术配套及推广类',
+  '情报调研类'
+]
+const statusTabs = [
+  { label: '全部', value: 'all' },
+  { label: '待预审', value: '待预审' },
+  { label: '已通过', value: '预审通过' },
+  { label: '已退回', value: '退回修改' }
+]
+const createProject = (data: Partial<Project> = {}): Project => ({
+  id: 1,
+  projectNo: 'XM202603001',
+  projectName: '高温高压井下工具可靠性研究',
+  projectType: '产品、工具和设备研发类',
+  projectLevel: '专业公司级/石油技术公司级',
+  projectLeader: '张明远',
+  executingUnit: '瑞恒兴仪 / 四川瑞都',
+  department: '科技发展部',
+  period: '2026-03 至 2027-03',
+  purpose: '提升复杂工况下工具运行可靠性,形成可推广的井下工具评价方法。',
+  content: '开展材料、结构及工艺适应性研究,完成样机试制、环境测试和现场验证。',
+  submitDate: '2026-09-16',
+  status: '待预审',
+  budgetTotal: 275000,
+  budgets: [
+    { subject: '直接材料投入', amount: 180000, remark: '样机材料及加工费用' },
+    { subject: '检验费', amount: 65000, remark: '高温高压环境测试' },
+    { subject: '差旅费', amount: 30000, remark: '现场调研与试验支持' }
+  ],
+  attachments: [
+    {
+      name: '可行性研究报告.pdf',
+      kind: 'pdf',
+      size: '2.4 MB',
+      uploadedBy: '张明远',
+      uploadedAt: '2026-09-16'
+    },
+    {
+      name: '项目实施方案.docx',
+      kind: 'word',
+      size: '856 KB',
+      uploadedBy: '张明远',
+      uploadedAt: '2026-09-16'
+    },
+    {
+      name: '项目预算明细.xlsx',
+      kind: 'excel',
+      size: '128 KB',
+      uploadedBy: '张明远',
+      uploadedAt: '2026-09-16'
+    }
+  ],
+  process: [
+    { name: '项目负责人提交', handler: '张明远', date: '2026-09-16', state: 'done' },
+    { name: '科技发展部预审', handler: '当前节点', date: '', state: 'current' }
+  ],
+  ...data
+})
+const projects = ref<Project[]>([
+  createProject(),
+  createProject({
+    id: 2,
+    projectNo: 'XM202603002',
+    projectName: '智能钻井参数优化方法研究',
+    projectType: '基础研究类',
+    projectLeader: '赵海宁',
+    projectLevel: '集团公司级',
+    budgetTotal: 420000,
+    status: '待预审',
+    submitDate: '2026-09-15',
+    department: '技术中心'
+  }),
+  createProject({
+    id: 3,
+    projectNo: 'XM202602018',
+    projectName: '油气田节能降碳技术应用研究',
+    projectType: '成熟工艺技术配套及推广类',
+    projectLeader: '陈立',
+    status: '预审通过',
+    submitDate: '2026-09-10'
+  })
+])
+const queryParams = reactive({ projectName: '', projectType: '', submitDate: '' })
+const pagination = reactive({ pageNo: 1, pageSize: 5 })
+const activeStatus = ref('all')
+const selectedProject = ref<Project>(projects.value[0])
+const activeTab = ref('info')
+const reviewComment = ref('')
+const filteredProjects = computed(() =>
+  projects.value.filter(
+    (item) =>
+      (!queryParams.projectName || item.projectName.includes(queryParams.projectName)) &&
+      (!queryParams.projectType || item.projectType === queryParams.projectType) &&
+      (!queryParams.submitDate || item.submitDate === queryParams.submitDate) &&
+      (activeStatus.value === 'all' || item.status === activeStatus.value)
+  )
+)
+const pagedProjects = computed(() => {
+  const start = (pagination.pageNo - 1) * pagination.pageSize
+  return filteredProjects.value.slice(start, start + pagination.pageSize)
+})
+const statusCount = (status: string) =>
+  status === 'all'
+    ? projects.value.length
+    : projects.value.filter((item) => item.status === status).length
+const statusType = (status: Status) =>
+  status === '待预审' ? 'warning' : status === '预审通过' ? 'success' : 'danger'
+const selectProject = (project: Project) => {
+  selectedProject.value = project
+  reviewComment.value = ''
+  activeTab.value = 'info'
+}
+const changeStatus = (status: string) => {
+  activeStatus.value = status
+  pagination.pageNo = 1
+  handleQuery()
+}
+const resetQuery = () => {
+  queryParams.projectName = ''
+  queryParams.projectType = ''
+  queryParams.submitDate = ''
+  activeStatus.value = 'all'
+  pagination.pageNo = 1
+}
+const handleQuery = () => {
+  pagination.pageNo = 1
+  if (!filteredProjects.value.includes(selectedProject.value))
+    selectedProject.value = filteredProjects.value[0]
+}
+const handlePagination = () => {
+  const maxPage = Math.max(1, Math.ceil(filteredProjects.value.length / pagination.pageSize))
+  if (pagination.pageNo > maxPage) pagination.pageNo = maxPage
+  if (!filteredProjects.value.includes(selectedProject.value)) {
+    selectedProject.value = pagedProjects.value[0]
+  }
+}
+const formatAmount = (amount: number) =>
+  amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
+const fileIcon = (kind: Attachment['kind']) =>
+  kind === 'pdf' ? 'ep:document' : kind === 'word' ? 'ep:reading' : 'ep:files'
+const previewFile = (file: Attachment) => {
+  if (file.url) window.open(file.url, '_blank')
+  else ElMessage.info(`前端演示:${file.name}暂无在线文件地址`)
+}
+const downloadFile = (file: Attachment) => {
+  if (file.url) {
+    const link = document.createElement('a')
+    link.href = file.url
+    link.download = file.name
+    link.click()
+  } else ElMessage.info(`前端演示:${file.name}暂无下载地址`)
+}
+const submitReview = async (result: '预审通过' | '退回修改') => {
+  if (result === '退回修改' && !reviewComment.value.trim()) {
+    ElMessage.warning('退回修改时必须填写预审意见')
+    return
+  }
+  const confirmed = await ElMessageBox.confirm(`确定${result}该项目吗?`, '提交预审结果', {
+    type: result === '预审通过' ? 'success' : 'warning',
+    confirmButtonText: '确定',
+    cancelButtonText: '取消'
+  })
+    .then(() => true)
+    .catch(() => false)
+  if (!confirmed || !selectedProject.value) return
+  selectedProject.value.status = result
+  selectedProject.value.process = selectedProject.value.process.map((step) => ({
+    ...step,
+    state: 'done',
+    date: step.date || '2026-09-18'
+  }))
+  ElMessage.success(`${result}成功`)
+}
+</script>
+
+<style scoped lang="scss">
+.review-page {
+  min-height: 100%;
+  color: var(--el-text-color-primary);
+}
+.review-filter {
+  margin-bottom: 16px;
+  background-color: #fff;
+  display: flex;
+  padding: 20px;
+  padding-top: 15px;
+  padding-bottom: 0px;
+  align-items: center;
+}
+.review-layout {
+  display: grid;
+  grid-template-columns: 360px minmax(0, 1fr);
+  gap: 16px;
+  align-items: start;
+}
+.project-list-panel,
+.review-detail-panel {
+  min-height: 680px;
+}
+.panel-heading,
+.detail-heading,
+.project-item-top,
+.project-item-bottom,
+.action-buttons {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+.panel-heading h2,
+.detail-heading h1 {
+  margin: 0;
+}
+.panel-heading p,
+.detail-heading p {
+  margin: 7px 0 0;
+  color: var(--el-text-color-secondary);
+  font-size: 13px;
+}
+.status-tabs {
+  display: flex;
+  gap: 4px;
+  margin: 0px -8px 8px;
+  border-bottom: 1px solid var(--el-border-color-lighter);
+}
+.status-tabs button {
+  padding: 10px 9px;
+  border: 0;
+  border-bottom: 2px solid transparent;
+  background: transparent;
+  color: var(--el-text-color-secondary);
+  cursor: pointer;
+  font-size: 13px;
+}
+.status-tabs button.active {
+  border-bottom-color: var(--el-color-primary);
+  color: var(--el-color-primary);
+}
+.status-tabs span {
+  margin-left: 3px;
+  font-size: 12px;
+}
+.project-list {
+  display: grid;
+  gap: 8px;
+}
+.project-item {
+  padding: 14px;
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: 4px;
+  background: var(--el-bg-color);
+  text-align: left;
+  cursor: pointer;
+  transition:
+    border-color 0.2s,
+    background 0.2s;
+}
+.project-item:hover,
+.project-item.selected {
+  border-color: var(--el-color-primary-light-5);
+  background: var(--el-color-primary-light-9);
+}
+.project-item strong {
+  max-width: 215px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.project-meta,
+.project-item-bottom,
+.file-info span,
+.process-step span {
+  color: var(--el-text-color-secondary);
+  font-size: 12px;
+}
+.project-meta {
+  margin: 10px 0;
+}
+.project-item-bottom {
+  font-size: 12px;
+}
+.eyebrow {
+  margin-bottom: 8px;
+  color: var(--el-color-primary);
+  font-size: 12px;
+}
+.detail-heading h1 {
+  font-size: 22px;
+}
+.process-strip {
+  display: flex;
+  align-items: flex-start;
+  margin: 10px 0 22px;
+  padding: 18px 20px;
+  background: var(--el-fill-color-light);
+}
+.process-step {
+  display: flex;
+  flex: 1;
+  align-items: center;
+  gap: 8px;
+  position: relative;
+  color: var(--el-text-color-secondary);
+}
+.process-step > div:not(.step-icon) {
+  display: grid;
+  gap: 4px;
+}
+.process-step.done,
+.process-step.current {
+  color: var(--el-text-color-primary);
+}
+.step-icon {
+  display: grid;
+  width: 28px;
+  height: 28px;
+  place-items: center;
+  border-radius: 50%;
+  background: var(--el-border-color);
+  color: white;
+}
+.process-step.done .step-icon {
+  background: var(--el-color-success);
+}
+.process-step.current .step-icon {
+  background: var(--el-color-primary);
+}
+.process-step i {
+  flex: 1;
+  height: 1px;
+  margin: 0 14px;
+  background: var(--el-border-color);
+}
+.detail-tabs {
+  min-height: 330px;
+}
+.info-table {
+  margin-top: 8px;
+}
+.budget-summary {
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  gap: 12px;
+  margin: 8px 0 18px;
+}
+.budget-summary div {
+  padding: 16px;
+  border: 1px solid var(--el-border-color-lighter);
+}
+.budget-summary span,
+.budget-summary strong {
+  display: block;
+}
+.budget-summary span {
+  color: var(--el-text-color-secondary);
+  font-size: 13px;
+}
+.budget-summary strong {
+  margin-top: 8px;
+  font-size: 20px;
+}
+.success-text {
+  color: var(--el-color-success);
+}
+.attachment-tip {
+  padding: 10px 12px;
+  background: var(--el-color-primary-light-9);
+  color: var(--el-color-primary);
+  font-size: 13px;
+}
+.attachment-list {
+  margin-top: 14px;
+}
+.attachment-item {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  padding: 14px 4px;
+  border-bottom: 1px solid var(--el-border-color-lighter);
+}
+.file-icon {
+  display: grid;
+  width: 38px;
+  height: 38px;
+  place-items: center;
+  border-radius: 4px;
+  font-size: 20px;
+}
+.file-icon.pdf {
+  background: #fef0f0;
+  color: #f56c6c;
+}
+.file-icon.word {
+  background: #ecf5ff;
+  color: #409eff;
+}
+.file-icon.excel {
+  background: #f0f9eb;
+  color: #67c23a;
+}
+.file-info {
+  display: grid;
+  flex: 1;
+  gap: 5px;
+  min-width: 0;
+}
+.file-info strong {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.review-action {
+  margin-top: 16px;
+  padding-top: 18px;
+  border-top: 1px solid var(--el-border-color-lighter);
+}
+.review-action > div:first-child {
+  display: grid;
+  gap: 5px;
+  margin-bottom: 10px;
+}
+.review-action small {
+  color: var(--el-text-color-secondary);
+}
+.action-buttons {
+  justify-content: flex-end;
+  margin-top: 12px;
+}
+.empty-panel {
+  display: grid;
+  place-items: center;
+}
+@media (max-width: 1100px) {
+  .review-layout {
+    grid-template-columns: 300px minmax(0, 1fr);
+  }
+  .process-strip {
+    overflow-x: auto;
+    min-width: 620px;
+  }
+}
+@media (max-width: 800px) {
+  .review-layout {
+    grid-template-columns: 1fr;
+  }
+  .project-list-panel,
+  .review-detail-panel {
+    min-height: auto;
+  }
+  .budget-summary {
+    grid-template-columns: 1fr;
+  }
+  .detail-heading {
+    align-items: flex-start;
+    gap: 12px;
+  }
+  .detail-heading h1 {
+    font-size: 18px;
+  }
+}
+</style>