Эх сурвалжийг харах

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

yanghao 1 сар өмнө
parent
commit
31527b26e6

+ 2 - 0
package.json

@@ -29,6 +29,7 @@
     "@form-create/designer": "^3.2.6",
     "@form-create/element-ui": "^3.2.11",
     "@iconify/iconify": "^3.1.1",
+    "@iconify/vue": "^5.0.1",
     "@microsoft/fetch-event-source": "^2.0.1",
     "@videojs-player/vue": "^1.0.0",
     "@vuepic/vue-datepicker": "^14.0.0",
@@ -75,6 +76,7 @@
     "video.js": "^7.21.5",
     "vue": "3.5.12",
     "vue-dompurify-html": "^4.1.4",
+    "vue-draggable-plus": "^0.6.1",
     "vue-i18n": "9.10.2",
     "vue-router": "4.4.5",
     "vue-types": "^5.1.1",

+ 34 - 0
pnpm-lock.yaml

@@ -20,6 +20,9 @@ importers:
       '@iconify/iconify':
         specifier: ^3.1.1
         version: 3.1.1
+      '@iconify/vue':
+        specifier: ^5.0.1
+        version: 5.0.1(vue@3.5.12(typescript@5.3.3))
       '@microsoft/fetch-event-source':
         specifier: ^2.0.1
         version: 2.0.1
@@ -158,6 +161,9 @@ importers:
       vue-dompurify-html:
         specifier: ^4.1.4
         version: 4.1.4(vue@3.5.12(typescript@5.3.3))
+      vue-draggable-plus:
+        specifier: ^0.6.1
+        version: 0.6.1(@types/sortablejs@1.15.9)
       vue-i18n:
         specifier: 9.10.2
         version: 9.10.2(vue@3.5.12(typescript@5.3.3))
@@ -1295,6 +1301,11 @@ packages:
   '@iconify/utils@2.1.33':
     resolution: {integrity: sha512-jP9h6v/g0BIZx0p7XGJJVtkVnydtbgTgt9mVNcGDYwaa7UhdHdI9dvoq+gKj9sijMSJKxUPEG2JyjsgXjxL7Kw==}
 
+  '@iconify/vue@5.0.1':
+    resolution: {integrity: sha512-aumwwooJlFJ5H5qYWB6ZTAyM0C8hpfcSVLB9/a3qnH1GGvIJ+FEbpEs4s/HfErYe/M5qZeLjwmESR5fFm3lXEw==}
+    peerDependencies:
+      vue: '>=3.0.0'
+
   '@intlify/bundle-utils@7.5.1':
     resolution: {integrity: sha512-UovJl10oBIlmYEcWw+VIHdKY5Uv5sdPG0b/b6bOYxGLln3UwB75+2dlc0F3Fsa0RhoznQ5Rp589/BZpABpE4Xw==}
     engines: {node: '>= 14.16'}
@@ -1848,6 +1859,9 @@ packages:
   '@types/semver@7.5.8':
     resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==}
 
+  '@types/sortablejs@1.15.9':
+    resolution: {integrity: sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==}
+
   '@types/trusted-types@2.0.7':
     resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
 
@@ -5032,6 +5046,15 @@ packages:
     peerDependencies:
       vue: ^2.7.0 || ^3.0.0
 
+  vue-draggable-plus@0.6.1:
+    resolution: {integrity: sha512-FbtQ/fuoixiOfTZzG3yoPl4JAo9HJXRHmBQZFB9x2NYCh6pq0TomHf7g5MUmpaDYv+LU2n6BPq2YN9sBO+FbIg==}
+    peerDependencies:
+      '@types/sortablejs': ^1.15.0
+      '@vue/composition-api': '*'
+    peerDependenciesMeta:
+      '@vue/composition-api':
+        optional: true
+
   vue-eslint-parser@9.4.3:
     resolution: {integrity: sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==}
     engines: {node: ^14.17.0 || >=16.0.0}
@@ -6372,6 +6395,11 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
+  '@iconify/vue@5.0.1(vue@3.5.12(typescript@5.3.3))':
+    dependencies:
+      '@iconify/types': 2.0.0
+      vue: 3.5.12(typescript@5.3.3)
+
   '@intlify/bundle-utils@7.5.1(vue-i18n@9.10.2(vue@3.5.12(typescript@5.3.3)))':
     dependencies:
       '@intlify/message-compiler': 9.14.2
@@ -6877,6 +6905,8 @@ snapshots:
 
   '@types/semver@7.5.8': {}
 
+  '@types/sortablejs@1.15.9': {}
+
   '@types/trusted-types@2.0.7':
     optional: true
 
@@ -10466,6 +10496,10 @@ snapshots:
     transitivePeerDependencies:
       - '@vue/composition-api'
 
+  vue-draggable-plus@0.6.1(@types/sortablejs@1.15.9):
+    dependencies:
+      '@types/sortablejs': 1.15.9
+
   vue-eslint-parser@9.4.3(eslint@8.57.1):
     dependencies:
       debug: 4.3.7

+ 31 - 0
src/api/technology‌/index.ts

@@ -0,0 +1,31 @@
+import request from '@/config/axios'
+
+// 核心技术
+export const getTechnologyList = (params) => {
+  return request.get({ url: '/rd/core-technology/page', params })
+}
+
+// 新建核心技术
+export const createTechnology = (data: any) => {
+  return request.post({ url: '/rd/core-technology/create', data })
+}
+
+// 更新核心技术
+export const updateTechnology = (data: any) => {
+  return request.put({ url: '/rd/core-technology/update', data })
+}
+
+// 核心技术详情
+export const getTechnologyDetail = (id: number) => {
+  return request.get({ url: `/rd/core-technology/get?id=${id}` })
+}
+
+// 删除核心技术
+export const deleteTechnology = (id: number) => {
+  return request.delete({ url: `/rd/core-technology/delete?id=${id}` })
+}
+
+// 导出核心技术
+export const exportTechnology = (params) => {
+  return request.get({ url: '/rd/core-technology/export', params, responseType: 'blob' })
+}

+ 230 - 0
src/components/DeptTreeSelect/index.vue

@@ -0,0 +1,230 @@
+<script lang="ts" setup>
+import { defaultProps, handleTree } from '@/utils/tree'
+import { ElTree } from 'element-plus'
+import * as DeptApi from '@/api/system/dept'
+import { Search, CaretLeft, CaretRight } from '@element-plus/icons-vue'
+
+interface Tree {
+  id: number
+  name: string
+  children?: Tree[]
+  sort?: number
+}
+
+type RequestApi = 'specifiedSimpleDepts' | 'getSimpleDeptList'
+
+const props = defineProps({
+  deptId: { type: Number, required: true },
+  modelValue: { type: Number, default: undefined },
+  topId: { type: Number, required: true },
+  title: { type: String, default: '部门' },
+  initSelect: { type: Boolean, default: true },
+  showTitle: { type: Boolean, default: true },
+  requestApi: {
+    type: String as PropType<RequestApi>,
+    default: 'specifiedSimpleDepts'
+  }
+})
+
+const emits = defineEmits(['update:modelValue', 'node-click'])
+
+// --- 状态控制 ---
+const isCollapsed = ref(false)
+const deptName = ref('')
+const deptList = ref<Tree[]>([])
+const treeRef = ref<InstanceType<typeof ElTree>>()
+const expandedKeys = ref<number[]>([])
+
+// --- 逻辑处理 (保持不变) ---
+const sortTreeBySort = (treeNodes: Tree[]) => {
+  if (!treeNodes || !Array.isArray(treeNodes)) return treeNodes
+  const sortedNodes = [...treeNodes].sort((a, b) => (a.sort ?? 999999) - (b.sort ?? 999999))
+  sortedNodes.forEach((node) => {
+    if (node.children) node.children = sortTreeBySort(node.children)
+  })
+  return sortedNodes
+}
+
+const resolveFallbackId = (depts: Tree[], preferredId: number) => {
+  if (depts.some((item) => item.id === preferredId)) return preferredId
+  if (depts.some((item) => item.id === props.topId)) return props.topId
+  return depts[0]?.id
+}
+
+const loadDeptData = async () => {
+  if (props.requestApi === 'getSimpleDeptList') {
+    const depts = (await DeptApi.getSimpleDeptList()) as Tree[]
+    return {
+      depts,
+      currentId: resolveFallbackId(depts, props.deptId)
+    }
+  }
+
+  let id = props.deptId
+  if (id !== props.topId) {
+    const depts = await DeptApi.specifiedSimpleDepts(props.topId)
+    if (!depts.some((item) => Number(item.id) === Number(props.deptId))) id = props.topId
+  }
+  const depts = (await DeptApi.specifiedSimpleDepts(id)) as Tree[]
+  return {
+    depts,
+    currentId: id
+  }
+}
+
+const loadTree = async () => {
+  try {
+    const { depts, currentId } = await loadDeptData()
+    const targetKey = props.modelValue ?? (props.initSelect ? currentId : null)
+    // Only initialize the parent value when no external selection was provided.
+    if (props.initSelect && props.modelValue === undefined && currentId !== undefined) {
+      emits('update:modelValue', currentId)
+    }
+    if (targetKey && !expandedKeys.value.includes(targetKey)) {
+      expandedKeys.value = [...expandedKeys.value, targetKey]
+    }
+    deptList.value = sortTreeBySort(handleTree(depts))
+    nextTick(() => {
+      if (targetKey && treeRef.value) {
+        treeRef.value.setCurrentKey(targetKey)
+      }
+    })
+  } catch (e) {
+    console.error(e)
+  }
+}
+
+const handleNodeClick = (data: Tree) => {
+  emits('update:modelValue', data.id)
+  emits('node-click', data)
+}
+
+const filterNode = (val: string, data: Tree) => !val || data.name.includes(val)
+
+watch(deptName, (val) => treeRef.value?.filter(val))
+watch(() => [props.deptId, props.topId, props.requestApi], loadTree)
+watch(
+  () => props.modelValue,
+  (val) => {
+    if (val && treeRef.value) treeRef.value.setCurrentKey(val)
+
+    if (val && !expandedKeys.value.includes(val)) {
+      expandedKeys.value.push(val)
+    }
+  },
+  { immediate: true }
+)
+
+onMounted(loadTree)
+</script>
+
+<template>
+  <div
+    class="dept-aside-container relative bg-white dark:bg-[#1d1e1f] shadow rounded-lg transition-all duration-300 ease-in-out overflow-visible"
+    :class="[isCollapsed ? 'is-collapsed' : 'p-4']">
+    <div v-show="!isCollapsed" class="h-full flex flex-col gap-4 overflow-hidden w-full">
+      <h1 v-if="showTitle" class="text-lg font-medium truncate shrink-0">{{ props.title }}</h1>
+
+      <div class="shrink-0">
+        <el-input
+          v-model="deptName"
+          placeholder="请输入部门名称"
+          clearable
+          size="default"
+          :prefix-icon="Search" />
+      </div>
+
+      <div class="flex-1 relative overflow-hidden">
+        <el-auto-resizer class="absolute">
+          <template #default="{ height }">
+            <el-scrollbar :style="{ height: `${height}px` }">
+              <el-tree
+                ref="treeRef"
+                :data="deptList"
+                :props="defaultProps"
+                :expand-on-click-node="false"
+                :filter-node-method="filterNode"
+                node-key="id"
+                highlight-current
+                :current-node-key="modelValue"
+                :default-expanded-keys="expandedKeys"
+                @node-click="handleNodeClick" />
+            </el-scrollbar>
+          </template>
+        </el-auto-resizer>
+      </div>
+    </div>
+
+    <div class="collapse-handle" @click="isCollapsed = !isCollapsed">
+      <el-icon size="12">
+        <CaretLeft v-if="!isCollapsed" />
+        <CaretRight v-else />
+      </el-icon>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.dept-aside-container {
+  /* 关键点 1:初始宽度设为 15% */
+  width: 14vw; /* 或者使用百分比,但在 Grid 容器内使用 vw 更稳定 */
+  height: calc(
+    100vh - 20px - var(--top-tool-height) - var(--tags-view-height) - var(--app-footer-height)
+  );
+  min-width: 200px; /* 防止在极小屏幕下 15% 太窄看不清 */
+  box-sizing: border-box;
+  flex-shrink: 0;
+}
+
+/* 关键点 2:折叠状态 */
+.dept-aside-container.is-collapsed {
+  width: 0 !important;
+  min-width: 0 !important;
+  padding: 0 !important;
+  margin-right: -16px; /* 抵消父级 grid 的 gap-x-4,让右侧内容贴合 */
+  overflow: visible !important;
+  pointer-events: none; /* 折叠后不响应鼠标事件,除了 handle */
+
+  /* opacity: 0; */
+  box-shadow: none;
+}
+
+/* 即使父级折叠,handle 也要可见并可点击 */
+.collapse-handle {
+  position: absolute;
+  top: 50%;
+  right: -14px;
+  z-index: 200;
+  display: flex;
+  width: 14px;
+  height: 60px;
+  color: var(--el-text-color-secondary);
+  pointer-events: auto;
+  cursor: pointer;
+  background-color: var(--el-bg-color);
+  border: 1px solid var(--el-border-color-light);
+  border-left: none;
+  border-radius: 0 12px 12px 0;
+  transform: translateY(-50%);
+  box-shadow: 2px 0 6px rgb(0 0 0 / 5%);
+  transition: right 0.3s;
+  align-items: center;
+  justify-content: center;
+}
+
+.is-collapsed .collapse-handle {
+  right: -8px; /* 在边缘露出一半 */
+  border-left: 1px solid var(--el-border-color-light);
+}
+
+.collapse-handle:hover {
+  color: var(--el-color-primary);
+  background-color: var(--el-fill-color-light);
+}
+
+.truncate {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+</style>

+ 71 - 7
src/components/UploadFile/src/UploadFile.vue

@@ -68,7 +68,7 @@
 </template>
 <script lang="ts" setup>
 import { propTypes } from '@/utils/propTypes'
-import type { UploadInstance, UploadProps, UploadRawFile, UploadUserFile } from 'element-plus'
+import type { UploadProps, UploadRawFile, UploadUserFile } from 'element-plus'
 import { isString } from '@/utils/is'
 import { useUpload } from '@/components/UploadFile/src/useUpload'
 import { UploadFile } from 'element-plus/es/components/upload/src/upload'
@@ -80,6 +80,7 @@ const emit = defineEmits(['update:modelValue'])
 
 const props = defineProps({
   modelValue: propTypes.oneOfType<string | string[]>([String, Array<String>]).isRequired,
+  returnObject: propTypes.bool.def(false), // 是否返回文件元数据对象
   fileType: propTypes.array.def(['doc', 'xls', 'ppt', 'txt', 'pdf']), // 文件类型, 例如['png', 'jpg', 'jpeg']
   fileSize: propTypes.number.def(5), // 大小限制(MB)
   limit: propTypes.number.def(5), // 数量限制
@@ -91,7 +92,6 @@ const props = defineProps({
 })
 
 // ========== 上传相关 ==========
-const uploadRef = ref<UploadInstance>()
 const uploadList = ref<UploadUserFile[]>([])
 const fileList = ref<UploadUserFile[]>([])
 const uploadNumber = ref<number>(0)
@@ -126,17 +126,65 @@ const beforeUpload: UploadProps['beforeUpload'] = (file: UploadRawFile) => {
   uploadNumber.value++
   return true
 }
+
+const getFileUrl = (value: unknown): string => {
+  if (typeof value === 'string') return value
+  if (value && typeof value === 'object') {
+    const file = value as { url?: unknown; path?: unknown; fileUrl?: unknown; filePath?: unknown }
+    if (typeof file.url === 'string') return file.url
+    if (typeof file.path === 'string') return file.path
+    if (typeof file.fileUrl === 'string') return file.fileUrl
+    if (typeof file.filePath === 'string') return file.filePath
+  }
+  return ''
+}
+
+const getFilePath = (value: unknown): string => {
+  if (value && typeof value === 'object') {
+    const file = value as { filePath?: unknown; path?: unknown; url?: unknown }
+    if (typeof file.filePath === 'string') return file.filePath
+    if (typeof file.path === 'string') return file.path
+    if (typeof file.url === 'string') return file.url
+  }
+  return getFileUrl(value)
+}
+
+const getFileMetadata = (value: unknown, rawFile?: UploadRawFile) => ({
+  fileName:
+    rawFile?.name ||
+    (value && typeof value === 'object' && typeof (value as any).fileName === 'string'
+      ? (value as any).fileName
+      : ''),
+  fileType:
+    rawFile?.type ||
+    (value && typeof value === 'object' && typeof (value as any).fileType === 'string'
+      ? (value as any).fileType
+      : ''),
+  fileSize:
+    rawFile?.size ||
+    (value && typeof value === 'object' && typeof (value as any).fileSize === 'number'
+      ? (value as any).fileSize
+      : 0),
+  filePath: getFilePath(value)
+})
+
 // 处理上传的文件发生变化
 // const handleFileChange = (uploadFile: UploadFile): void => {
 //   uploadRef.value.data.path = uploadFile.name
 // }
 // 文件上传成功
-const handleFileSuccess: UploadProps['onSuccess'] = (res: any): void => {
+const handleFileSuccess: UploadProps['onSuccess'] = (res: any, uploadFile): void => {
   message.success('上传成功')
+  const url = getFileUrl(res?.data)
+  if (!url) return
   // 删除自身
-  const index = fileList.value.findIndex((item) => item.response?.data === res.data)
+  const index = fileList.value.findIndex((item) => (item.response as any)?.data === res.data)
   fileList.value.splice(index, 1)
-  uploadList.value.push({ name: res.data, url: res.data })
+  uploadList.value.push({
+    name: uploadFile.name || url.substring(url.lastIndexOf('/') + 1),
+    url,
+    attachment: getFileMetadata(res?.data, uploadFile.raw as UploadRawFile)
+  } as UploadUserFile & { attachment: ReturnType<typeof getFileMetadata> })
   if (uploadList.value.length == uploadNumber.value) {
     fileList.value.push(...uploadList.value)
     uploadList.value = []
@@ -185,15 +233,31 @@ watch(
     }
     // 情况2:数组
     fileList.value.push(
-      ...(val as string[]).map((url) => ({ name: url.substring(url.lastIndexOf('/') + 1), url }))
+      ...((val as any[])
+        .map((item) => {
+          const url = getFileUrl(item)
+          return url
+            ? {
+                name:
+                  (typeof item === 'object' && item.fileName) ||
+                  url.substring(url.lastIndexOf('/') + 1),
+                url,
+                attachment: getFileMetadata(item)
+              }
+            : null
+        })
+        .filter(Boolean) as UploadUserFile[])
     )
   },
   { immediate: true, deep: true }
 )
+
 // 发送文件链接列表更新
 const emitUpdateModelValue = () => {
   // 情况1:数组结果
-  let result: string | string[] = fileList.value.map((file) => file.url!)
+  let result: string | string[] = props.returnObject
+    ? fileList.value.map((file: any) => file.attachment || getFileMetadata(file.url))
+    : fileList.value.map((file) => file.url!)
   // 情况2:逗号分隔的字符串
   if (props.limit === 1 || isString(props.modelValue)) {
     result = result.join(',')

+ 516 - 0
src/components/ZmTable/README.md

@@ -0,0 +1,516 @@
+# ZmTable
+
+`ZmTable` 是基于 Element Plus `el-table` / `el-table-column` 封装的业务表格组件,主要补了这些能力:
+
+- 默认统一表格视觉样式。
+- 自动按内容计算列宽,并支持全局限制最大宽度。
+- 表格级 `align` 统一控制列对齐方式。
+- 列头内置排序、筛选按钮,但排序和筛选逻辑交给调用方自定义。
+- 操作列可以开启列设置面板,支持列显隐、固定左/右、拖拽排序。
+- 支持多级表头,子级表头只能在自己的父级分组内排序。
+- 样式通过少量 CSS 变量开放给外部调整。
+
+## 文件说明
+
+| 文件 | 作用 |
+| --- | --- |
+| `index.vue` | 表格主体,负责数据、默认样式、列配置收集、列顺序/显隐/固定渲染。 |
+| `ZmTableColumn.vue` | 列组件,负责列头按钮、排序、筛选、操作列设置入口、列宽计算。 |
+| `ZmTableColumnSettingTree.vue` | 列设置面板里的递归树,负责拖拽排序、显隐、固定按钮。 |
+| `token.ts` | 表格内部依赖注入类型和 key。 |
+| `useTableComponents.ts` | 给 TS 泛型使用的便捷导出。 |
+
+## 基础用法
+
+推荐在页面里通过 `useTableComponents<T>()` 获取泛型组件,这样 `prop`、`row` 会更容易获得类型提示。
+
+```vue
+<script setup lang="ts">
+import { useTableComponents } from '@/components/ZmTable/useTableComponents'
+
+interface ListItem {
+  id: number
+  name: string
+  status: string
+}
+
+const loading = ref(false)
+const list = ref<ListItem[]>([])
+
+const { ZmTable, ZmTableColumn } = useTableComponents<ListItem>()
+</script>
+
+<template>
+  <ZmTable :data="list" :loading="loading">
+    <ZmTableColumn type="index" label="序号" :width="60" />
+    <ZmTableColumn prop="name" label="名称" />
+    <ZmTableColumn prop="status" label="状态">
+      <template #default="{ row }">
+        <el-tag>{{ row.status }}</el-tag>
+      </template>
+    </ZmTableColumn>
+  </ZmTable>
+</template>
+```
+
+也可以直接用小写组件名:
+
+```vue
+<zm-table :data="list" :loading="loading">
+  <zm-table-column prop="name" label="名称" />
+</zm-table>
+```
+
+## ZmTable Props
+
+`ZmTable` 继承大部分 Element Plus `TableProps`,下面是额外增加或重点关注的属性。
+
+| 属性 | 类型 | 默认值 | 说明 |
+| --- | --- | --- | --- |
+| `data` | `T[]` | 必填 | 表格数据。 |
+| `loading` | `boolean` | 必填 | 表格 loading 状态。 |
+| `align` | `'left' \| 'center' \| 'right'` | `'center'` | 全局列对齐方式。普通列会使用它;开启排序、筛选、操作列时,列头会优先左对齐。 |
+| `columnMaxWidth` | `number` | `360` | 自动计算列宽时的最大宽度。 |
+| `customClass` | `boolean` | `false` | 为 `true` 时不挂载默认 `.zm-table` class,也就不会使用组件默认样式变量。 |
+| `showBorder` | `boolean` | `false` | 控制组件自己的边框显示样式。 |
+| `hoverHighlight` | `boolean` | `true` | 控制鼠标悬浮行时是否显示 hover 背景。 |
+| `showOverflowTooltip` | `boolean` | `true` | 继承自 Element Plus。想让文字换行时传 `false`,再配合页面 CSS 覆盖 `.cell` 的换行样式。 |
+| `settingsCache` | `boolean` | `true` | 是否缓存列设置。当前使用 `localStorage`,缓存列顺序、显隐和固定状态。 |
+| `settingsCacheKey` | `string` | - | 列设置缓存 key。建议业务页面传稳定唯一值,没传时组件会用当前路由和列 key 自动生成。 |
+
+组件内部默认还会给 Element Plus 表格设置这些值:
+
+```ts
+{
+  size: 'default',
+  stripe: true,
+  border: true,
+  highlightCurrentRow: true,
+  showOverflowTooltip: true,
+  scrollbarAlwaysOn: true,
+  tooltipOptions: {
+    popperClass: 'max-w-120'
+  }
+}
+```
+
+## ZmTableColumn Props
+
+`ZmTableColumn` 继承大部分 Element Plus `TableColumnCtx`,下面是额外增加或重点关注的属性。
+
+| 属性 | 类型 | 默认值 | 说明 |
+| --- | --- | --- | --- |
+| `prop` | `keyof T \| string` | - | 字段名,也会作为列设置 key 的优先来源。 |
+| `action` | `boolean` | `false` | 标记为操作列,并在表头显示列设置按钮。操作列会进入列设置列表。 |
+| `hideInColumnSettings` | `boolean` | `false` | 不显示在列设置面板里,适合序号列、选择列等固定功能列。 |
+| `isParent` | `boolean` | `false` | 标记当前列是多级表头父级。需要控制子级列排序/显隐/固定时必须加。 |
+| `zmSortable` | `boolean` | `false` | 显示排序按钮。组件只维护/展示排序状态,具体排序逻辑由调用方处理。 |
+| `zmFilterable` | `boolean` | `false` | 显示筛选按钮和 popover。筛选内容和筛选逻辑由调用方通过 `#filter` 自定义。 |
+| `sortOrder` | `'asc' \| 'desc' \| null` | - | 外部受控排序状态。传了这个值之后,组件不会自己保存排序状态。 |
+| `defaultSortOrder` | `'asc' \| 'desc' \| null` | `null` | 非受控模式下的默认排序状态。 |
+| `zmSortMethod` | `(prop, order) => void` | - | 点击排序按钮后调用。 |
+| `filterActive` | `boolean` | - | 强制控制筛选按钮激活态。不传时根据 `filterModelValue` 自动判断。 |
+| `filterModelValue` | `any` | - | 筛选值,可用 `v-model:filter-model-value` 双向绑定。 |
+| `realValue` | `(...args) => any` | - | 获取真实展示值。自动算宽时会优先使用它;配合 `coverFormatter` 时也会作为 formatter。 |
+| `coverFormatter` | `boolean` | `false` | 为 `true` 时,把 `realValue` 作为 Element Plus 的 `formatter` 使用。 |
+
+## 排序
+
+开启 `zm-sortable` 后,表头只显示排序按钮和排序状态图标:
+
+- 未排序:`i-lucide:arrow-up-down`
+- 升序:`i-lucide:arrow-up-narrow-wide`
+- 降序:`i-lucide:arrow-down-wide-narrow`
+
+点击顺序是:
+
+```txt
+null -> asc -> desc -> null
+```
+
+非受控用法:
+
+```vue
+<ZmTableColumn prop="meetingDate" label="会议日期" zm-sortable :zm-sort-method="handleSort" />
+```
+
+```ts
+function handleSort(prop: string, order: 'asc' | 'desc' | null) {
+  query.value.sortField = prop
+  query.value.sortOrder = order
+  getList()
+}
+```
+
+受控用法:
+
+```vue
+<ZmTableColumn
+  prop="meetingDate"
+  label="会议日期"
+  zm-sortable
+  v-model:sort-order="meetingDateOrder"
+  @sort-change="handleSortChange" />
+```
+
+```ts
+const meetingDateOrder = ref<'asc' | 'desc' | null>(null)
+
+function handleSortChange(payload: { prop: string; order: 'asc' | 'desc' | null }) {
+  query.value.sortField = payload.prop
+  query.value.sortOrder = payload.order
+  getList()
+}
+```
+
+## 筛选
+
+开启 `zm-filterable` 后,表头只显示筛选按钮。popover 里面展示什么、怎么过滤,都由调用方写在 `#filter` 插槽里。
+
+```vue
+<ZmTableColumn
+  prop="status"
+  label="状态"
+  zm-filterable
+  v-model:filter-model-value="query.status"
+  @filter-visible-change="handleFilterVisibleChange">
+  <template #filter="{ filterModelValue, updateFilterModelValue, close }">
+    <div class="p-2">
+      <el-select
+        :model-value="filterModelValue"
+        placeholder="请选择状态"
+        clearable
+        class="w-full"
+        @update:model-value="updateFilterModelValue"
+      >
+        <el-option label="启用" value="enable" />
+        <el-option label="停用" value="disable" />
+      </el-select>
+
+      <div class="mt-2 flex justify-end gap-2">
+        <el-button size="small" @click="updateFilterModelValue(undefined)">清空</el-button>
+        <el-button
+          size="small"
+          type="primary"
+          @click="
+            () => {
+              handleQuery()
+              close()
+            }
+          "
+        >
+          确定
+        </el-button>
+      </div>
+    </div>
+  </template>
+</ZmTableColumn>
+```
+
+`#filter` 插槽会收到这些常用参数:
+
+| 参数                            | 说明                                           |
+| ------------------------------- | ---------------------------------------------- |
+| `prop`                          | 当前列字段名。                                 |
+| `filterModelValue`              | 当前筛选值。                                   |
+| `updateFilterModelValue(value)` | 更新筛选值,会触发 `update:filterModelValue`。 |
+| `close()`                       | 关闭 popover。                                 |
+| `setVisible(visible)`           | 手动控制 popover 显隐。                        |
+
+筛选按钮是否高亮:
+
+- 传了 `filterActive` 时,以 `filterActive` 为准。
+- 没传 `filterActive` 时,组件会根据 `filterModelValue` 是否有值自动判断。
+
+## 操作列和列设置
+
+给操作列加 `action`,表头会显示设置按钮。
+
+```vue
+<ZmTable :data="list" :loading="loading">
+  <ZmTableColumn prop="name" label="名称" />
+  <ZmTableColumn prop="status" label="状态" />
+
+  <ZmTableColumn label="操作" width="120" fixed="right" action>
+    <template #default="{ row }">
+      <el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
+    </template>
+  </ZmTableColumn>
+</ZmTable>
+```
+
+列设置面板支持:
+
+- 拖拽调整列顺序。
+- 显示 / 隐藏列。
+- 固定到左侧 / 固定到右侧 / 取消固定。
+- 重置为初始列配置。
+- 自动缓存列设置,下次进入同一张表时恢复。
+
+缓存默认开启,当前先写入 `localStorage`。后续接接口时,建议继续使用稳定的 `settingsCacheKey` 作为后端保存和读取配置的业务标识。
+
+```vue
+<ZmTable :data="list" :loading="loading" settings-cache-key="device-monitor-list">
+  <ZmTableColumn prop="name" label="名称" />
+  <ZmTableColumn prop="status" label="状态" />
+
+  <ZmTableColumn label="操作" width="120" fixed="right" action>
+    <template #default="{ row }">
+      <el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
+    </template>
+  </ZmTableColumn>
+</ZmTable>
+```
+
+不希望某张表缓存时,可以关闭:
+
+```vue
+<ZmTable :data="list" :loading="loading" :settings-cache="false">
+  ...
+</ZmTable>
+```
+
+列设置 key 的优先级:
+
+```txt
+columnKey -> prop -> type -> label -> column-${index}
+```
+
+建议动态列、同名列、没有 `prop` 的列都手动传 `column-key`,避免 key 不稳定。
+
+```vue
+<ZmTableColumn column-key="custom-action" label="自定义列">
+  ...
+</ZmTableColumn>
+```
+
+## 多级表头
+
+Element Plus 原生多级表头可以直接写:
+
+```vue
+<ZmTableColumn label="累计" is-parent>
+  <ZmTableColumn prop="totalGasInjection" label="注气量" />
+  <ZmTableColumn prop="totalWaterInjection" label="注水量" />
+  <ZmTableColumn prop="totalPower" label="用电量" />
+</ZmTableColumn>
+```
+
+如果想让列设置面板识别并控制子级表头,需要给父级列加 `is-parent`。
+
+子级列拖拽时只能在自己的父级分组内排序。比如 `累计` 下面有三个子列,这三个子列只能在 `累计` 里面互相调整,不能被拖到其他父级外面。
+
+## 对齐优先级
+
+普通列对齐优先级:
+
+```txt
+列自己的 align -> ZmTable 的 align -> 默认 center
+```
+
+开启了下面任意能力时,表头会优先左对齐,方便标题和按钮同时展示:
+
+- `action`
+- `zm-sortable`
+- `zm-filterable`
+
+示例:
+
+```vue
+<ZmTable :data="list" :loading="loading" align="right">
+  <ZmTableColumn prop="amount" label="金额" />
+  <ZmTableColumn prop="name" label="名称" align="left" />
+  <ZmTableColumn prop="date" label="日期" zm-sortable />
+</ZmTable>
+```
+
+上面例子里:
+
+- `amount` 使用表格全局 `right`。
+- `name` 使用列自己的 `left`。
+- `date` 因为开启了排序,表头优先 `left`。
+
+## 自动列宽
+
+每个有 `prop` 的列会根据表头和当前数据自动计算 `minWidth`。
+
+计算逻辑大致是:
+
+- 取表头文字宽度。
+- 取当前页数据里该列内容的最大文字宽度。
+- 如果列头有排序/筛选按钮,会额外加上按钮宽度。
+- 最终宽度不会超过 `ZmTable` 的 `columnMaxWidth`,默认是 `360`。
+
+```vue
+<ZmTable :data="list" :loading="loading" :column-max-width="480">
+  <ZmTableColumn prop="description" label="描述" />
+</ZmTable>
+```
+
+如果展示值不是原始字段值,建议传 `real-value`,这样自动宽度会按真实显示值计算。
+
+```vue
+<ZmTableColumn
+  prop="meetingDate"
+  label="会议日期"
+  cover-formatter
+  :real-value="(row) => dayjs(row.meetingDate).format('YYYY-MM-DD')" />
+```
+
+## 插槽
+
+`ZmTable` 会透传 Element Plus 表格的大部分插槽,默认插槽用于放 `ZmTableColumn`。
+
+`ZmTableColumn` 会透传 Element Plus 列的大部分插槽:
+
+```vue
+<ZmTableColumn prop="status" label="状态">
+  <template #default="{ row }">
+    <el-tag>{{ row.status }}</el-tag>
+  </template>
+</ZmTableColumn>
+```
+
+如果自定义了 `#header`,组件内置的排序、筛选、设置按钮不会自动渲染,需要调用方自己处理表头内容。
+
+## Expose
+
+`ZmTable` 暴露了内部 Element Plus 表格实例:
+
+```vue
+<script setup lang="ts">
+const tableRef = ref()
+
+function clearSelection() {
+  tableRef.value?.elTableRef?.clearSelection()
+}
+</script>
+
+<template>
+  <ZmTable ref="tableRef" :data="list" :loading="loading" />
+</template>
+```
+
+## CSS 变量
+
+默认样式挂在 `.zm-table` 上。外部可以通过 class 覆盖变量:
+
+```vue
+<ZmTable class="meeting-table" :data="list" :loading="loading" />
+```
+
+```scss
+.meeting-table {
+  --zm-table-font-size: 13px;
+  --zm-table-header-bg: #f3f6fb;
+  --zm-table-cell-height: 44px;
+}
+```
+
+### 表格变量
+
+| 变量 | 默认值 | 说明 |
+| --- | --- | --- |
+| `--zm-table-font-family` | `inherit` | 表格字体。 |
+| `--zm-table-font-size` | `12px` | 表格基础字号。 |
+| `--zm-table-text-color` | `#40546d` | 普通文字颜色。 |
+| `--zm-table-strong-text-color` | `#24364d` | body 单元格文字颜色。 |
+| `--zm-table-row-font-weight` | `500` | body 单元格字重。 |
+| `--zm-table-bg` | `var(--el-bg-color)` | 表格背景。 |
+| `--zm-table-border-color` | `#e7edf4` | 表格外边框颜色。 |
+| `--zm-table-radius` | `10px` | 表格圆角。 |
+| `--zm-table-header-bg` | `var(--el-fill-color-extra-light, #f7f9fc)` | 表头背景。 |
+| `--zm-table-header-text-color` | `var(--el-text-color-secondary, #6b7f99)` | 表头文字颜色。 |
+| `--zm-table-header-border-color` | `var(--zm-table-border-color)` | 表头边框颜色。 |
+| `--zm-table-header-cell-height` | `36px` | 普通表头高度。 |
+| `--zm-table-header-group-cell-height` | `42px` | 多级表头父级高度。 |
+| `--zm-table-header-font-size` | `var(--zm-table-font-size)` | 表头字号。 |
+| `--zm-table-header-font-weight` | `600` | 表头字重。 |
+| `--zm-table-header-line-height` | `16px` | 表头行高。 |
+| `--zm-table-header-icon-btn-size` | `18px` | 表头图标按钮尺寸。 |
+| `--zm-table-header-icon-btn-color` | `#8aa0b8` | 表头图标默认颜色。 |
+| `--zm-table-header-icon-btn-radius` | `4px` | 表头图标按钮圆角。 |
+| `--zm-table-header-icon-btn-hover-color` | `var(--el-color-primary)` | 表头图标 hover 颜色。 |
+| `--zm-table-header-icon-btn-hover-bg` | `var(--el-color-primary-light-9)` | 表头图标 hover 背景。 |
+| `--zm-table-header-icon-btn-active-color` | `var(--zm-table-header-icon-btn-hover-color)` | 表头图标激活颜色。 |
+| `--zm-table-header-icon-btn-active-bg` | `var(--zm-table-header-icon-btn-hover-bg)` | 表头图标激活背景。 |
+| `--zm-table-header-icon-size` | `16px` | 表头图标尺寸。 |
+| `--zm-table-row-border-color` | `#edf2f7` | body 单元格边框颜色。 |
+| `--zm-table-stripe-bg` | `#fcfdff` | 斑马纹背景。 |
+| `--zm-table-hover-bg` | `#f5f9ff` | 行 hover 背景。 |
+| `--zm-table-current-bg` | `#eef6ff` | 当前行背景。 |
+| `--zm-table-cell-height` | `38px` | body 单元格高度。 |
+| `--zm-table-cell-padding-x` | `13px` | body 单元格左右 padding。 |
+| `--zm-table-cell-first-padding-left` | `calc(var(--zm-table-cell-padding-x) + 3px)` | 每行第一个单元格左 padding。 |
+| `--zm-table-cell-last-padding-right` | `var(--zm-table-cell-first-padding-left)` | 每行最后一个单元格右 padding。 |
+| `--zm-table-cell-line-height` | `18px` | body 单元格行高。 |
+| `--zm-table-empty-min-height` | `148px` | 空状态最小高度。 |
+| `--zm-table-empty-bg` | `var(--zm-table-bg)` | 空状态背景。 |
+| `--zm-table-empty-text-font-size` | `var(--zm-table-font-size)` | 空状态文字字号。 |
+| `--zm-table-empty-text-color` | `var(--el-text-color-secondary)` | 空状态文字颜色。 |
+| `--zm-table-scrollbar-size` | `7px` | 滚动条尺寸。 |
+| `--zm-table-scrollbar-thumb-bg` | `#b8c5d6` | 滚动条滑块颜色。 |
+
+### 列设置变量
+
+列设置面板 popover 使用 `.zm-table-column-setting-popper`,可以全局覆盖,也可以在页面样式中覆盖。
+
+| 变量 | 默认值 | 说明 |
+| --- | --- | --- |
+| `--zm-table-column-setting-font-size` | `var(--zm-table-font-size, 12px)` | 设置面板字号。 |
+| `--zm-table-column-setting-text-color` | `var(--el-text-color-regular)` | 设置面板文字颜色。 |
+| `--zm-table-column-setting-border-color` | `var(--el-border-color)` | 设置面板分割线颜色。 |
+| `--zm-table-column-setting-hover-bg` | `var(--el-fill-color-lighter)` | 设置项 hover 背景。 |
+| `--zm-table-column-setting-radius` | `6px` | 设置项圆角。 |
+| `--zm-table-column-setting-gap` | `4px` | 设置项纵向间距。 |
+| `--zm-table-column-setting-max-height` | `360px` | 设置面板最大高度。 |
+| `--zm-table-column-setting-row-height` | `32px` | 一级设置项高度。 |
+| `--zm-table-column-setting-child-row-height` | `30px` | 子级设置项高度。 |
+| `--zm-table-column-setting-item-font-weight` | `400` | 普通设置项字重。 |
+| `--zm-table-column-setting-group-font-weight` | `600` | 分组设置项字重。 |
+| `--zm-table-column-setting-icon-btn-size` | `22px` | 设置项图标按钮尺寸。 |
+| `--zm-table-column-setting-icon-size` | `15px` | 设置项图标尺寸。 |
+| `--zm-table-column-setting-icon-color` | `#8aa0b8` | 设置项图标默认颜色。 |
+| `--zm-table-column-setting-icon-active-color` | `var(--el-color-primary)` | 设置项图标激活颜色。 |
+| `--zm-table-column-setting-icon-active-bg` | `var(--el-color-primary-light-9)` | 设置项图标激活背景。 |
+| `--zm-table-column-setting-ghost-bg` | `var(--el-color-primary-light-9)` | 拖拽占位背景。 |
+| `--zm-table-column-setting-ghost-opacity` | `0.55` | 拖拽占位透明度。 |
+
+### 自动宽度测量变量
+
+自动算宽时会创建隐藏文本节点测量文字宽度,可以用这两个变量控制测量字体:
+
+| 变量 | 默认值 | 说明 |
+| --- | --- | --- |
+| `--zm-table-column-width-measure-font-size` | `--zm-table-font-size` 或当前表格字号 | 测量文字宽度时的字号。 |
+| `--zm-table-column-width-measure-font-family` | 当前表格字体或 `Noto Sans SC` | 测量文字宽度时的字体。 |
+
+## 文字换行
+
+默认 `showOverflowTooltip` 是 `true`,单元格会走 Element Plus 的溢出 tooltip。想关闭 tooltip 并让内容自动换行,可以这样写:
+
+```vue
+<ZmTable class="wrap-table" :data="list" :loading="loading" :show-overflow-tooltip="false">
+  <ZmTableColumn prop="description" label="描述" />
+</ZmTable>
+```
+
+```scss
+.wrap-table {
+  :deep(.el-table__body .cell) {
+    overflow: visible;
+    text-overflow: initial;
+    white-space: normal;
+    word-break: break-word;
+  }
+}
+```
+
+## 注意事项
+
+- 列设置只识别 `ZmTableColumn`,不要在默认插槽里混入其他会渲染成列的组件。
+- 多级表头想被设置面板递归识别,需要在父级列上加 `is-parent`。
+- 动态列建议手动传 `column-key`,否则列顺序和显隐状态可能因为 key 变化而重置。
+- 排序和筛选按钮只负责 UI 状态,不会自动修改 `data` 或请求接口。
+- 如果传了自定义 `#header`,内置排序、筛选、设置按钮不会出现。
+- `customClass=true` 会关闭默认 `.zm-table` 样式,相关 CSS 变量也不会自动生效。

+ 445 - 0
src/components/ZmTable/ZmTableColumn.vue

@@ -0,0 +1,445 @@
+<script lang="ts" setup generic="T">
+import { type TableColumnCtx } from 'element-plus'
+import { computed, inject, nextTick, ref, useAttrs, useSlots, watch } from 'vue'
+import { TableContextKey } from './token'
+import ZmTableColumnSettingTree from './ZmTableColumnSettingTree.vue'
+import type { ColumnAlign, ColumnSettingItem, SortChangePayload, SortOrder } from './token'
+import type { DefaultRow } from 'element-plus/es/components/table/src/table/defaults'
+import { Icon } from '@iconify/vue'
+
+interface Props
+  extends /* @vue-ignore */ Partial<
+    Omit<TableColumnCtx<T extends DefaultRow ? T : DefaultRow>, 'prop'>
+  > {
+  prop?: (keyof T & string) | (string & {})
+  action?: boolean
+  visible?: boolean
+  hideInColumnSettings?: boolean
+  isParent?: boolean
+  zmSortable?: boolean
+  zmFilterable?: boolean
+  sortOrder?: SortOrder | null
+  defaultSortOrder?: SortOrder | null
+  zmSortMethod?: (prop: string, order: SortOrder | null) => void
+  filterActive?: boolean
+  filterModelValue?: any
+  realValue?: (...args: any[]) => any
+  coverFormatter?: boolean
+}
+
+const emits = defineEmits<{
+  'update:filterModelValue': [value: any]
+  'update:sortOrder': [order: SortOrder | null]
+  'sort-change': [payload: SortChangePayload]
+  'filter-click': [payload: { prop?: string }]
+  'filter-visible-change': [visible: boolean]
+}>()
+
+const props = defineProps<Props>()
+const attrs = useAttrs()
+const slots = useSlots()
+
+const tableContext = inject(TableContextKey, {
+  data: ref([]),
+  loading: ref(false),
+  columnAlign: ref<ColumnAlign>('center'),
+  columnMaxWidth: ref(360),
+  columnSettings: ref([]),
+  updateColumnVisible: () => {},
+  updateColumnFixed: () => {},
+  updateColumnOrder: () => {},
+  resetColumnSettings: () => {}
+})
+
+const innerSortOrder = ref<SortOrder | null>(props.defaultSortOrder ?? null)
+const filterVisible = ref(false)
+const settingVisible = ref(false)
+const hasHeaderAction = computed(() => props.action || props.zmSortable || props.zmFilterable)
+const forwardedSlots = computed(() => {
+  const { header: _header, ...restSlots } = slots
+  return restSlots
+})
+
+const defaultOptions = ref<Partial<Props>>({
+  align: 'center',
+  resizable: true,
+  visible: true
+})
+
+const bindProps = computed(() => {
+  const {
+    action,
+    visible,
+    hideInColumnSettings,
+    zmSortable,
+    zmFilterable,
+    sortOrder,
+    defaultSortOrder,
+    zmSortMethod,
+    filterActive,
+    filterModelValue,
+    realValue,
+    coverFormatter,
+    isParent,
+    ...columnProps
+  } = props
+  const columnAlign = props.align || (attrs.align as ColumnAlign | undefined)
+  const resolvedAlign = columnAlign || tableContext.columnAlign.value || defaultOptions.value.align
+
+  return {
+    ...defaultOptions.value,
+    ...attrs,
+    ...columnProps,
+    prop: props.prop,
+    align: resolvedAlign,
+    className: [props.className, props.prop].filter(Boolean).join(' '),
+    formatter: coverFormatter ? realValue : props.formatter
+  }
+})
+
+const alignMap: Record<string, string> = {
+  center: 'justify-center',
+  left: 'justify-between',
+  right: 'justify-end'
+}
+const headerFlexClass = computed(() => {
+  if (hasHeaderAction.value) return 'justify-between'
+  return alignMap[String(bindProps.value.align)] || 'justify-center'
+})
+
+const isSortControlled = computed(() => props.sortOrder !== undefined)
+const currentOrder = computed<SortOrder | null>(() => {
+  return isSortControlled.value ? (props.sortOrder ?? null) : innerSortOrder.value
+})
+const isSortActive = computed(() => currentOrder.value !== null)
+
+const hasFilterValue = (value: any) => {
+  if (Array.isArray(value)) return value.length > 0
+  if (typeof value === 'string') return value.length > 0
+  return value !== undefined && value !== null && value !== ''
+}
+const isFilterActive = computed(() => props.filterActive ?? hasFilterValue(props.filterModelValue))
+
+const columnSettingsModel = computed<ColumnSettingItem[]>({
+  get: () => tableContext.columnSettings.value,
+  set: (items) => {
+    tableContext.updateColumnOrder(items.map((item) => item.key))
+  }
+})
+
+const handleSortClick = () => {
+  if (!props.prop) return
+
+  let nextOrder: SortOrder | null = 'asc'
+
+  if (currentOrder.value === 'asc') {
+    nextOrder = 'desc'
+  } else if (currentOrder.value === 'desc') {
+    nextOrder = null
+  }
+
+  if (!isSortControlled.value) {
+    innerSortOrder.value = nextOrder
+  }
+
+  emits('update:sortOrder', nextOrder)
+  emits('sort-change', { prop: props.prop, order: nextOrder })
+  props.zmSortMethod?.(props.prop, nextOrder)
+}
+
+const updateFilterModelValue = (value: any) => {
+  emits('update:filterModelValue', value)
+}
+
+const closeFilterPopover = () => {
+  filterVisible.value = false
+}
+
+const setFilterVisible = (visible: boolean) => {
+  filterVisible.value = visible
+}
+
+const getFilterSlotProps = (scope: any) => ({
+  ...scope,
+  prop: props.prop,
+  filterModelValue: props.filterModelValue,
+  close: closeFilterPopover,
+  setVisible: setFilterVisible,
+  updateFilterModelValue
+})
+
+const handleFilterReferenceClick = () => {
+  emits('filter-click', { prop: props.prop })
+}
+
+watch(
+  () => props.defaultSortOrder,
+  (order) => {
+    if (!isSortControlled.value) {
+      innerSortOrder.value = order ?? null
+    }
+  }
+)
+
+watch(filterVisible, (visible) => {
+  emits('filter-visible-change', visible)
+})
+
+const getTableComputedStyle = () => {
+  const tableElement = document.querySelector('.zm-table') as HTMLElement | null
+  return getComputedStyle(tableElement ?? document.documentElement)
+}
+
+const getTableStyleVariable = (name: string, fallback: string) => {
+  const style = getTableComputedStyle()
+  return style.getPropertyValue(name).trim() || fallback
+}
+
+const getTextWidth = (text: string) => {
+  const tableStyle = getTableComputedStyle()
+  const span = document.createElement('span')
+  span.style.visibility = 'hidden'
+  span.style.position = 'absolute'
+  span.style.whiteSpace = 'nowrap'
+  span.style.fontSize = getTableStyleVariable(
+    '--zm-table-column-width-measure-font-size',
+    getTableStyleVariable('--zm-table-font-size', tableStyle.fontSize || '12px')
+  )
+  span.style.fontFamily = getTableStyleVariable(
+    '--zm-table-column-width-measure-font-family',
+    tableStyle.fontFamily || 'Noto Sans SC'
+  )
+  span.innerText = text
+  document.body.appendChild(span)
+  const width = span.offsetWidth
+  document.body.removeChild(span)
+  return width
+}
+
+const calculativeWidth = () => {
+  if (!props.prop) return
+  const values = tableContext.data.value
+    .map((item) => props.realValue?.(item) ?? item[props.prop as keyof typeof item])
+    .filter(hasFilterValue)
+  let labelWidth = getTextWidth(bindProps.value.label || '') + 32
+  if (hasHeaderAction.value) labelWidth += 8
+  if (props.zmFilterable) labelWidth += 22
+  if (props.zmSortable) labelWidth += 22
+
+  const maxWidth = Math.min(
+    Math.max(...values.map((value) => getTextWidth(String(value)) + 38), labelWidth),
+    tableContext.columnMaxWidth.value
+  )
+  defaultOptions.value.minWidth = maxWidth
+}
+
+watch(
+  [() => tableContext.loading.value, () => tableContext.columnMaxWidth.value],
+  () => {
+    nextTick(() => {
+      calculativeWidth()
+    })
+  },
+  { immediate: true }
+)
+</script>
+
+<template>
+  <el-table-column ref="columnRef" v-bind="bindProps">
+    <template v-for="(_, name) in forwardedSlots" :key="name" #[name]="slotData">
+      <slot :name="name" v-bind="slotData || {}"></slot>
+    </template>
+    <template #header="scope">
+      <slot name="header" v-bind="scope">
+        <div class="header-wrapper" :class="headerFlexClass">
+          <span class="truncate" :title="scope.column.label">{{ scope.column.label }}</span>
+          <div v-if="hasHeaderAction" class="action-area">
+            <el-tooltip
+              v-if="props.zmSortable"
+              :content="
+                currentOrder === 'asc'
+                  ? '点击降序'
+                  : currentOrder === 'desc'
+                    ? '取消排序'
+                    : '点击升序'
+              "
+              placement="top"
+              :show-after="500"
+            >
+              <button
+                type="button"
+                class="icon-btn"
+                :class="{ 'is-active': isSortActive }"
+                @click.stop="handleSortClick"
+              >
+                <div v-if="currentOrder === 'asc'" class="sort-icon i-lucide:arrow-up-narrow-wide">
+                </div>
+                <div
+                  v-else-if="currentOrder === 'desc'"
+                  class="sort-icon i-lucide:arrow-down-wide-narrow"
+                >
+                </div>
+                <div v-else class="sort-icon i-lucide:arrow-up-down"></div>
+              </button>
+            </el-tooltip>
+
+            <el-popover
+              v-if="props.zmFilterable"
+              v-model:visible="filterVisible"
+              placement="top"
+              :popper-options="{ modifiers: [{ name: 'offset', options: { offset: [16, 18] } }] }"
+              trigger="click"
+              :width="260"
+              :show-arrow="false"
+            >
+              <template #reference>
+                <button
+                  type="button"
+                  class="icon-btn"
+                  :class="{ 'is-active': isFilterActive }"
+                  @click.stop="handleFilterReferenceClick"
+                >
+                  <div class="filter-icon i-lucide:list-filter"></div>
+                </button>
+              </template>
+              <slot name="filter" v-bind="getFilterSlotProps(scope)"></slot>
+            </el-popover>
+
+            <el-popover
+              v-if="props.action"
+              v-model:visible="settingVisible"
+              placement="bottom-end"
+              trigger="click"
+              :width="360"
+              :show-arrow="false"
+              popper-class="zm-table-column-setting-popper"
+            >
+              <template #reference>
+                <button type="button" class="icon-btn" title="列设置" @click.stop>
+                  <Icon icon="material-symbols-light:settings" width="500" height="500" />
+                </button>
+              </template>
+
+              <div class="column-setting-panel">
+                <ZmTableColumnSettingTree v-model="columnSettingsModel" />
+
+                <div class="column-setting-footer">
+                  <el-button
+                    link
+                    type="primary"
+                    size="small"
+                    @click="tableContext.resetColumnSettings"
+                  >
+                    重置
+                  </el-button>
+                </div>
+              </div>
+            </el-popover>
+          </div>
+        </div>
+      </slot>
+    </template>
+  </el-table-column>
+</template>
+
+<style scoped lang="scss">
+// 表头整体容器:左侧是标题,右侧是按钮区。
+.header-wrapper {
+  display: flex;
+  align-items: center;
+  width: 100%;
+  height: 100%;
+  min-width: 0;
+  gap: 6px;
+  font-size: var(--zm-table-header-font-size, var(--zm-table-font-size, 12px));
+  font-weight: var(--zm-table-header-font-weight, 600);
+  line-height: var(--zm-table-header-line-height, 16px);
+  color: var(--zm-table-header-text-color, #6b7f99);
+  user-select: none;
+
+  .truncate {
+    min-width: 0;
+  }
+}
+
+// 表头右侧按钮区。
+.action-area {
+  display: flex;
+  flex: 0 0 auto;
+  height: 100%;
+  margin-left: 4px;
+  align-items: center;
+  gap: 3px;
+}
+
+// 表头小图标按钮的通用样式。
+.icon-btn {
+  display: flex;
+  width: var(--zm-table-header-icon-btn-size, 18px);
+  height: var(--zm-table-header-icon-btn-size, 18px);
+  padding: 0;
+  color: var(--zm-table-header-icon-btn-color, #8aa0b8);
+  cursor: pointer;
+  background: transparent;
+  border: 0;
+  border-radius: var(--zm-table-header-icon-btn-radius, 4px);
+  transition:
+    color 0.16s ease,
+    background-color 0.16s ease;
+  align-items: center;
+  justify-content: center;
+
+  &:hover {
+    color: var(--zm-table-header-icon-btn-hover-color, var(--el-color-primary));
+    background-color: var(--zm-table-header-icon-btn-hover-bg, var(--el-color-primary-light-9));
+  }
+
+  &.is-active {
+    color: var(--zm-table-header-icon-btn-active-color, var(--el-color-primary));
+    background-color: var(--zm-table-header-icon-btn-active-bg, var(--el-color-primary-light-9));
+  }
+}
+
+// 排序、筛选、设置这几个图标统一大小。
+.sort-icon,
+.filter-icon,
+.setting-icon {
+  width: var(--zm-table-header-icon-size, 16px);
+  height: var(--zm-table-header-icon-size, 16px);
+}
+
+// 列设置面板容器。
+.column-setting-panel {
+  min-width: 0;
+}
+
+// 面板底部区域,放重置按钮。
+.column-setting-footer {
+  display: flex;
+  justify-content: flex-end;
+  padding-top: 8px;
+  margin-top: 8px;
+  border-top: 1px solid var(--zm-table-column-setting-border-color, var(--el-border-color-lighter));
+}
+
+:global(.zm-table-column-setting-popper) {
+  --zm-table-column-setting-font-size: var(--zm-table-font-size, 12px);
+  --zm-table-column-setting-text-color: var(--el-text-color-regular);
+  --zm-table-column-setting-border-color: var(--el-border-color);
+  --zm-table-column-setting-hover-bg: var(--el-fill-color-lighter);
+  --zm-table-column-setting-radius: 6px;
+  --zm-table-column-setting-gap: 4px;
+  --zm-table-column-setting-max-height: 360px;
+  --zm-table-column-setting-row-height: 32px;
+  --zm-table-column-setting-child-row-height: 30px;
+  --zm-table-column-setting-item-font-weight: 400;
+  --zm-table-column-setting-group-font-weight: 600;
+  --zm-table-column-setting-icon-btn-size: 22px;
+  --zm-table-column-setting-icon-size: 15px;
+  --zm-table-column-setting-icon-color: #8aa0b8;
+  --zm-table-column-setting-icon-active-color: var(--el-color-primary);
+  --zm-table-column-setting-icon-active-bg: var(--el-color-primary-light-9);
+  --zm-table-column-setting-ghost-bg: var(--el-color-primary-light-9);
+  --zm-table-column-setting-ghost-opacity: 0.55;
+}
+</style>

+ 257 - 0
src/components/ZmTable/ZmTableColumnSettingTree.vue

@@ -0,0 +1,257 @@
+<script lang="ts" setup>
+import { computed, inject, ref } from 'vue'
+import { VueDraggable } from 'vue-draggable-plus'
+import { TableContextKey } from './token'
+import type { ColumnFixed, ColumnSettingItem } from './token'
+
+defineOptions({
+  name: 'ZmTableColumnSettingTree'
+})
+
+interface Props {
+  modelValue: ColumnSettingItem[]
+  parentKey?: string
+  level?: number
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  level: 0
+})
+
+const emits = defineEmits<{
+  'update:modelValue': [value: ColumnSettingItem[]]
+}>()
+
+const tableContext = inject(TableContextKey, {
+  data: ref([]),
+  loading: ref(false),
+  columnAlign: ref('center' as const),
+  columnMaxWidth: ref(360),
+  columnSettings: ref([]),
+  updateColumnVisible: () => {},
+  updateColumnFixed: () => {},
+  updateColumnOrder: () => {},
+  resetColumnSettings: () => {}
+})
+
+const columnList = computed<ColumnSettingItem[]>({
+  get: () => props.modelValue,
+  set: (value) => {
+    emits('update:modelValue', value)
+  }
+})
+
+const dragHandleClass = computed(() => `column-setting-drag-handle-${props.level}`)
+const dragHandleSelector = computed(() => `.${dragHandleClass.value}`)
+const dragGroup = computed(() => ({
+  name: `zm-table-column-setting-${props.parentKey || 'root'}`,
+  pull: false,
+  put: false
+}))
+
+const hasChildColumns = (item: ColumnSettingItem) => !!item.children?.length
+
+const getVisibleTooltip = (item: ColumnSettingItem) => (item.visible ? '隐藏列' : '显示列')
+
+const getFixedTooltip = (item: ColumnSettingItem, fixed: Exclude<ColumnFixed, false>) => {
+  if (item.fixed === fixed) return fixed === 'left' ? '取消固定左侧' : '取消固定右侧'
+  return fixed === 'left' ? '固定到左侧' : '固定到右侧'
+}
+
+const handleChildColumnOrder = (parentKey: string, children: ColumnSettingItem[]) => {
+  tableContext.updateColumnOrder(
+    children.map((item) => item.key),
+    parentKey
+  )
+}
+
+const toggleColumnFixed = (item: ColumnSettingItem, fixed: Exclude<ColumnFixed, false>) => {
+  tableContext.updateColumnFixed(item.key, item.fixed === fixed ? false : fixed)
+}
+</script>
+
+<template>
+  <VueDraggable
+    v-model="columnList"
+    :animation="150"
+    :handle="dragHandleSelector"
+    :group="dragGroup"
+    ghost-class="column-setting-ghost"
+    class="column-setting-list"
+    :class="{ 'is-root': level === 0, 'is-child-list': level > 0 }"
+  >
+    <div v-for="item in columnList" :key="item.key" class="column-setting-group">
+      <div
+        class="column-setting-item"
+        :class="{ 'is-group': hasChildColumns(item), 'is-child': level > 0 }"
+      >
+        <button
+          type="button"
+          class="column-setting-icon-btn column-setting-drag-handle"
+          :class="dragHandleClass"
+          title="拖拽排序"
+        >
+          <Icon icon="material-symbols-light:drag-indicator" width="500" height="500" />
+        </button>
+        <span class="column-setting-label" :title="item.label">
+          {{ item.label }}
+        </span>
+        <el-tooltip
+          :content="getVisibleTooltip(item)"
+          placement="top"
+          :show-after="500"
+          :hide-after="0"
+        >
+          <button
+            type="button"
+            class="column-setting-icon-btn"
+            :class="{ 'is-active': item.visible }"
+            @click="tableContext.updateColumnVisible(item.key, !item.visible)"
+          >
+            <!-- <div :class="item.visible ? 'i-lucide:eye' : 'i-lucide:eye-off'"></div> -->
+            <Icon v-if="item.visible" icon="boxicons:eye-alt" width="500" height="500" />
+            <Icon v-else icon="lucide:eye-off" width="500" height="500" />
+          </button>
+        </el-tooltip>
+        <el-tooltip
+          :content="getFixedTooltip(item, 'left')"
+          placement="top"
+          :show-after="500"
+          :hide-after="0"
+        >
+          <button
+            type="button"
+            class="column-setting-icon-btn"
+            :class="{ 'is-active': item.fixed === 'left' }"
+            @click="toggleColumnFixed(item, 'left')"
+          >
+            <Icon icon="mingcute:left-line" width="500" height="500" />
+          </button>
+        </el-tooltip>
+        <el-tooltip
+          :content="getFixedTooltip(item, 'right')"
+          placement="top"
+          :show-after="500"
+          :hide-after="0"
+        >
+          <button
+            type="button"
+            class="column-setting-icon-btn"
+            :class="{ 'is-active': item.fixed === 'right' }"
+            @click="toggleColumnFixed(item, 'right')"
+          >
+            <Icon icon="mingcute:right-line" width="500" height="500" />
+          </button>
+        </el-tooltip>
+      </div>
+
+      <ZmTableColumnSettingTree
+        v-if="item.children?.length"
+        :model-value="item.children"
+        :parent-key="item.key"
+        :level="level + 1"
+        @update:model-value="(children) => handleChildColumnOrder(item.key, children)"
+      />
+    </div>
+  </VueDraggable>
+</template>
+
+<style scoped lang="scss">
+.column-setting-list {
+  display: flex;
+  min-width: 0;
+  flex-direction: column;
+  gap: var(--zm-table-column-setting-gap, 4px);
+
+  &.is-root {
+    max-height: var(--zm-table-column-setting-max-height, 360px);
+    overflow-y: auto;
+  }
+
+  &.is-child-list {
+    padding-left: 18px;
+    margin: 3px 0 6px;
+    border-left: 1px dashed var(--zm-table-column-setting-border-color, var(--el-border-color));
+    gap: 3px;
+  }
+}
+
+.column-setting-group {
+  min-width: 0;
+}
+
+.column-setting-item {
+  display: grid;
+  height: var(--zm-table-column-setting-row-height, 32px);
+  min-width: 0;
+  padding: 0 4px;
+  font-size: var(--zm-table-column-setting-font-size, 12px);
+  font-weight: var(--zm-table-column-setting-item-font-weight, 400);
+  color: var(--zm-table-column-setting-text-color, var(--el-text-color-regular));
+  border-radius: var(--zm-table-column-setting-radius, 6px);
+  align-items: center;
+  column-gap: 6px;
+  grid-template-columns: 22px minmax(0, 1fr) repeat(3, 24px);
+
+  &:hover {
+    background: var(--zm-table-column-setting-hover-bg, var(--el-fill-color-lighter));
+  }
+
+  &.is-group {
+    font-weight: var(--zm-table-column-setting-group-font-weight, 600);
+  }
+
+  &.is-child {
+    height: var(--zm-table-column-setting-child-row-height, 30px);
+    padding-left: 2px;
+  }
+}
+
+.column-setting-label {
+  overflow: hidden;
+  color: currentcolor;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.column-setting-icon-btn {
+  display: flex;
+  width: var(--zm-table-column-setting-icon-btn-size, 22px);
+  height: var(--zm-table-column-setting-icon-btn-size, 22px);
+  padding: 0;
+  color: var(--zm-table-column-setting-icon-color, #8aa0b8);
+  cursor: pointer;
+  background: transparent;
+  border: 0;
+  border-radius: 4px;
+  align-items: center;
+  justify-content: center;
+
+  &:hover,
+  &.is-active {
+    color: var(--zm-table-column-setting-icon-active-color, var(--el-color-primary));
+    background-color: var(
+      --zm-table-column-setting-icon-active-bg,
+      var(--el-color-primary-light-9)
+    );
+  }
+
+  > div {
+    width: var(--zm-table-column-setting-icon-size, 15px);
+    height: var(--zm-table-column-setting-icon-size, 15px);
+  }
+}
+
+.column-setting-drag-handle {
+  cursor: grab;
+
+  &:active {
+    cursor: grabbing;
+  }
+}
+
+.column-setting-ghost {
+  background: var(--zm-table-column-setting-ghost-bg, var(--el-color-primary-light-9));
+  opacity: var(--zm-table-column-setting-ghost-opacity, 0.55);
+}
+</style>

+ 841 - 0
src/components/ZmTable/index.vue

@@ -0,0 +1,841 @@
+<script lang="ts" setup generic="T">
+import type { TableInstance, TableProps } from 'element-plus'
+import {
+  Comment,
+  Fragment,
+  Text,
+  cloneVNode,
+  computed,
+  nextTick,
+  provide,
+  ref,
+  useAttrs,
+  useSlots,
+  watch
+} from 'vue'
+import { useRoute } from 'vue-router'
+import { TableContextKey } from './token'
+import type { ColumnAlign, ColumnFixed, ColumnSettingItem } from './token'
+import type { DefaultRow } from 'element-plus/es/components/table/src/table/defaults'
+import type { VNode } from 'vue'
+
+interface ColumnMeta {
+  key: string
+  label: string
+  visible?: boolean
+  action: boolean
+  configurable: boolean
+  fixed: ColumnFixed
+  children: ColumnMeta[]
+}
+
+interface Props
+  extends /* @vue-ignore */ Partial<
+    Omit<TableProps<T extends DefaultRow ? T : DefaultRow>, 'data'>
+  > {
+  data: T[]
+  loading: boolean
+  customClass?: boolean
+  showBorder?: boolean
+  hoverHighlight?: boolean
+  align?: ColumnAlign
+  columnMaxWidth?: number
+  settingsCache?: boolean
+  settingsCacheKey?: string
+}
+
+const props = defineProps<Props>()
+const attrs = useAttrs()
+const slots = useSlots()
+const route = useRoute()
+const tableRef = ref<TableInstance>()
+const columnSettings = ref<ColumnSettingItem[]>([])
+const columnDefaultSettings = ref<ColumnSettingItem[]>([])
+const slotColumnSignature = ref('')
+const columnSettingsStorageKey = ref('')
+const isSyncingColumnSettings = ref(false)
+const columnSettingsStoragePrefix = 'zm-table:column-settings:'
+
+const defaultOptions: Partial<Props> = {
+  size: 'default',
+  stripe: true,
+  border: true,
+  highlightCurrentRow: true,
+  showOverflowTooltip: true,
+  scrollbarAlwaysOn: true,
+  showBorder: false,
+  hoverHighlight: true,
+  customClass: false,
+  tooltipOptions: {
+    popperClass: 'max-w-120'
+  }
+}
+
+const bindProps = computed(() => {
+  const {
+    data,
+    customClass: _customClass,
+    showBorder: _showBorder,
+    hoverHighlight: _hoverHighlight,
+    align: _align,
+    columnMaxWidth,
+    settingsCache: _settingsCache,
+    settingsCacheKey: _settingsCacheKey,
+    ...otherProps
+  } = props
+
+  return {
+    ...defaultOptions,
+    ...attrs,
+    ...otherProps,
+    data: data || []
+  }
+})
+
+const safeData = computed(() => props.data || [])
+const safeLoading = computed(() => props.loading)
+const safeColumnAlign = computed<ColumnAlign>(() => {
+  return props.align === 'left' || props.align === 'right' || props.align === 'center'
+    ? props.align
+    : 'center'
+})
+const safeColumnMaxWidth = computed(() => {
+  const maxWidth = Number(props.columnMaxWidth)
+  return Number.isFinite(maxWidth) && maxWidth > 0 ? maxWidth : 360
+})
+const isColumnSettingsCacheEnabled = computed(() => props.settingsCache !== false)
+
+const forwardedSlots = computed(() => {
+  const { default: _default, ...restSlots } = slots
+  return restSlots
+})
+
+const getPropValue = (props: Record<string, any> | null | undefined, ...keys: string[]) => {
+  if (!props) return undefined
+  for (const key of keys) {
+    if (props[key] !== undefined) return props[key]
+  }
+  return undefined
+}
+
+const toBoolean = (value: unknown) => value === true || value === ''
+
+const normalizeFixed = (fixed: unknown): ColumnFixed => {
+  if (fixed === true || fixed === 'left') return 'left'
+  if (fixed === 'right') return 'right'
+  return false
+}
+
+const isColumnNode = (node: VNode) => {
+  if (!node.type && typeof node.type !== 'object') return false
+  const nodeType = node.type as { __name?: string }
+  return nodeType.__name === 'ZmTableColumn'
+}
+
+const canResolveColumnChildren = (node: VNode) => {
+  const nodeProps = node.props
+  return toBoolean(getPropValue(nodeProps, 'isParent', 'is-parent'))
+}
+
+const getColumnKey = (node: VNode, index: number, parentKey?: string) => {
+  const nodeProps = node.props
+  const key = getPropValue(nodeProps, 'columnKey', 'column-key')
+  const prop = getPropValue(nodeProps, 'prop')
+  const type = getPropValue(nodeProps, 'type')
+  const label = getPropValue(nodeProps, 'label')
+  const fallbackKey = parentKey ? `${parentKey}.column-${index}` : `column-${index}`
+  return String(key ?? prop ?? type ?? label ?? fallbackKey)
+}
+
+const getColumnLabel = (node: VNode, index: number, parentKey?: string) => {
+  const label = getPropValue(node.props, 'label')
+  return String(label ?? getColumnKey(node, index, parentKey))
+}
+
+const getColumnChildren = (node: VNode) => {
+  if (!canResolveColumnChildren(node)) return []
+  const children = node.children
+  if (children && typeof children === 'object' && 'default' in children) {
+    const defaultSlot = (children as { default?: unknown }).default
+    if (typeof defaultSlot !== 'function') return []
+    try {
+      const slotNodes = defaultSlot()
+      const normalizedNodes = Array.isArray(slotNodes) ? slotNodes : slotNodes ? [slotNodes] : []
+      return flattenSlotNodes(normalizedNodes as VNode[]).filter(isColumnNode)
+    } catch {
+      return []
+    }
+  }
+  return []
+}
+
+const getColumnMeta = (node: VNode, index: number, parentKey?: string): ColumnMeta => {
+  const key = getColumnKey(node, index, parentKey)
+  const action = toBoolean(getPropValue(node.props, 'action'))
+  const hideInColumnSettings = toBoolean(
+    getPropValue(node.props, 'hideInColumnSettings', 'hide-in-column-settings')
+  )
+  return {
+    key,
+    label: getColumnLabel(node, index, parentKey),
+    visible: getPropValue(node.props, 'visible'),
+    action,
+    configurable: !hideInColumnSettings,
+    fixed: normalizeFixed(getPropValue(node.props, 'fixed')),
+    children: getColumnChildren(node)
+      .map((child, childIndex) => getColumnMeta(child, childIndex, key))
+      .filter((item) => item.configurable)
+  }
+}
+
+const flattenSlotNodes = (nodes: Array<VNode | null | undefined>): VNode[] => {
+  return nodes.flatMap((node) => {
+    if (!node) return []
+    if (node.type === Fragment && Array.isArray(node.children)) {
+      return flattenSlotNodes(node.children as VNode[])
+    }
+    if (node.type === Comment || node.type === Text) return []
+    return [node]
+  })
+}
+
+const syncColumnSettings = (nodes: VNode[]) => {
+  const metas = nodes
+    .map((node, index) => getColumnMeta(node, index))
+    .filter((item) => item.configurable)
+  const signature = JSON.stringify(metas)
+  if (signature === slotColumnSignature.value) return
+  slotColumnSignature.value = signature
+  nextTick(() => {
+    const defaults = metas.map(createDefaultColumnSetting)
+    const storageKey = resolveColumnSettingsStorageKey(defaults)
+    const cachedSettings = loadColumnSettings(storageKey)
+    columnDefaultSettings.value = defaults
+    columnSettingsStorageKey.value = storageKey
+    setColumnSettings(mergeColumnSettings(defaults, cachedSettings || columnSettings.value))
+  })
+}
+
+const createDefaultColumnSetting = (meta: ColumnMeta): ColumnSettingItem => ({
+  key: meta.key,
+  label: meta.label,
+  visible: true,
+  fixed: meta.fixed,
+  children: meta.children.length ? meta.children.map(createDefaultColumnSetting) : undefined
+})
+
+const mergeColumnSettings = (
+  defaults: ColumnSettingItem[],
+  current: ColumnSettingItem[]
+): ColumnSettingItem[] => {
+  const defaultMap = new Map(defaults.map((item) => [item.key, item]))
+  const currentMap = new Map(current.map((item) => [item.key, item]))
+  const existingItems = current
+    .filter((item) => defaultMap.has(item.key))
+    .map((item) => {
+      const defaultItem = defaultMap.get(item.key)!
+      return {
+        ...defaultItem,
+        visible: item.visible,
+        fixed: item.fixed,
+        children: defaultItem.children
+          ? mergeColumnSettings(defaultItem.children, item.children || [])
+          : undefined
+      }
+    })
+  const addedItems = defaults.filter((item) => !currentMap.has(item.key))
+  return [...existingItems, ...addedItems]
+}
+
+const mapColumnSettings = (
+  items: ColumnSettingItem[],
+  mapper: (item: ColumnSettingItem) => ColumnSettingItem
+): ColumnSettingItem[] => {
+  return items.map((item) => {
+    const mappedItem = mapper(item)
+    return {
+      ...mappedItem,
+      children: mappedItem.children ? mapColumnSettings(mappedItem.children, mapper) : undefined
+    }
+  })
+}
+
+const updateColumnVisible = (key: string, visible: boolean) => {
+  columnSettings.value = mapColumnSettings(columnSettings.value, (item) =>
+    item.key === key ? { ...item, visible } : item
+  )
+}
+
+const updateColumnFixed = (key: string, fixed: ColumnFixed) => {
+  columnSettings.value = mapColumnSettings(columnSettings.value, (item) =>
+    item.key === key ? { ...item, fixed } : item
+  )
+}
+
+const sortSettingsByKeys = (items: ColumnSettingItem[], keys: string[]) => {
+  const orderMap = new Map(keys.map((key, index) => [key, index]))
+  return [...items].sort((a, b) => {
+    const orderA = orderMap.get(a.key) ?? Number.MAX_SAFE_INTEGER
+    const orderB = orderMap.get(b.key) ?? Number.MAX_SAFE_INTEGER
+    return orderA - orderB
+  })
+}
+
+const updateColumnOrder = (keys: string[], parentKey?: string) => {
+  if (!parentKey) {
+    columnSettings.value = sortSettingsByKeys(columnSettings.value, keys)
+    return
+  }
+
+  columnSettings.value = mapColumnSettings(columnSettings.value, (item) => {
+    if (item.key !== parentKey) return item
+
+    return {
+      ...item,
+      children: item.children ? sortSettingsByKeys(item.children, keys) : item.children
+    }
+  })
+}
+
+const cloneColumnSettings = (items: ColumnSettingItem[]): ColumnSettingItem[] => {
+  return items.map((item) => ({
+    ...item,
+    children: item.children ? cloneColumnSettings(item.children) : undefined
+  }))
+}
+
+const resetColumnSettings = () => {
+  setColumnSettings(cloneColumnSettings(columnDefaultSettings.value))
+}
+
+const flattenColumnSettingKeys = (items: ColumnSettingItem[]): string[] => {
+  return items.flatMap((item) => [
+    item.key,
+    ...(item.children ? flattenColumnSettingKeys(item.children) : [])
+  ])
+}
+
+const encodeStorageKeyPart = (value: string) => {
+  try {
+    return encodeURIComponent(value)
+  } catch {
+    return value
+  }
+}
+
+const resolveColumnSettingsStorageKey = (defaults: ColumnSettingItem[]) => {
+  if (!isColumnSettingsCacheEnabled.value) return ''
+
+  const userKey = props.settingsCacheKey?.trim()
+  if (userKey) return `${columnSettingsStoragePrefix}${encodeStorageKeyPart(userKey)}`
+
+  const routeKey = String(route.name || route.path || 'global')
+  const columnKey = flattenColumnSettingKeys(defaults).sort().join('|')
+  return `${columnSettingsStoragePrefix}${encodeStorageKeyPart(`${routeKey}:${columnKey}`)}`
+}
+
+const isColumnSettingItem = (item: unknown): item is ColumnSettingItem => {
+  if (!item || typeof item !== 'object') return false
+  const setting = item as ColumnSettingItem
+  const validFixed =
+    setting.fixed === false || setting.fixed === 'left' || setting.fixed === 'right'
+  return typeof setting.key === 'string' && typeof setting.visible === 'boolean' && validFixed
+}
+
+const normalizeCachedColumnSettings = (items: unknown): ColumnSettingItem[] => {
+  if (!Array.isArray(items)) return []
+
+  return items.filter(isColumnSettingItem).map((item) => ({
+    key: item.key,
+    label: typeof item.label === 'string' ? item.label : item.key,
+    visible: item.visible,
+    fixed: item.fixed,
+    children: item.children ? normalizeCachedColumnSettings(item.children) : undefined
+  }))
+}
+
+const loadColumnSettings = (storageKey: string) => {
+  if (!storageKey || typeof window === 'undefined') return null
+
+  try {
+    const rawValue = window.localStorage.getItem(storageKey)
+    if (!rawValue) return null
+
+    const cachedValue = JSON.parse(rawValue)
+    const settings = normalizeCachedColumnSettings(cachedValue?.settings)
+    return settings.length ? settings : null
+  } catch {
+    return null
+  }
+}
+
+const saveColumnSettings = (storageKey: string, settings: ColumnSettingItem[]) => {
+  if (!storageKey || typeof window === 'undefined') return
+
+  try {
+    window.localStorage.setItem(
+      storageKey,
+      JSON.stringify({
+        version: 1,
+        settings
+      })
+    )
+  } catch {
+    // localStorage 可能被浏览器策略禁用,失败时不影响表格正常使用。
+  }
+}
+
+const setColumnSettings = (settings: ColumnSettingItem[]) => {
+  isSyncingColumnSettings.value = true
+  columnSettings.value = settings
+  nextTick(() => {
+    isSyncingColumnSettings.value = false
+    saveColumnSettings(columnSettingsStorageKey.value, columnSettings.value)
+  })
+}
+
+watch(
+  columnSettings,
+  (settings) => {
+    if (isSyncingColumnSettings.value) return
+    saveColumnSettings(columnSettingsStorageKey.value, settings)
+  },
+  { deep: true }
+)
+
+watch(
+  [() => props.settingsCache, () => props.settingsCacheKey, () => route.name, () => route.path],
+  () => {
+    slotColumnSignature.value = ''
+  }
+)
+
+const applyColumnSetting = (node: VNode, setting: ColumnSettingItem) => {
+  const clonedNode = cloneVNode(
+    node,
+    {
+      columnKey: setting.key,
+      fixed: setting.fixed || undefined,
+      key: `${setting.key}-${setting.visible}-${setting.fixed || 'none'}`
+    },
+    true
+  )
+
+  const childNodes = getColumnChildren(node)
+  if (!childNodes.length || !setting.children?.length) return clonedNode
+
+  const originalChildren =
+    clonedNode.children && typeof clonedNode.children === 'object' ? clonedNode.children : {}
+
+  return {
+    ...clonedNode,
+    children: {
+      ...(originalChildren as Record<string, unknown>),
+      default: () => renderColumnNodes(childNodes, setting.children || [], setting.key)
+    }
+  } as VNode
+}
+
+const getSettingVisible = (meta: ColumnMeta, setting?: ColumnSettingItem) => {
+  return meta.visible ?? setting?.visible ?? true
+}
+
+const isColumnVisible = (meta: ColumnMeta, setting?: ColumnSettingItem) => {
+  if (!getSettingVisible(meta, setting)) return false
+  if (!meta.children.length) return true
+
+  const childrenSettings = setting?.children || []
+  const childSettingMap = new Map(childrenSettings.map((item) => [item.key, item]))
+  return meta.children.some((child) => isColumnVisible(child, childSettingMap.get(child.key)))
+}
+
+const renderColumnNodes = (nodes: VNode[], settings: ColumnSettingItem[], parentKey?: string) => {
+  const settingMap = new Map(settings.map((item) => [item.key, item]))
+  const orderMap = new Map(settings.map((item, index) => [item.key, index]))
+  const sortedConfigurableNodes = nodes
+    .map((node, index) => ({ node, meta: getColumnMeta(node, index, parentKey) }))
+    .filter(({ meta }) => {
+      const setting = settingMap.get(meta.key)
+      return meta.configurable && setting && isColumnVisible(meta, setting)
+    })
+    .sort((a, b) => {
+      const orderA = orderMap.get(a.meta.key) ?? Number.MAX_SAFE_INTEGER
+      const orderB = orderMap.get(b.meta.key) ?? Number.MAX_SAFE_INTEGER
+      return orderA - orderB
+    })
+
+  return nodes.flatMap((node, index) => {
+    const meta = getColumnMeta(node, index, parentKey)
+
+    const setting = settingMap.get(meta.key)
+
+    if (!isColumnVisible(meta, setting)) return []
+
+    if (!meta.configurable) return [node]
+
+    const nextColumn = sortedConfigurableNodes.shift()
+    if (!nextColumn) return []
+
+    const nextColumnSetting = settingMap.get(nextColumn.meta.key)
+    return nextColumnSetting
+      ? [applyColumnSetting(nextColumn.node, nextColumnSetting)]
+      : [nextColumn.node]
+  })
+}
+
+const renderDefaultSlot = () => {
+  const nodes = flattenSlotNodes(slots.default?.() || [])
+  syncColumnSettings(nodes)
+  return renderColumnNodes(nodes, columnSettings.value)
+}
+
+const TableDefaultSlot = () => renderDefaultSlot()
+
+provide(TableContextKey, {
+  data: safeData,
+  loading: safeLoading,
+  columnAlign: safeColumnAlign,
+  columnMaxWidth: safeColumnMaxWidth,
+  columnSettings,
+  updateColumnVisible,
+  updateColumnFixed,
+  updateColumnOrder,
+  resetColumnSettings
+})
+
+defineExpose({
+  elTableRef: tableRef
+})
+</script>
+
+<template>
+  <el-table
+    ref="tableRef"
+    v-loading="loading"
+    :class="{
+      'zm-table': !customClass,
+      'show-border': showBorder,
+      'is-hover-highlight-disabled': hoverHighlight === false
+    }"
+    v-bind="bindProps"
+    :data="data">
+    <template v-for="(_, name) in forwardedSlots" #[name]="slotData">
+      <slot :name="name" v-bind="slotData || {}"></slot>
+    </template>
+    <TableDefaultSlot />
+  </el-table>
+</template>
+
+<style lang="scss">
+.zm-table {
+  --zm-table-font-family: inherit;
+  --zm-table-font-size: 12px;
+  --zm-table-text-color: #40546d;
+  --zm-table-strong-text-color: #24364d;
+  --zm-table-row-font-weight: 500;
+  --zm-table-bg: var(--el-bg-color);
+  --zm-table-border-color: #e7edf4;
+  --zm-table-radius: 10px;
+  --zm-table-header-bg: var(--el-fill-color-extra-light, #f7f9fc);
+  --zm-table-header-text-color: var(--el-text-color-secondary, #6b7f99);
+  --zm-table-header-border-color: var(--zm-table-border-color);
+  --zm-table-header-cell-height: 36px;
+  --zm-table-header-group-cell-height: 42px;
+  --zm-table-header-font-size: var(--zm-table-font-size);
+  --zm-table-header-font-weight: 600;
+  --zm-table-header-line-height: 16px;
+  --zm-table-header-icon-btn-size: 18px;
+  --zm-table-header-icon-btn-color: #8aa0b8;
+  --zm-table-header-icon-btn-radius: 4px;
+  --zm-table-header-icon-btn-hover-color: var(--el-color-primary);
+  --zm-table-header-icon-btn-hover-bg: var(--el-color-primary-light-9);
+  --zm-table-header-icon-btn-active-color: var(--zm-table-header-icon-btn-hover-color);
+  --zm-table-header-icon-btn-active-bg: var(--zm-table-header-icon-btn-hover-bg);
+  --zm-table-header-icon-size: 16px;
+  --zm-table-row-border-color: #edf2f7;
+  --zm-table-stripe-bg: #fcfdff;
+  --zm-table-hover-bg: #f5f9ff;
+  --zm-table-current-bg: #eef6ff;
+  --zm-table-summary-bg: #f7f9fc;
+  --zm-table-summary-text-color: var(--zm-table-strong-text-color);
+  --zm-table-summary-font-weight: 600;
+  --zm-table-summary-border-color: var(--zm-table-border-color);
+  --zm-table-cell-height: 38px;
+  --zm-table-cell-padding-x: 8px;
+  --zm-table-cell-first-padding-left: 0px;
+  --zm-table-cell-last-padding-right: 0px;
+  --zm-table-cell-line-height: 18px;
+  --zm-table-empty-min-height: 148px;
+  --zm-table-empty-bg: var(--zm-table-bg);
+  --zm-table-empty-text-font-size: var(--zm-table-font-size);
+  --zm-table-empty-text-color: var(--el-text-color-secondary);
+  --zm-table-scrollbar-size: 7px;
+  --zm-table-scrollbar-thumb-bg: #b8c5d6;
+
+  width: 100%;
+  overflow: hidden;
+  font-family: var(--zm-table-font-family);
+  font-size: var(--zm-table-font-size);
+  color: var(--zm-table-text-color);
+  background: var(--zm-table-bg);
+  border: 1px solid var(--zm-table-border-color);
+  border-radius: var(--zm-table-radius);
+  box-shadow: none;
+
+  &::before,
+  &::after {
+    display: none;
+  }
+
+  .el-table__inner-wrapper {
+    &::before,
+    &::after {
+      display: none;
+    }
+  }
+
+  .el-table__border-left-patch {
+    display: none;
+  }
+
+  .el-table__inner-wrapper,
+  .el-table__header-wrapper,
+  .el-table__body-wrapper,
+  .el-scrollbar__wrap {
+    background: transparent;
+  }
+
+  .el-table__inner-wrapper {
+    border-radius: var(--zm-table-radius);
+  }
+
+  .el-table__cell {
+    height: var(--zm-table-cell-height);
+    padding: 0;
+    color: var(--zm-table-text-color);
+    background: var(--zm-table-bg);
+    border-right: 1px solid var(--zm-table-row-border-color) !important;
+    border-bottom: 1px solid var(--zm-table-row-border-color) !important;
+    transition:
+      background-color 0.16s ease,
+      color 0.16s ease;
+
+    &:last-child {
+      border-right: none !important;
+    }
+  }
+
+  .cell {
+    padding-right: var(--zm-table-cell-padding-x);
+    padding-left: var(--zm-table-cell-padding-x);
+    line-height: var(--zm-table-cell-line-height);
+  }
+
+  .el-table__header {
+    color: var(--zm-table-header-text-color);
+
+    .el-table__cell {
+      height: var(--zm-table-header-cell-height);
+      font-size: var(--zm-table-header-font-size);
+      font-weight: var(--zm-table-header-font-weight);
+      color: var(--zm-table-header-text-color);
+      background: var(--zm-table-header-bg) !important;
+      border-right: 1px solid var(--zm-table-header-border-color) !important;
+      border-bottom: 1px solid var(--zm-table-header-border-color) !important;
+
+      .cell {
+        display: flex;
+        min-height: 100%;
+        align-items: center;
+        justify-content: center;
+        padding-top: 0;
+        padding-bottom: 0;
+      }
+
+      &:last-child {
+        .cell {
+          border-right: none;
+        }
+      }
+    }
+
+    tr:first-child {
+      .el-table__cell {
+        &:first-child {
+          border-top-left-radius: var(--zm-table-radius);
+        }
+
+        &:last-child {
+          border-top-right-radius: var(--zm-table-radius);
+        }
+      }
+    }
+
+    tr:not(:last-child) {
+      .el-table__cell {
+        height: var(--zm-table-header-group-cell-height);
+        border-bottom-color: var(--zm-table-header-border-color) !important;
+      }
+    }
+  }
+
+  .el-table__body {
+    tr.el-table__row--striped {
+      .el-table__cell {
+        background: var(--zm-table-stripe-bg);
+      }
+    }
+
+    tr.current-row {
+      .el-table__cell {
+        // color: var(--el-color-primary);
+        background: var(--zm-table-current-bg) !important;
+      }
+    }
+  }
+
+  &:not(.is-hover-highlight-disabled) {
+    .el-table__body {
+      tr:hover,
+      tr.hover-row {
+        .el-table__cell {
+          background: var(--zm-table-hover-bg) !important;
+        }
+      }
+    }
+  }
+
+  .el-table__row {
+    .el-table__cell {
+      font-weight: var(--zm-table-row-font-weight);
+      color: var(--zm-table-strong-text-color);
+
+      &:first-child {
+        // .cell {
+        //   padding-left: var(--zm-table-cell-first-padding-left);
+        // }
+      }
+
+      &:last-child {
+        // .cell {
+        //   padding-right: var(--zm-table-cell-last-padding-right);
+        // }
+      }
+    }
+  }
+
+  .el-table__empty-block {
+    width: 100% !important;
+    min-width: 100%;
+    min-height: var(--zm-table-empty-min-height);
+    background: var(--zm-table-empty-bg);
+  }
+
+  .el-table__empty-text {
+    font-size: var(--zm-table-empty-text-font-size);
+    color: var(--zm-table-empty-text-color);
+  }
+
+  .el-table__footer-wrapper {
+    background: var(--zm-table-summary-bg);
+    border-top: 1px solid var(--zm-table-summary-border-color);
+  }
+
+  .el-table__footer {
+    color: var(--zm-table-summary-text-color);
+
+    .el-table__cell {
+      height: var(--zm-table-cell-height);
+      font-weight: var(--zm-table-summary-font-weight);
+      color: var(--zm-table-summary-text-color);
+      background: var(--zm-table-summary-bg) !important;
+      border-right: 1px solid var(--zm-table-row-border-color) !important;
+      border-bottom: none !important;
+
+      .cell {
+        display: flex;
+        min-height: 100%;
+        align-items: center;
+        justify-content: center;
+      }
+
+      &:last-child {
+        border-right: none !important;
+      }
+    }
+
+    tr:last-child {
+      .el-table__cell {
+        &:first-child {
+          border-bottom-left-radius: var(--zm-table-radius);
+        }
+
+        &:last-child {
+          border-bottom-right-radius: var(--zm-table-radius);
+        }
+      }
+    }
+
+    .el-table__cell.el-table-fixed-column--left.is-last-column,
+    .el-table__cell.el-table-fixed-column--right.is-first-column {
+      box-shadow: none;
+    }
+  }
+
+  .el-table__cell.el-table-fixed-column--left,
+  .el-table__cell.el-table-fixed-column--right {
+    background: inherit;
+  }
+
+  .el-table__cell.el-table-fixed-column--left.is-last-column {
+    box-shadow: 6px 0 12px -10px rgb(15 23 42 / 22%);
+  }
+
+  .el-table__cell.el-table-fixed-column--right.is-first-column {
+    box-shadow: -6px 0 12px -10px rgb(15 23 42 / 22%);
+  }
+
+  .el-table__fixed-right-patch {
+    background: var(--zm-table-header-bg);
+    border-bottom: 1px solid var(--zm-table-header-border-color);
+  }
+
+  .el-scrollbar__bar {
+    &.is-horizontal {
+      height: var(--zm-table-scrollbar-size);
+    }
+
+    &.is-vertical {
+      width: var(--zm-table-scrollbar-size);
+    }
+  }
+
+  .el-scrollbar__thumb {
+    background: var(--zm-table-scrollbar-thumb-bg);
+    border-radius: 999px;
+    opacity: 0.55;
+
+    &:hover {
+      opacity: 0.85;
+    }
+  }
+}
+
+.zm-table:not(.show-border) {
+  .el-table__header {
+    .el-table__cell {
+      border-right-color: var(--zm-table-header-border-color) !important;
+
+      .cell {
+        border-right: none;
+      }
+
+      &:last-child {
+        .cell {
+          border-right: none;
+        }
+      }
+    }
+  }
+}
+</style>

+ 32 - 0
src/components/ZmTable/token.ts

@@ -0,0 +1,32 @@
+import type { InjectionKey, Ref } from 'vue'
+
+export type SortOrder = 'asc' | 'desc'
+export type ColumnFixed = false | 'left' | 'right'
+export type ColumnAlign = 'left' | 'center' | 'right'
+
+export interface SortChangePayload {
+  prop: string
+  order: SortOrder | null
+}
+
+export interface ColumnSettingItem {
+  key: string
+  label: string
+  visible: boolean
+  fixed: ColumnFixed
+  children?: ColumnSettingItem[]
+}
+
+export interface TableContext<T = any> {
+  data: Ref<T[]>
+  loading: Ref<boolean>
+  columnAlign: Ref<ColumnAlign>
+  columnMaxWidth: Ref<number>
+  columnSettings: Ref<ColumnSettingItem[]>
+  updateColumnVisible: (key: string, visible: boolean) => void
+  updateColumnFixed: (key: string, fixed: ColumnFixed) => void
+  updateColumnOrder: (keys: string[], parentKey?: string) => void
+  resetColumnSettings: () => void
+}
+
+export const TableContextKey: InjectionKey<TableContext> = Symbol('zm-table')

+ 9 - 0
src/components/ZmTable/useTableComponents.ts

@@ -0,0 +1,9 @@
+import ZmTable from './index.vue'
+import ZmTableColumn from './ZmTableColumn.vue'
+
+export function useTableComponents<T>() {
+  return {
+    ZmTable: ZmTable<T>,
+    ZmTableColumn: ZmTableColumn<T>
+  }
+}

+ 4 - 1
src/utils/dict.ts

@@ -259,5 +259,8 @@ export enum DICT_TYPE {
   RD_EXPERT_SOFTWARE = 'rd_expert_software', // 专业软件技能
   RD_EXPERT_EDUCATION = 'rd_expert_education‌', // 学历
   RD_EXPERT_RANK = 'rd_expert_rank', //专家序列
-  RD_EXPERT_ECHELON = 'rd_expert_echelon' //专家梯队
+  RD_EXPERT_ECHELON = 'rd_expert_echelon', //专家梯队
+  RD_CORE_TECHNOLOGY_TYPE = 'rd_core_technology_type', // 核心技术类型
+  RD_CORE_TECHNOLOGY_STATUS = 'rd_core_technology_status', // 核心技术状态
+  RD_CORE_TECHNOLOGY_LEVEL = 'rd_core_technology_level' // 核心技术级别
 }

+ 15 - 14
src/views/expert/team/index.vue

@@ -47,14 +47,14 @@
   </ContentWrap>
 
   <ContentWrap>
-    <el-table v-loading="loading" :data="list" row-key="id" @row-click="handleRowClick">
-      <el-table-column label="团队所属公司" align="center" min-width="180">
+    <zm-table :loading="loading" :data="list" row-key="id" @row-click="handleRowClick">
+      <zm-table-column label="团队所属公司" align="center" min-width="180">
         <template #default="{ row }">
           {{ getDeptName(row.deptId) || '-' }}
         </template>
-      </el-table-column>
-      <el-table-column label="团队名称" align="center" prop="teamName" min-width="180" />
-      <el-table-column label="团队 Logo" align="center" width="120">
+      </zm-table-column>
+      <zm-table-column label="团队名称" align="center" prop="teamName" min-width="180" />
+      <zm-table-column label="团队 Logo" align="center" width="120">
         <template #default="{ row }">
           <el-image
             v-if="row.avatar"
@@ -67,26 +67,26 @@
           />
           <span v-else>-</span>
         </template>
-      </el-table-column>
-      <el-table-column
+      </zm-table-column>
+      <zm-table-column
         label="团队概述"
         align="center"
         prop="summary"
         min-width="260"
         show-overflow-tooltip
       />
-      <el-table-column label="创建时间" align="center" prop="createTime" min-width="180">
+      <zm-table-column label="创建时间" align="center" prop="createTime" min-width="180">
         <template #default="{ row }">
           {{ formatDate(row.createTime) }}
         </template>
-      </el-table-column>
-      <el-table-column label="操作" align="center" width="160" fixed="right">
+      </zm-table-column>
+      <zm-table-column label="操作" align="center" width="160" fixed="right" action>
         <template #default="{ row }">
           <el-button link type="primary" @click.stop="openForm('update', row.id)">编辑</el-button>
           <el-button link type="danger" @click.stop="handleDelete(row.id)">删除</el-button>
         </template>
-      </el-table-column>
-    </el-table>
+      </zm-table-column>
+    </zm-table>
 
     <Pagination
       :total="total"
@@ -517,9 +517,10 @@ onMounted(async () => {
 
 <style scoped lang="scss">
 .team-logo {
-  width: 52px;
-  height: 52px;
+  width: 42px;
+  height: 42px;
   border-radius: 8px;
+  margin-top: 4px;
   border: 1px solid #e5e7eb;
 }
 </style>

+ 1150 - 0
src/views/technology‌/list/index2.vue

@@ -0,0 +1,1150 @@
+<template>
+  <div
+    class="grid grid-cols-[auto_1fr] grid-rows-[auto_auto_minmax(0,1fr)] gap-0 gap-x-4 h-[calc(100vh-20px-var(--top-tool-height)-var(--tags-view-height)-var(--app-footer-height))]"
+  >
+    <DeptTreeSelect
+      class="row-span-4"
+      :top-id="rootDeptId"
+      :deptId="deptId"
+      v-model="queryParams.departmentId"
+      :init-select="false"
+      :show-title="false"
+      request-api="getSimpleDeptList"
+      @node-click="handleDeptNodeClick"
+    />
+
+    <div class="mb-1">
+      <el-form
+        ref="queryFormRef"
+        :model="queryParams"
+        :inline="true"
+        @submit.prevent
+        class="bg-white dark:bg-[#1d1e1f] rounded-lg shadow px-8 pt-4 flex items-center flex-wrap min-w-0"
+      >
+        <el-form-item label="技术类型" prop="technologyType">
+          <el-select
+            style="width: 150px"
+            v-model="queryParams.technologyType"
+            placeholder="请选择技术类型"
+            clearable
+          >
+            <el-option
+              v-for="dict in getStrDictOptions(DICT_TYPE.RD_CORE_TECHNOLOGY_TYPE)"
+              :key="dict.value"
+              :label="dict.label"
+              :value="dict.value"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="技术名称" prop="technologyName">
+          <el-input
+            v-model="queryParams.technologyName"
+            placeholder="请输入技术名称"
+            clearable
+            style="width: 150px"
+          />
+        </el-form-item>
+        <el-form-item label="当前状态" prop="technologyStatus">
+          <el-select
+            style="width: 150px"
+            v-model="queryParams.technologyStatus"
+            placeholder="请选择当前状态"
+            clearable
+          >
+            <el-option
+              v-for="dict in getStrDictOptions(DICT_TYPE.RD_CORE_TECHNOLOGY_STATUS)"
+              :key="dict.value"
+              :label="dict.label"
+              :value="dict.value"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="技术级别" prop="technologyLevel">
+          <el-select
+            style="width: 150px"
+            v-model="queryParams.technologyLevel"
+            placeholder="请选择技术级别"
+            clearable
+          >
+            <el-option
+              v-for="dict in getStrDictOptions(DICT_TYPE.RD_CORE_TECHNOLOGY_LEVEL)"
+              :key="dict.value"
+              :label="dict.label"
+              :value="dict.value"
+            />
+          </el-select>
+        </el-form-item>
+
+        <el-form-item>
+          <el-button @click="handleQuery"> <Icon icon="ep:search" class="mr-5px" />搜索 </el-button>
+          <el-button @click="resetQuery"> <Icon icon="ep:refresh" class="mr-5px" />重置 </el-button>
+          <el-button type="primary" plain @click="openForm('create')">
+            <Icon icon="ep:plus" class="mr-5px" />新增
+          </el-button>
+          <el-button type="success" plain :loading="exportLoading" @click="handleExport">
+            <Icon icon="ep:download" class="mr-5px" />导出
+          </el-button>
+        </el-form-item>
+      </el-form>
+    </div>
+
+    <div class="min-w-0"></div>
+
+    <div class="bg-white dark:bg-[#1d1e1f] shadow rounded-lg flex flex-col p-2 pt-4 min-w-0">
+      <div class="flex-1 relative min-h-0">
+        <el-auto-resizer class="absolute">
+          <template #default="{ width, height }">
+            <zm-table :loading="loading" :data="list" :width="width" :height="height">
+              <zm-table-column label="序号" width="70" align="center">
+                <template #default="scope">
+                  {{ scope.$index + 1 }}
+                </template>
+              </zm-table-column>
+
+              <zm-table-column
+                label="核心技术名称"
+                prop="technologyName"
+                min-width="180"
+                align="center"
+                show-overflow-tooltip
+              />
+              <zm-table-column
+                label="技术类型"
+                prop="technologyType"
+                min-width="130"
+                align="center"
+              >
+                <template #default="scope">
+                  <dict-tag
+                    :type="DICT_TYPE.RD_CORE_TECHNOLOGY_TYPE"
+                    :value="scope.row.technologyType"
+                  />
+                </template>
+              </zm-table-column>
+              <zm-table-column
+                label="当前状态"
+                prop="technologyStatus"
+                min-width="120"
+                align="center"
+              >
+                <template #default="scope">
+                  <dict-tag
+                    :type="DICT_TYPE.RD_CORE_TECHNOLOGY_STATUS"
+                    :value="scope.row.technologyStatus"
+                  />
+                </template>
+              </zm-table-column>
+              <zm-table-column
+                label="技术级别"
+                prop="technologyLevel"
+                min-width="120"
+                align="center"
+              >
+                <template #default="scope">
+                  <dict-tag
+                    :type="DICT_TYPE.RD_CORE_TECHNOLOGY_LEVEL"
+                    :value="scope.row.technologyLevel"
+                  />
+                </template>
+              </zm-table-column>
+              <zm-table-column label="所属单位" prop="departmentId" min-width="140" align="center">
+                <template #default="{ row }">{{ getDeptName(row.departmentId) }}</template>
+              </zm-table-column>
+              <zm-table-column
+                label="合作单位"
+                prop="partner"
+                min-width="160"
+                align="center"
+                show-overflow-tooltip
+              />
+              <zm-table-column
+                label="概述"
+                prop="summary"
+                min-width="220"
+                align="center"
+                show-overflow-tooltip
+              />
+              <zm-table-column
+                label="主要原理及内容"
+                prop="principles"
+                min-width="240"
+                align="center"
+                show-overflow-tooltip
+              />
+              <zm-table-column
+                label="应用场景和优势"
+                prop="advantages"
+                min-width="240"
+                align="center"
+                show-overflow-tooltip
+              />
+              <zm-table-column label="操作" align="center" fixed="right" min-width="190" action>
+                <template #default="scope">
+                  <el-button link type="primary" @click="openDetail(scope.row.id)">
+                    <Icon icon="ep:view" class="mr-1" />详情
+                  </el-button>
+                  <el-button link type="primary" @click="openForm('update', scope.row.id)">
+                    编辑
+                  </el-button>
+                  <el-button link type="danger" @click="handleDelete(scope.row.id)">
+                    删除
+                  </el-button>
+                </template>
+              </zm-table-column>
+            </zm-table>
+          </template>
+        </el-auto-resizer>
+      </div>
+
+      <div class="h-8 mt-2 flex items-center justify-end">
+        <Pagination
+          :total="total"
+          v-model:page="queryParams.pageNo"
+          v-model:limit="queryParams.pageSize"
+          @pagination="getList"
+        />
+      </div>
+    </div>
+  </div>
+
+  <Dialog
+    :title="dialogTitle"
+    v-model="dialogVisible"
+    width="760px"
+    :close-on-click-modal="false"
+    :close-on-press-escape="false"
+    destroy-on-close
+    @close="closeDialog"
+  >
+    <el-form
+      ref="formRef"
+      :model="formData"
+      label-width="110px"
+      :rules="formRules"
+      v-loading="formLoading"
+    >
+      <el-row :gutter="20">
+        <el-col :span="12">
+          <el-form-item label="技术类型" prop="technologyType">
+            <el-select v-model="formData.technologyType" placeholder="请选择技术类型" clearable>
+              <el-option
+                v-for="dict in getStrDictOptions(DICT_TYPE.RD_CORE_TECHNOLOGY_TYPE)"
+                :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="technologyName">
+            <el-input v-model="formData.technologyName" placeholder="请输入核心技术名称" />
+          </el-form-item>
+        </el-col>
+      </el-row>
+
+      <el-row :gutter="20">
+        <el-col :span="12">
+          <el-form-item label="当前状态" prop="technologyStatus">
+            <el-select v-model="formData.technologyStatus" placeholder="请选择当前状态" clearable>
+              <el-option
+                v-for="dict in getStrDictOptions(DICT_TYPE.RD_CORE_TECHNOLOGY_STATUS)"
+                :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="technologyLevel">
+            <el-select v-model="formData.technologyLevel" placeholder="请选择技术级别" clearable>
+              <el-option
+                v-for="dict in getStrDictOptions(DICT_TYPE.RD_CORE_TECHNOLOGY_LEVEL)"
+                :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="departmentId">
+            <el-tree-select
+              v-model="formData.departmentId"
+              :data="deptList2"
+              :props="defaultProps"
+              :check-strictly="false"
+              node-key="id"
+              filterable
+              clearable
+              placeholder="请选择所属单位"
+              class="w-full"
+            />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="合作单位" prop="partner">
+            <el-input v-model="formData.partner" placeholder="请输入合作单位" />
+          </el-form-item>
+        </el-col>
+      </el-row>
+
+      <el-form-item label="概述" prop="summary">
+        <el-input v-model="formData.summary" type="textarea" :rows="3" placeholder="请输入概述" />
+      </el-form-item>
+      <el-form-item label="主要原理及内容" prop="principles">
+        <el-input
+          v-model="formData.principles"
+          type="textarea"
+          :rows="4"
+          placeholder="请输入主要原理及内容"
+        />
+      </el-form-item>
+      <el-form-item label="应用场景和优势" prop="advantages">
+        <el-input
+          v-model="formData.advantages"
+          type="textarea"
+          :rows="4"
+          placeholder="请输入应用场景和优势"
+        />
+      </el-form-item>
+      <el-form-item label="专利文件">
+        <UploadFile
+          :file-type="['doc', 'docx', 'pdf', 'jpg', 'png', 'jpeg']"
+          :return-object="true"
+          v-model="patentAttachments"
+          :limit="10"
+        />
+      </el-form-item>
+      <el-form-item label="论文文件">
+        <UploadFile
+          :file-type="['doc', 'docx', 'pdf', 'jpg', 'png', 'jpeg']"
+          :return-object="true"
+          v-model="paperAttachments"
+          :limit="10"
+        />
+      </el-form-item>
+      <el-form-item label="其他资料">
+        <UploadFile
+          :file-type="['doc', 'docx', 'pdf', 'jpg', 'png', 'jpeg']"
+          :return-object="true"
+          v-model="otherMaterialAttachments"
+          :limit="10"
+        />
+      </el-form-item>
+      <el-form-item label="进度明细" prop="details">
+        <div class="details-editor">
+          <div class="details-toolbar">
+            <span class="details-tip">最新研发/应用/成果进展</span>
+            <el-button type="primary" plain size="small" @click="addDetail">
+              <Icon icon="ep:plus" class="mr-5px" />新增进度
+            </el-button>
+          </div>
+
+          <div v-if="formData.details.length" class="details-list">
+            <div v-for="(detail, index) in formData.details" :key="index" class="detail-row">
+              <el-date-picker
+                v-model="detail.progressDate"
+                type="date"
+                value-format="x"
+                placeholder="请选择进度日期"
+                class="detail-date"
+              />
+              <el-input
+                v-model="detail.overview"
+                type="textarea"
+                :rows="1"
+                placeholder="请输入进度描述"
+                class="detail-overview"
+              />
+              <el-button
+                link
+                type="danger"
+                class="detail-delete"
+                title="删除此条进度"
+                @click="removeDetail(index)"
+              >
+                <Icon icon="ep:delete" />
+              </el-button>
+            </div>
+          </div>
+          <el-empty v-else :image-size="48" description="暂无进度明细,点击右上角新增" />
+        </div>
+      </el-form-item>
+    </el-form>
+
+    <template #footer>
+      <el-button type="primary" @click="submitForm" :disabled="formLoading">确定</el-button>
+      <el-button @click="dialogVisible = false">取消</el-button>
+    </template>
+  </Dialog>
+
+  <Dialog
+    v-model="detailVisible"
+    title="核心技术详情"
+    width="820px"
+    :close-on-click-modal="false"
+    destroy-on-close
+  >
+    <div v-loading="detailLoading" class="technology-detail">
+      <div class="detail-hero">
+        <div class="detail-hero-icon"><Icon icon="ep:collection" /></div>
+        <div class="detail-hero-content">
+          <div class="detail-hero-label">CORE TECHNOLOGY</div>
+          <h2>{{ detailData.technologyName || '未命名技术' }}</h2>
+          <div class="detail-hero-meta">
+            <dict-tag
+              :type="DICT_TYPE.RD_CORE_TECHNOLOGY_TYPE"
+              :value="detailData.technologyType"
+            />
+            <span>{{ detailData.partner || '暂无合作单位' }}</span>
+          </div>
+        </div>
+      </div>
+
+      <div class="detail-section">
+        <div class="detail-section-title"><Icon icon="ep:info-filled" />基本信息</div>
+        <div class="detail-info-grid">
+          <div class="detail-info-item">
+            <span>当前状态</span>
+            <dict-tag
+              :type="DICT_TYPE.RD_CORE_TECHNOLOGY_STATUS"
+              :value="detailData.technologyStatus"
+            />
+          </div>
+          <div class="detail-info-item">
+            <span>技术级别</span>
+            <dict-tag
+              :type="DICT_TYPE.RD_CORE_TECHNOLOGY_LEVEL"
+              :value="detailData.technologyLevel"
+            />
+          </div>
+          <div class="detail-info-item">
+            <span>所属单位</span>
+            <strong>{{ getDeptName(detailData.departmentId) }}</strong>
+          </div>
+          <div class="detail-info-item">
+            <span>合作单位</span>
+            <strong>{{ detailData.partner || '-' }}</strong>
+          </div>
+        </div>
+      </div>
+
+      <div class="detail-section detail-text-section">
+        <div class="detail-section-title"><Icon icon="ep:document" />技术说明</div>
+        <div class="detail-text-block">
+          <span>概述</span>
+          <p>{{ detailData.summary || '暂无内容' }}</p>
+        </div>
+        <div class="detail-text-block">
+          <span>主要原理及内容</span>
+          <p>{{ detailData.principles || '暂无内容' }}</p>
+        </div>
+        <div class="detail-text-block">
+          <span>应用场景和优势</span>
+          <p>{{ detailData.advantages || '暂无内容' }}</p>
+        </div>
+      </div>
+
+      <div class="detail-section">
+        <div class="detail-section-title"><Icon icon="ep:paperclip" />相关资料</div>
+        <div class="detail-attachments">
+          <div v-for="group in detailAttachmentGroups" :key="group.type" class="attachment-group">
+            <span class="attachment-label">{{ group.label }}</span>
+            <div v-if="group.files.length" class="attachment-list">
+              <div
+                v-for="(file, index) in group.files"
+                :key="file.filePath || index"
+                class="attachment-item"
+              >
+                <span class="attachment-name" :title="file.name || file.fileName || file.filePath">
+                  <Icon icon="ep:document" class="attachment-file-icon" />
+                  {{ file.name || file.fileName || file.filePath }}
+                </span>
+                <span class="attachment-actions">
+                  <el-button
+                    link
+                    type="primary"
+                    title="预览文件"
+                    @click="viewFileInfo(file.filePath)"
+                  >
+                    <Icon icon="ep:view" />
+                  </el-button>
+                  <el-button
+                    link
+                    type="primary"
+                    title="下载文件"
+                    @click="downloadFile(file.filePath, file.name || file.fileName)"
+                  >
+                    <Icon icon="ep:download" />
+                  </el-button>
+                </span>
+              </div>
+            </div>
+            <span v-else class="empty-value">暂无文件</span>
+          </div>
+        </div>
+      </div>
+
+      <div class="detail-section">
+        <div class="detail-section-title"><Icon icon="ep:clock" />进度明细</div>
+        <div v-if="detailData.details.length" class="detail-timeline">
+          <div v-for="(detail, index) in detailData.details" :key="index" class="timeline-item">
+            <div class="timeline-dot"></div>
+            <div class="timeline-content">
+              <strong>{{ formatProgressDate(detail.progressDate) }}</strong>
+              <p>{{ detail.overview || '暂无进度描述' }}</p>
+            </div>
+          </div>
+        </div>
+        <el-empty v-else :image-size="56" description="暂无进度明细" />
+      </div>
+    </div>
+  </Dialog>
+</template>
+
+<script setup lang="ts">
+import { computed, onMounted, reactive, ref } from 'vue'
+import {
+  getTechnologyList,
+  createTechnology,
+  updateTechnology,
+  getTechnologyDetail,
+  deleteTechnology,
+  exportTechnology
+} from '@/api/technology‌'
+import { useUserStore } from '@/store/modules/user'
+import { defaultProps } from '@/utils/tree'
+import * as DeptApi from '@/api/system/dept'
+import { DICT_TYPE, getStrDictOptions } from '@/utils/dict'
+
+const Base64 = {
+  encode: (value: string) =>
+    window.btoa(
+      encodeURIComponent(value).replace(/%([0-9A-F]{2})/g, (_, code) =>
+        String.fromCharCode(Number.parseInt(code, 16))
+      )
+    )
+}
+
+defineOptions({ name: 'CoreTechnology' })
+
+const rootDeptId = 156
+const deptId = useUserStore().getUser.deptId || rootDeptId
+const message = useMessage()
+const loading = ref(false)
+const exportLoading = ref(false)
+const dialogVisible = ref(false)
+const dialogTitle = ref('')
+const formLoading = ref(false)
+const list = ref<any[]>([])
+const total = ref(0)
+const deptList2 = ref<Tree[]>([])
+const formType = ref<'create' | 'update'>('create')
+const formRef = ref()
+const queryFormRef = ref()
+const detailVisible = ref(false)
+const detailLoading = ref(false)
+const detailData = ref<any>({ details: [], attachments: [] })
+const patentAttachments = ref<any[]>([])
+const paperAttachments = ref<any[]>([])
+const otherMaterialAttachments = ref<any[]>([])
+
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  technologyName: undefined,
+  technologyType: undefined,
+  technologyStatus: undefined,
+  technologyLevel: undefined,
+  departmentId: undefined
+})
+
+const createDefaultForm = () => ({
+  id: undefined,
+  technologyName: '',
+  technologyType: '',
+  technologyStatus: '',
+  technologyLevel: '',
+  departmentId: undefined,
+  partner: '',
+  summary: '',
+  principles: '',
+  advantages: '',
+  details: [] as Array<{ progressDate: string | number; overview: string }>
+})
+
+const formData = ref(createDefaultForm())
+
+const formRules = {
+  technologyName: [{ required: true, message: '请输入核心技术名称', trigger: 'blur' }],
+  technologyType: [{ required: true, message: '请选择技术类型', trigger: 'change' }],
+  technologyStatus: [{ required: true, message: '请选择当前状态', trigger: 'change' }],
+  technologyLevel: [{ required: true, message: '请选择技术级别', trigger: 'change' }],
+  departmentId: [{ required: true, message: '请选择所属单位', trigger: 'change' }]
+}
+
+const getDeptName = (departmentId?: number) => {
+  if (!departmentId) return '-'
+  const findDept = (departments: Tree[]): string | undefined => {
+    for (const department of departments) {
+      if (department.id === departmentId) return department.name
+      const name = department.children ? findDept(department.children) : undefined
+      if (name) return name
+    }
+    return undefined
+  }
+  return findDept(deptList2.value) || String(departmentId)
+}
+
+const normalizeAttachments = (attachments: unknown, type?: string): any[] => {
+  if (!attachments) return []
+  const values = Array.isArray(attachments) ? attachments : [attachments]
+  return values
+    .flatMap((attachment: any) => {
+      if (typeof attachment === 'string') {
+        return attachment
+          .split(',')
+          .map((filePath) => filePath.trim())
+          .filter(Boolean)
+          .map((filePath) => (type ? { filePath, type } : filePath))
+      }
+      if (attachment && typeof attachment === 'object') {
+        const filePath =
+          attachment.filePath || attachment.path || attachment.url || attachment.fileUrl
+        return filePath ? [{ ...attachment, filePath, ...(type ? { type } : {}) }] : []
+      }
+      return []
+    })
+    .filter((attachment) =>
+      typeof attachment === 'string' ? attachment.length > 0 : attachment.filePath
+    )
+}
+
+const splitAttachments = (attachments: unknown) => {
+  const values = normalizeAttachments(attachments)
+  patentAttachments.value = values.filter(
+    (attachment) => !attachment.type || attachment.type === 'PATENT'
+  )
+  paperAttachments.value = values.filter((attachment) => attachment.type === 'PAPER')
+  otherMaterialAttachments.value = values.filter(
+    (attachment) => attachment.type === 'OTHER_MATERIAL'
+  )
+}
+
+const getAttachments = () => [
+  ...normalizeAttachments(patentAttachments.value, 'PATENT'),
+  ...normalizeAttachments(paperAttachments.value, 'PAPER'),
+  ...normalizeAttachments(otherMaterialAttachments.value, 'OTHER_MATERIAL')
+]
+
+const detailAttachmentGroups = computed(() => {
+  const attachments = normalizeAttachments(detailData.value.attachments)
+  return [
+    {
+      type: 'PATENT',
+      label: '专利文件',
+      files: attachments.filter((file) => !file.type || file.type === 'PATENT')
+    },
+    {
+      type: 'PAPER',
+      label: '论文文件',
+      files: attachments.filter((file) => file.type === 'PAPER')
+    },
+    {
+      type: 'OTHER_MATERIAL',
+      label: '其他资料',
+      files: attachments.filter((file) => file.type === 'OTHER_MATERIAL')
+    }
+  ]
+})
+
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await getTechnologyList(queryParams)
+    list.value = data.list || []
+    total.value = data.total || 0
+  } finally {
+    loading.value = false
+  }
+}
+
+const handleDeptNodeClick = (row) => {
+  queryParams.departmentId = row.id
+  queryParams.pageNo = 1
+  getList()
+}
+
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+const resetQuery = () => {
+  queryParams.pageNo = 1
+  queryParams.technologyName = undefined
+  queryParams.technologyType = undefined
+  queryParams.technologyStatus = undefined
+  queryParams.technologyLevel = undefined
+  queryParams.departmentId = undefined
+  queryFormRef.value?.resetFields()
+  getList()
+}
+
+const openForm = async (type: 'create' | 'update', id?: number) => {
+  formType.value = type
+  dialogTitle.value = type === 'create' ? '新增核心技术' : '编辑核心技术'
+  if (type === 'create') {
+    formData.value = createDefaultForm()
+    patentAttachments.value = []
+    paperAttachments.value = []
+    otherMaterialAttachments.value = []
+    dialogVisible.value = true
+    return
+  }
+
+  loading.value = true
+  try {
+    const data = await getTechnologyDetail(id!)
+    formData.value = {
+      ...createDefaultForm(),
+      ...data,
+      details: Array.isArray(data.details) ? data.details : []
+    }
+    splitAttachments(data.attachments)
+    dialogVisible.value = true
+  } finally {
+    loading.value = false
+  }
+}
+
+const openDetail = async (id: number) => {
+  detailVisible.value = true
+  detailLoading.value = true
+  try {
+    const data = await getTechnologyDetail(id)
+    detailData.value = {
+      ...createDefaultForm(),
+      ...data,
+      details: Array.isArray(data.details) ? data.details : []
+    }
+  } finally {
+    detailLoading.value = false
+  }
+}
+
+const formatProgressDate = (date: string | number) => {
+  if (!date) return '未设置日期'
+  return new Date(date).toLocaleDateString('zh-CN')
+}
+
+const viewFileInfo = (file: string) => {
+  window.open(
+    'http://doc.deepoil.cc:8012/onlinePreview?url=' + encodeURIComponent(Base64.encode(file))
+  )
+}
+
+const downloadFile = (file: string, fileName?: string) => {
+  const link = document.createElement('a')
+  link.href = file
+  link.download = fileName || file.split('/').pop() || '下载文件'
+  link.target = '_blank'
+  link.click()
+}
+
+const closeDialog = () => {
+  formRef.value?.resetFields()
+}
+
+const addDetail = () => {
+  formData.value.details.push({ progressDate: '', overview: '' })
+}
+
+const removeDetail = (index: number) => {
+  formData.value.details.splice(index, 1)
+}
+
+const submitForm = () => {
+  formRef.value.validate(async (valid: boolean) => {
+    if (!valid) return
+    formLoading.value = true
+    try {
+      const payload = { ...formData.value, attachments: getAttachments() }
+      if (formType.value === 'update') {
+        payload.attachments = payload.attachments.map(({ id, ...attachment }) => attachment)
+        payload.details = formData.value.details.map(({ id, ...detail }) => detail)
+      }
+      if (formType.value === 'create') {
+        await createTechnology(payload)
+        message.success('新增成功')
+      } else {
+        await updateTechnology(payload)
+        message.success('更新成功')
+      }
+      dialogVisible.value = false
+      getList()
+    } finally {
+      formLoading.value = false
+    }
+  })
+}
+
+const handleDelete = async (id: number) => {
+  try {
+    await message.delConfirm()
+    await deleteTechnology(id)
+    message.success('删除成功')
+    getList()
+  } catch {}
+}
+
+const handleExport = async () => {
+  exportLoading.value = true
+  try {
+    await exportTechnology(queryParams)
+  } finally {
+    exportLoading.value = false
+  }
+}
+
+onMounted(async () => {
+  getList()
+  deptList2.value = await DeptApi.getSimpleDeptList()
+})
+</script>
+
+<style scoped lang="scss">
+.details-editor {
+  width: 100%;
+  padding: 10px 12px;
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: 4px;
+  background: var(--el-fill-color-blank);
+  box-sizing: border-box;
+}
+
+.details-toolbar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  margin-bottom: 10px;
+}
+
+.details-tip {
+  color: var(--el-text-color-secondary);
+  font-size: 12px;
+}
+
+.details-list {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+
+.detail-row {
+  position: relative;
+  display: flex;
+  align-items: flex-start;
+  gap: 10px;
+}
+
+.detail-date {
+  width: 170px;
+  flex: 0 0 170px;
+}
+
+.detail-overview {
+  flex: 1;
+  min-width: 0;
+}
+
+.detail-delete {
+  flex: 0 0 28px;
+  margin-top: 5px;
+}
+
+.technology-detail {
+  max-height: 68vh;
+  overflow-y: auto;
+  padding: 0 4px 8px;
+}
+
+.detail-hero {
+  display: flex;
+  align-items: center;
+  gap: 16px;
+  padding: 20px 22px;
+  border-radius: 10px;
+  background: linear-gradient(135deg, var(--el-color-primary-light-9), var(--el-fill-color-light));
+}
+
+.detail-hero-icon {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 48px;
+  height: 48px;
+  flex: 0 0 48px;
+  border-radius: 12px;
+  color: var(--el-color-primary);
+  background: var(--el-color-primary-light-8);
+  font-size: 24px;
+}
+
+.detail-hero-label {
+  color: var(--el-color-primary);
+  font-size: 11px;
+  letter-spacing: 1.5px;
+}
+
+.detail-hero h2 {
+  margin: 4px 0 8px;
+  color: var(--el-text-color-primary);
+  font-size: 20px;
+}
+
+.detail-hero-meta,
+.detail-info-item,
+.attachment-group {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+}
+
+.detail-hero-meta {
+  color: var(--el-text-color-secondary);
+  font-size: 13px;
+}
+
+.detail-section {
+  margin-top: 22px;
+}
+
+.detail-section-title {
+  display: flex;
+  align-items: center;
+  gap: 7px;
+  margin-bottom: 13px;
+  color: var(--el-text-color-primary);
+  font-size: 15px;
+  font-weight: 600;
+}
+
+.detail-section-title :deep(.iconify) {
+  color: var(--el-color-primary);
+}
+
+.detail-info-grid {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 1px;
+  overflow: hidden;
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: 8px;
+  background: var(--el-border-color-lighter);
+}
+
+.detail-info-item {
+  min-height: 48px;
+  padding: 0 16px;
+  background: var(--el-bg-color);
+}
+
+.detail-info-item > span,
+.detail-text-block > span,
+.attachment-label {
+  color: var(--el-text-color-secondary);
+  font-size: 13px;
+}
+
+.detail-info-item > span {
+  width: 72px;
+  flex: 0 0 72px;
+}
+
+.detail-info-item strong {
+  overflow: hidden;
+  color: var(--el-text-color-primary);
+  font-size: 13px;
+  font-weight: 500;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.detail-text-block {
+  padding: 12px 14px;
+  border-left: 3px solid var(--el-color-primary-light-5);
+  background: var(--el-fill-color-lighter);
+}
+
+.detail-text-block + .detail-text-block {
+  margin-top: 10px;
+}
+
+.detail-text-block p {
+  margin: 7px 0 0;
+  color: var(--el-text-color-primary);
+  font-size: 13px;
+  line-height: 1.7;
+  white-space: pre-wrap;
+}
+
+.detail-attachments {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+  padding: 13px 16px;
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: 8px;
+}
+
+.attachment-label {
+  width: 72px;
+  flex: 0 0 72px;
+}
+
+.attachment-list {
+  flex: 1;
+  min-width: 0;
+  display: flex;
+  flex-wrap: wrap;
+  flex-direction: column;
+  gap: 6px;
+}
+
+.attachment-item {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  min-width: 0;
+  width: 100%;
+}
+
+.attachment-name {
+  display: flex;
+  align-items: center;
+  min-width: 0;
+  flex: 1;
+  overflow: hidden;
+  color: var(--el-color-primary);
+  font-size: 13px;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.attachment-file-icon {
+  flex: 0 0 auto;
+  margin-right: 4px;
+}
+
+.attachment-actions {
+  display: flex;
+  flex: 0 0 auto;
+  align-items: center;
+  gap: 2px;
+}
+
+.attachment-actions :deep(.el-button) {
+  width: 26px;
+  height: 26px;
+  margin-left: 0;
+  padding: 0;
+}
+
+.empty-value {
+  color: var(--el-text-color-placeholder);
+  font-size: 13px;
+}
+
+.detail-timeline {
+  padding-left: 10px;
+  border-left: 2px solid var(--el-color-primary-light-8);
+}
+
+.timeline-item {
+  position: relative;
+  padding: 0 0 16px 18px;
+}
+
+.timeline-item:last-child {
+  padding-bottom: 0;
+}
+
+.timeline-dot {
+  position: absolute;
+  top: 3px;
+  left: -17px;
+  width: 8px;
+  height: 8px;
+  border: 3px solid var(--el-color-primary-light-8);
+  border-radius: 50%;
+  background: var(--el-color-primary);
+}
+
+.timeline-content strong {
+  color: var(--el-color-primary);
+  font-size: 13px;
+}
+
+.timeline-content p {
+  margin: 5px 0 0;
+  color: var(--el-text-color-regular);
+  font-size: 13px;
+  line-height: 1.6;
+}
+
+:deep(.el-empty) {
+  padding: 12px 0 4px;
+}
+
+@media (max-width: 640px) {
+  .detail-info-grid {
+    grid-template-columns: 1fr;
+  }
+
+  .detail-hero {
+    padding: 16px;
+  }
+
+  .detail-hero h2 {
+    font-size: 18px;
+  }
+
+  .attachment-group {
+    align-items: flex-start;
+  }
+
+  .details-toolbar,
+  .detail-row {
+    align-items: stretch;
+  }
+
+  .details-toolbar {
+    flex-direction: column;
+  }
+
+  .detail-row {
+    flex-wrap: wrap;
+  }
+
+  .detail-date {
+    width: calc(100% - 38px);
+    flex-basis: calc(100% - 38px);
+  }
+
+  .detail-overview {
+    flex-basis: 100%;
+  }
+
+  .detail-delete {
+    position: absolute;
+    margin-left: calc(100% - 28px);
+  }
+}
+</style>