yanghao 2 settimane fa
parent
commit
c963c1e2a3

+ 1 - 1
.env.local

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

+ 1 - 0
package.json

@@ -75,6 +75,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",

+ 21 - 0
pnpm-lock.yaml

@@ -158,6 +158,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))
@@ -1848,6 +1851,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 +5038,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}
@@ -6877,6 +6892,8 @@ snapshots:
 
   '@types/semver@7.5.8': {}
 
+  '@types/sortablejs@1.15.9': {}
+
   '@types/trusted-types@2.0.7':
     optional: true
 
@@ -10466,6 +10483,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>

+ 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 变量也不会自动生效。

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

@@ -0,0 +1,437 @@
+<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'
+
+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>
+                  <div class="setting-icon i-lucide:settings"></div>
+                </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>

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

@@ -0,0 +1,255 @@
+<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="拖拽排序"
+        >
+          <div class="i-lucide:grip-vertical"></div>
+        </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>
+          </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')"
+          >
+            <div class="i-lucide:panel-left"></div>
+          </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')"
+          >
+            <div class="i-lucide:panel-right"></div>
+          </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>
+  }
+}

+ 19 - 0
src/views/technology‌/list/index.vue

@@ -0,0 +1,19 @@
+<template>
+  <div class="core-container"> </div>
+</template>
+
+<script setup lang="ts">
+import { computed, nextTick, onMounted, reactive, ref } from 'vue'
+import {
+  getTechnologyList,
+  createTechnology,
+  updateTechnology,
+  getTechnologyDetail
+} from '@/api/technology'
+import * as DeptApi from '@/api/system/dept'
+import { defaultProps } from '@/utils/tree'
+import { DICT_TYPE, getStrDictOptions } from '@/utils/dict'
+import { ElTable } from 'element-plus'
+
+defineOptions({ name: 'Technology‌List' })
+</script>

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

@@ -0,0 +1,463 @@
+<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.deptId"
+      :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="type">
+          <el-select
+            style="width: 150px"
+            v-model="queryParams.type"
+            placeholder="请选择类型"
+            clearable
+          >
+            <el-option
+              v-for="dict in getStrDictOptions(DICT_TYPE.QHSE_LAW_TYPE)"
+              :key="dict.value"
+              :label="dict.label"
+              :value="dict.value"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="名称" prop="name">
+          <el-input
+            v-model="queryParams.name"
+            placeholder="请输入名称"
+            clearable
+            class="!w-220px"
+          />
+        </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')"
+            v-hasPermi="['rq:qhse-law:create']"
+          >
+            <Icon icon="ep:plus" class="mr-5px" />新增
+          </el-button>
+          <el-button
+            type="success"
+            plain
+            :loading="exportLoading"
+            @click="handleExport"
+            v-hasPermi="['rq:qhse-law:export']"
+          >
+            <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="name"
+                min-width="180"
+                align="center"
+                show-overflow-tooltip
+              />
+              <zm-table-column label="类型" prop="type" min-width="110" align="center">
+                <template #default="scope">
+                  <dict-tag :type="DICT_TYPE.QHSE_LAW_TYPE" :value="scope.row.type" />
+                </template>
+              </zm-table-column>
+
+              <zm-table-column label="发布日期" prop="publishDate" min-width="160" align="center">
+                <template #default="{ row }">{{
+                  formatDateTime(row.publishDate).substring(0, 10)
+                }}</template>
+              </zm-table-column>
+              <zm-table-column label="生效日期" prop="effectiveDate" min-width="160" align="center">
+                <template #default="{ row }">{{
+                  formatDateTime(row.effectiveDate).substring(0, 10)
+                }}</template>
+              </zm-table-column>
+
+              <zm-table-column label="最新修订日期" prop="newDate" min-width="160" align="center">
+                <template #default="{ row }">{{
+                  formatDateTime(row.newDate).substring(0, 10)
+                }}</template>
+              </zm-table-column>
+              <zm-table-column label="发布部门" prop="publishDept" min-width="140" align="center" />
+              <zm-table-column label="文件编号" prop="fileNo" min-width="140" align="center" />
+              <!-- <zm-table-column label="部门" prop="deptName" min-width="120" align="center">
+                <template #default="{ row }">{{ row.deptName || row.deptId || '-' }}</template>
+              </zm-table-column> -->
+
+              <zm-table-column
+                label="适用条款"
+                prop="applicableTerm"
+                min-width="180"
+                align="center"
+                show-overflow-tooltip
+              />
+              <zm-table-column
+                label="备注"
+                prop="remark"
+                min-width="160"
+                align="center"
+                show-overflow-tooltip
+              />
+              <zm-table-column label="操作" align="center" fixed="right" min-width="140" action>
+                <template #default="scope">
+                  <el-button
+                    link
+                    type="primary"
+                    @click="openForm('update', scope.row.id)"
+                    v-hasPermi="['rq:qhse-law:update']"
+                  >
+                    编辑
+                  </el-button>
+                  <el-button
+                    link
+                    type="danger"
+                    @click="handleDelete(scope.row.id)"
+                    v-hasPermi="['rq:qhse-law:delete']"
+                  >
+                    删除
+                  </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"
+    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="type">
+            <el-select v-model="formData.type" placeholder="请选择类型" clearable>
+              <el-option
+                v-for="dict in getStrDictOptions(DICT_TYPE.QHSE_LAW_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="name">
+            <el-input v-model="formData.name" placeholder="请输入法规名称" />
+          </el-form-item>
+        </el-col>
+      </el-row>
+
+      <el-row :gutter="20">
+        <el-col :span="12">
+          <el-form-item label="发布日期" prop="publishDate">
+            <el-date-picker
+              v-model="formData.publishDate"
+              type="date"
+              value-format="x"
+              placeholder="请选择发布日期"
+              style="width: 100%"
+            />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="生效日期" prop="effectiveDate">
+            <el-date-picker
+              v-model="formData.effectiveDate"
+              type="date"
+              value-format="x"
+              placeholder="请选择生效日期"
+              style="width: 100%"
+            />
+          </el-form-item>
+        </el-col>
+      </el-row>
+
+      <el-row :gutter="20">
+        <el-col :span="12">
+          <el-form-item label="最新修订日期" prop="newDate">
+            <el-date-picker
+              v-model="formData.newDate"
+              type="date"
+              value-format="x"
+              placeholder="请选择最新修订日期"
+              style="width: 100%"
+            />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="文件编号" prop="fileNo">
+            <el-input v-model="formData.fileNo" placeholder="请输入文件编号" />
+          </el-form-item>
+        </el-col>
+      </el-row>
+
+      <el-row :gutter="20">
+        <el-col :span="12">
+          <el-form-item label="发布部门" prop="publishDept">
+            <el-input v-model="formData.publishDept" placeholder="请输入发布部门" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="部门" prop="deptId">
+            <el-tree-select
+              v-model="formData.deptId"
+              :data="deptList2"
+              :props="defaultProps"
+              :check-strictly="false"
+              node-key="id"
+              filterable
+              clearable
+              placeholder="请选择部门"
+              class="w-full"
+            />
+          </el-form-item>
+        </el-col>
+      </el-row>
+
+      <el-form-item label="适用条款" prop="applicableTerm">
+        <el-input
+          v-model="formData.applicableTerm"
+          type="textarea"
+          :rows="3"
+          placeholder="请输入适用条款"
+        />
+      </el-form-item>
+      <el-form-item label="备注" prop="remark">
+        <el-input v-model="formData.remark" type="textarea" :rows="3" placeholder="请输入备注" />
+      </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>
+</template>
+
+<script setup lang="ts">
+import { onMounted, reactive, ref } from 'vue'
+import { useI18n } from 'vue-i18n'
+import { formatDate } from '@/utils/formatTime'
+import {
+  getTechnologyList,
+  createTechnology,
+  updateTechnology,
+  getTechnologyDetail
+} 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'
+
+defineOptions({ name: 'QhseIndustryStandard' })
+
+const rootDeptId = 156
+const deptId = useUserStore().getUser.deptId || rootDeptId
+const message = useMessage()
+const { t } = useI18n()
+
+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 queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  type: undefined,
+  name: undefined,
+  publishDept: undefined,
+  deptId: undefined
+})
+
+const createDefaultForm = () => ({
+  id: undefined,
+  type: '',
+  name: '',
+  publishDate: '',
+  effectiveDate: '',
+  publishDept: '',
+  fileNo: '',
+  deptId: undefined,
+  newDate: '',
+  applicableTerm: '',
+  remark: ''
+})
+
+const formData = ref(createDefaultForm())
+
+const formRules = {
+  type: [{ required: true, message: '请输入类型', trigger: 'blur' }],
+  name: [{ required: true, message: '请输入法规名称', trigger: 'blur' }],
+  publishDate: [{ required: true, message: '请选择发布日期', trigger: 'change' }],
+  publishDept: [{ required: true, message: '请输入发布部门', trigger: 'blur' }],
+  fileNo: [{ required: true, message: '请输入文件编号', trigger: 'blur' }],
+  deptId: [{ required: true, message: '请选择部门', trigger: 'change' }],
+  effectiveDate: [{ required: true, message: '请选择生效日期', trigger: 'change' }]
+}
+
+const formatDateTime = (value: string | number) => {
+  return value ? formatDate(value).replace('T', ' ') : '-'
+}
+
+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.deptId = row.id
+  queryParams.pageNo = 1
+  getList()
+}
+
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+const resetQuery = () => {
+  queryParams.pageNo = 1
+  queryParams.type = undefined
+  queryParams.name = undefined
+  queryParams.publishDept = 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()
+    dialogVisible.value = true
+    return
+  }
+
+  loading.value = true
+  try {
+    const data = await getTechnologyDetail(id!)
+    formData.value = {
+      ...createDefaultForm(),
+      ...data
+    }
+    dialogVisible.value = true
+  } finally {
+    loading.value = false
+  }
+}
+
+const closeDialog = () => {
+  formRef.value?.resetFields()
+}
+
+const submitForm = () => {
+  formRef.value.validate(async (valid: boolean) => {
+    if (!valid) return
+    formLoading.value = true
+    try {
+      if (formType.value === 'create') {
+        await createTechnology(formData.value)
+        message.success('新增成功')
+      } else {
+        await updateTechnology(formData.value)
+        message.success('更新成功')
+      }
+      dialogVisible.value = false
+      getList()
+    } finally {
+      formLoading.value = false
+    }
+  })
+}
+
+const handleDelete = async (id: number) => {
+  try {
+    await message.delConfirm()
+    await getTechnologyList(id)
+    message.success('删除成功')
+    getList()
+  } catch {}
+}
+
+const handleExport = async () => {
+  exportLoading.value = true
+  try {
+    await getTechnologyList(queryParams)
+  } finally {
+    exportLoading.value = false
+  }
+}
+
+onMounted(async () => {
+  getList()
+  deptList2.value = await DeptApi.getSimpleDeptList()
+})
+</script>