Explorar o código

专家库样式优化

yanghao hai 3 semanas
pai
achega
cffb9adfdc
Modificáronse 3 ficheiros con 2186 adicións e 168 borrados
  1. 1 1
      .env.local
  2. 214 167
      src/views/expert/list/index.vue
  3. 1971 0
      src/views/expert/list/index2.vue

+ 1 - 1
.env.local

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

+ 214 - 167
src/views/expert/list/index.vue

@@ -1,11 +1,22 @@
 <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 />
+    <div class="search-layout">
+      <section class="filter-panel">
+        <div class="filter-fields">
+          <div class="filter-field filter-field-name">
+            <span class="filter-label">按姓名搜索</span>
+            <el-input
+              size="small"
+              v-model="queryParams.expertName"
+              placeholder="输入专家姓名..."
+              clearable
+            >
+              <template #prefix><Icon icon="ep:search" /></template>
+            </el-input>
+          </div>
 
+          <div class="filter-field">
+            <span class="filter-label">按学历搜索</span>
             <el-select v-model="queryParams.education" placeholder="请选择专家学历" clearable>
               <el-option
                 v-for="dict in getStrDictOptions(DICT_TYPE.RD_EXPERT_EDUCATION)"
@@ -14,7 +25,10 @@
                 :value="dict.value"
               />
             </el-select>
+          </div>
 
+          <div class="filter-field">
+            <span class="filter-label">按梯队搜索</span>
             <el-select v-model="queryParams.echelon" placeholder="请选择专家梯队" clearable>
               <el-option
                 v-for="dict in getStrDictOptions(DICT_TYPE.RD_EXPERT_ECHELON)"
@@ -23,24 +37,27 @@
                 :value="dict.value"
               />
             </el-select>
+          </div>
 
-            <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 class="filter-actions">
+            <el-button color="#1e3a8a" type="primary" @click="handleAdd">
+              <Icon icon="ep:plus" class="mr-5px" />新增专家
+            </el-button>
+            <el-button @click="getList"> <Icon icon="ep:search" class="mr-5px" />搜索 </el-button>
+            <el-button @click="handleReset">
+              <Icon icon="ep:refresh" class="mr-5px" />重置
+            </el-button>
           </div>
-        </section>
+        </div>
 
         <aside class="word-cloud-panel">
+          <div class="cloud-heading">
+            <div>
+              <span class="cloud-title">词云搜索</span>
+              <span class="cloud-hint">点击标签快速筛选专家专长</span>
+            </div>
+            <span class="cloud-refresh">↻ 换一批</span>
+          </div>
           <div class="word-cloud">
             <button
               v-for="word in wordCloudWords"
@@ -48,12 +65,9 @@
               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)`
+                color: word.color
               }"
               @click="handleWordClick(word.text)"
             >
@@ -61,59 +75,58 @@
             </button>
           </div>
         </aside>
+      </section>
+    </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>
 
-      <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>
 
-        <table class="content-table" :aria-label="row.teamName">
-          <colgroup>
-            <col
-              v-for="(_, index) in row.columnWidths || []"
+        <tbody>
+          <tr>
+            <td
+              v-for="(cell, index) in row.cells || []"
               :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>
-
+              :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-drawer
       v-model="drawerVisible"
       size="56%"
@@ -1231,44 +1244,17 @@ onMounted(async () => {
 }
 
 .search-layout {
-  display: grid;
-  grid-template-columns: minmax(220px, 0.34fr) minmax(0, 1fr);
-  gap: 18px;
-  margin-bottom: 15px;
+  margin-bottom: 18px;
 }
 
-.filter-panel,
-.word-cloud-panel {
+.filter-panel {
   min-width: 0;
-  padding: 12px;
-  border: 1px solid rgba(28, 44, 62, 0.1);
-  border-radius: 8px;
+  padding: 28px 29px 26px;
+  border: 1px solid #dfe6ef;
+  border-radius: 10px;
   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;
+  background: #fff;
+  box-shadow: 0 2px 4px rgba(34, 57, 86, 0.06);
 }
 
 .panel-kicker {
@@ -1280,95 +1266,110 @@ onMounted(async () => {
   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;
+.filter-fields {
+  display: grid;
+  grid-template-columns: minmax(280px, 1.2fr) minmax(180px, 0.86fr) minmax(180px, 0.86fr) auto;
+  align-items: end;
+  gap: 26px;
 }
 
-.panel-description {
-  margin: 8px 0 18px;
-  color: var(--muted-ink);
-  font-size: 12px;
-  line-height: 1.65;
+.filter-field {
+  min-width: 0;
 }
 
-.filter-fields {
-  display: flex;
-  flex-direction: column;
-  gap: 10px;
+.filter-label {
+  display: block;
+  margin-bottom: 9px;
+  color: #5d6f89;
+  font-size: 14px;
+  font-weight: 700;
+}
+
+.filter-fields :deep(.el-input),
+.filter-fields :deep(.el-select) {
+  width: 100%;
 }
 
 .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;
+  min-height: 32px;
+  border-radius: 8px;
+  background: #fff;
+  box-shadow: 0 0 0 1px #dce4ee inset;
+  transition: box-shadow 180ms ease;
 }
 
+.filter-fields :deep(.el-input__wrapper:hover),
+.filter-fields :deep(.el-select__wrapper:hover),
 .filter-fields :deep(.el-input__wrapper.is-focus),
 .filter-fields :deep(.el-select__wrapper.is-focused) {
-  box-shadow: 0 0 0 1px #467da4 inset;
+  box-shadow: 0 0 0 1px #234395 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;
+.filter-fields :deep(.el-input__inner),
+.filter-fields :deep(.el-select__placeholder),
+.filter-fields :deep(.el-select__selected-item) {
+  color: #365379;
+  font-size: 16px;
 }
 
-.status-dot {
-  width: 6px;
-  height: 6px;
-  border-radius: 50%;
-  background: #6f9878;
-  box-shadow: 0 0 0 4px rgba(111, 152, 120, 0.13);
+.filter-fields :deep(.el-input__inner::placeholder) {
+  color: #9aabc2;
+}
+
+.filter-fields :deep(.el-input__prefix) {
+  color: #91a5c0;
+  font-size: 20px;
 }
 
 .cloud-heading {
-  position: relative;
-  z-index: 1;
   display: flex;
-  align-items: flex-start;
+  align-items: center;
   justify-content: space-between;
-  gap: 20px;
+  padding-top: 15px;
+  margin-top: 16px;
+  border-top: 1px solid #e4eaf2;
+}
+
+.cloud-title {
+  color: #526783;
+  font-size: 14px;
+  font-weight: 700;
 }
 
 .cloud-hint {
-  padding-top: 18px;
-  color: #9aa6b3;
-  font-size: 11px;
+  margin-left: 10px;
+  color: #bdc9d8;
+  font-size: 14px;
+}
+
+.cloud-refresh {
+  border: 0;
+  padding: 0;
+  color: #234395;
+  background: transparent;
+  cursor: pointer;
+  font-size: 14px;
 }
 
 .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;
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 12px;
+  padding-top: 17px;
 }
 
 .cloud-word {
-  position: absolute;
+  position: static;
   border: 0;
-  padding: 0;
-  background: transparent;
+  border-radius: 999px;
+  padding: 10px 18px;
+  background: #f1f5fa;
   cursor: pointer;
-  // font-weight: 600;
+  font-weight: 500;
   letter-spacing: -0.03em;
-  line-height: 1;
+  line-height: 1.15;
   white-space: nowrap;
   transform-origin: center;
   transition:
@@ -1378,16 +1379,37 @@ onMounted(async () => {
 }
 
 .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));
+  opacity: 0.82;
+  text-shadow: none;
+  transform: translateY(-2px) scale(1.03);
 }
 
 .cloud-word:focus-visible {
-  outline: 2px solid #a15e3f;
+  outline: 2px solid #234395;
   outline-offset: 5px;
 }
 
+.cloud-word:nth-child(1),
+.cloud-word:nth-child(6) {
+  background: #edf8f3;
+}
+
+.cloud-word:nth-child(2),
+.cloud-word:nth-child(5),
+.cloud-word:nth-child(8) {
+  background: #edf5ff;
+}
+
+.cloud-word:nth-child(3),
+.cloud-word:nth-child(4) {
+  background: #fff8e8;
+}
+
+.cloud-word:nth-child(7),
+.cloud-word:nth-child(9) {
+  background: #fff0f2;
+}
+
 .table-divider {
   display: flex;
   align-items: center;
@@ -1694,11 +1716,36 @@ onMounted(async () => {
   }
 
   .search-layout {
+    width: 100%;
+  }
+
+  .filter-panel {
+    padding: 20px 16px;
+  }
+
+  .filter-fields {
     grid-template-columns: 1fr;
+    gap: 16px;
+  }
+
+  .filter-actions {
+    display: flex;
+    flex-wrap: wrap;
+  }
+
+  .cloud-heading {
+    align-items: flex-start;
+    flex-direction: column;
+    gap: 12px;
+  }
+
+  .cloud-hint {
+    display: block;
+    margin: 6px 0 0;
   }
 
   .word-cloud {
-    min-height: 160px;
+    gap: 8px;
   }
 
   .intro-meta {

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

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