Zimo 5 روز پیش
والد
کامیت
e95766cd10

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

@@ -0,0 +1,263 @@
+<script lang="ts" setup generic="T">
+import type { TableColumnCtx } from 'element-plus'
+import { computed, useAttrs, inject, ref } from 'vue'
+import { Filter } from '@element-plus/icons-vue'
+import { SortOrder, TableContextKey } from './token'
+
+interface Props extends /* @vue-ignore */ Partial<Omit<TableColumnCtx<T>, 'prop'>> {
+  prop: (keyof T & string) | (string & {})
+  zmSortable?: boolean
+  zmFilterable?: boolean
+  filterModelValue?: any
+}
+
+const emits = defineEmits(['update:filterModelValue'])
+const props = defineProps<Props>()
+const attrs = useAttrs()
+
+const tableContext = inject(TableContextKey, {
+  data: ref([]),
+  sortingFields: ref([]),
+  onQuery: () => {},
+  onSort: () => {},
+  loading: ref(false)
+})
+
+const defaultOptions: Partial<Props> = {
+  align: 'center',
+  resizable: true,
+  showOverflowTooltip: true
+}
+
+const bindProps = computed(() => {
+  const resolvedAlign =
+    props.zmSortable || props.zmFilterable
+      ? 'left'
+      : attrs.align || props.align || defaultOptions.align
+
+  return {
+    ...defaultOptions,
+    ...attrs,
+    ...props,
+    prop: props.prop,
+    align: resolvedAlign,
+    className: (props.className ?? '') + ' ' + props.prop
+  }
+})
+
+const alignMap: Record<string, string> = {
+  center: 'justify-center',
+  left: 'justify-between',
+  right: 'justify-end'
+}
+const headerFlexClass = computed(
+  () => alignMap[bindProps.value.align as string] || 'justify-center'
+)
+
+const currentSortField = computed(() => {
+  if (!props.prop) return undefined
+  return tableContext.sortingFields.value.find((f: any) => f.field === props.prop)
+})
+
+const isSortActive = computed(() => !!currentSortField.value)
+
+const currentOrder = computed<SortOrder | undefined>(() => currentSortField.value?.order)
+
+const handleSortClick = () => {
+  if (!props.prop) return
+
+  let nextOrder: SortOrder | null = 'asc'
+
+  if (currentOrder.value === 'asc') {
+    nextOrder = 'desc'
+  } else if (currentOrder.value === 'desc') {
+    nextOrder = null
+  }
+
+  tableContext.onSort(props.prop, nextOrder)
+}
+
+const handleSearchClick = () => {
+  if (tableContext.onQuery && props.prop) {
+    tableContext.onQuery({
+      prop: props.prop,
+      value: props.filterModelValue
+    })
+  }
+}
+
+const calculativeWidth = () => {
+  const nodes = document.querySelectorAll(`td.${props.prop} > .cell`)
+  Array.from(nodes).forEach((node) => {
+    console.log('node :>> ', node.childNodes[1])
+  })
+  console.log('nodes :>> ', nodes)
+}
+
+watch(
+  () => tableContext.loading.value,
+  (loading) => {
+    if (!loading) {
+      nextTick(() => {
+        calculativeWidth()
+      })
+    }
+  },
+  { immediate: true }
+)
+</script>
+
+<template>
+  <el-table-column ref="columnRef" v-bind="bindProps">
+    <template v-for="(_, name) in $slots" :key="name" #[name]="slotData">
+      <slot v-if="name !== 'header'" :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="bindProps.zmSortable || bindProps.zmFilterable" class="action-area">
+            <el-tooltip
+              v-if="bindProps.zmSortable"
+              :content="
+                currentOrder === 'asc'
+                  ? '点击降序'
+                  : currentOrder === 'desc'
+                    ? '取消排序'
+                    : '点击升序'
+              "
+              placement="top"
+              :show-after="500"
+            >
+              <div
+                class="icon-btn"
+                :class="{ 'is-active': isSortActive }"
+                @click.stop="handleSortClick"
+              >
+                <i
+                  class="zm-sort-icon"
+                  :class="{
+                    'is-desc': currentOrder === 'desc'
+                  }"
+                ></i>
+              </div>
+            </el-tooltip>
+
+            <el-popover
+              v-if="bindProps.zmFilterable"
+              placement="top-end"
+              :popper-options="{ modifiers: [{ name: 'offset', options: { offset: [16, 18] } }] }"
+              trigger="click"
+              :width="260"
+              :show-arrow="false"
+            >
+              <template #reference>
+                <div
+                  class="icon-btn"
+                  :class="{ 'is-active': bindProps.filterModelValue }"
+                  @click.stop
+                >
+                  <el-icon :size="16"><Filter /></el-icon>
+                </div>
+              </template>
+
+              <slot name="filter" v-bind="scope">
+                <div class="flex gap-2 p-1">
+                  <el-input
+                    :model-value="bindProps.filterModelValue"
+                    @input="(val) => emits('update:filterModelValue', val)"
+                    placeholder="输入关键词"
+                    size="small"
+                    clearable
+                    @keydown.enter="handleSearchClick"
+                  />
+                  <el-button type="primary" size="small" @click="handleSearchClick">
+                    搜索
+                  </el-button>
+                </div>
+              </slot>
+            </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%;
+  user-select: none;
+}
+
+.action-area {
+  display: flex;
+  height: 100%;
+  margin-left: 8px;
+  align-items: center;
+  gap: 4px;
+}
+
+.icon-btn {
+  display: flex;
+  width: 20px;
+  height: 20px;
+  color: var(--el-text-color-secondary);
+  cursor: pointer;
+  border-radius: 4px;
+  transition: background-color 0.2s;
+  align-items: center;
+  justify-content: center;
+
+  &:hover {
+    color: var(--el-color-primary);
+    background-color: var(--el-fill-color-darker);
+  }
+
+  &.is-active {
+    color: var(--el-color-primary);
+  }
+}
+
+.zm-sort-icon {
+  position: relative;
+  display: flex;
+  width: 12px;
+  height: 12px;
+  align-items: center;
+  justify-content: center;
+
+  &::before,
+  &::after {
+    position: absolute;
+    width: 8px;
+    height: 2px;
+    background-color: currentcolor;
+    border-radius: 2px;
+    content: '';
+    transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
+  }
+
+  &::before {
+    transform: translateX(-2.2px) rotate(45deg);
+  }
+
+  &::after {
+    transform: translateX(2.2px) rotate(-45deg);
+  }
+
+  &.is-desc {
+    &::before {
+      transform: translateX(-2.2px) rotate(-45deg);
+    }
+
+    &::after {
+      transform: translateX(2.2px) rotate(45deg);
+    }
+  }
+}
+</style>

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

@@ -0,0 +1,145 @@
+<script lang="ts" setup generic="T">
+import type { TableInstance, TableProps } from 'element-plus'
+import { FilterPayload, SortField, SortOrder, TableContextKey } from './token'
+
+interface Props extends /* @vue-ignore */ Partial<Omit<TableProps<T>, 'data'>> {
+  data: T[]
+  loading: boolean
+  handleQuery: (payload?: FilterPayload) => void
+  sortingFields?: SortField[]
+  sortFn?: (prop: string, order: SortOrder | null) => void
+}
+
+const props = defineProps<Props>()
+
+const emits = defineEmits<{
+  'update:sortingFields': [fields: SortField[]]
+}>()
+
+const attrs = useAttrs()
+const tableRef = ref<TableInstance>()
+
+const defaultOptions: Partial<Props> = {
+  size: 'default',
+  stripe: true,
+  border: true,
+  highlightCurrentRow: true,
+  showOverflowTooltip: true,
+  scrollbarAlwaysOn: false
+}
+
+const bindProps = computed(() => {
+  const { data, sortingFields, ...otherProps } = props
+
+  return {
+    ...defaultOptions,
+    ...attrs,
+    ...otherProps,
+    data: data || []
+  }
+})
+
+const handleDefaultSort = (prop: string, order: SortOrder | null) => {
+  const newFields = [...(props.sortingFields || [])]
+
+  const idx = newFields.findIndex((f) => f.field === prop)
+
+  if (order === null) {
+    if (idx > -1) {
+      newFields.splice(idx, 1)
+    }
+  } else {
+    if (idx > -1) {
+      newFields[idx] = { ...newFields[idx], order }
+    } else {
+      newFields.push({ field: prop, order })
+    }
+  }
+
+  emits('update:sortingFields', newFields)
+  props.handleQuery()
+}
+
+const safeSortingFields = computed(() => props.sortingFields || [])
+const safeData = computed(() => props.data || [])
+const safeLoading = computed(() => props.loading)
+
+provide(TableContextKey, {
+  onQuery: (payload) => props.handleQuery?.(payload),
+  onSort: (prop, order) => {
+    if (props.sortFn) {
+      props.sortFn(prop, order)
+    } else {
+      handleDefaultSort(prop, order)
+    }
+  },
+  // 关键:传递响应式的 data 和 sortingFields
+  data: safeData,
+  sortingFields: safeSortingFields,
+  loading: safeLoading
+})
+
+defineExpose({
+  elTableRef: tableRef
+})
+</script>
+
+<template>
+  <el-table ref="tableRef" v-loading="loading" class="zm-table" v-bind="bindProps" :data="data">
+    <template v-for="(_, name) in $slots" #[name]="slotData">
+      <slot :name="name" v-bind="slotData || {}"></slot>
+    </template>
+  </el-table>
+</template>
+
+<style>
+.zm-table {
+  border-radius: 8px;
+
+  &::before,
+  &::after {
+    display: none;
+  }
+
+  .el-table__inner-wrapper {
+    &::before,
+    &::after {
+      display: none;
+    }
+  }
+
+  .el-table__border-left-patch {
+    display: none;
+  }
+
+  .el-table__cell {
+    height: 52px;
+    border: none !important;
+  }
+
+  .el-table__header {
+    .el-table__cell {
+      background: var(--el-fill-color-light) !important;
+
+      .cell {
+        border-right: var(--el-table-border);
+        border-color: var(--el-table-header-text-color);
+      }
+
+      &:last-child {
+        .cell {
+          border-right: none;
+        }
+      }
+    }
+  }
+
+  .el-table__row {
+    &:last-child {
+      .el-table__cell {
+        border-bottom: none;
+      }
+    }
+  }
+}
+</style>

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

@@ -0,0 +1,23 @@
+import type { InjectionKey } from 'vue'
+
+export type SortOrder = 'asc' | 'desc'
+
+export interface SortField {
+  field: string
+  order: SortOrder
+}
+
+export interface FilterPayload {
+  prop: string
+  value: any
+}
+
+export interface TableContext<T = any> {
+  onQuery: (payload?: FilterPayload) => void
+  onSort: (prop: string, order: SortOrder | null) => void
+  data: Ref<T[]>
+  sortingFields: Ref<SortField[]>
+  loading: Ref<boolean>
+}
+
+export const TableContextKey: InjectionKey<TableContext> = Symbol('zm-table')

+ 0 - 14
src/components/ZmTable/types.ts

@@ -1,14 +0,0 @@
-export interface TableColumn<T = Record<string, any>> {
-  key: string
-  label: string
-  fixed?: 'left' | 'right'
-  align?: 'left' | 'center' | 'right'
-  prop: keyof T | string
-  visible?: boolean
-  sortable?: boolean
-  minWidth?: number
-  searchable?: boolean
-  formatter?: (row: T) => string
-  render?: (scope: { row: T; value: any }) => any
-  children?: TableColumn[]
-}

+ 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>
+  }
+}

+ 1 - 2
src/views/pms/device/index.vue

@@ -302,9 +302,8 @@
 import download from '@/utils/download'
 import { IotDeviceApi, IotDeviceVO } from '@/api/pms/device'
 import { DICT_TYPE, getStrDictOptions } from '@/utils/dict'
-import { dateFormatter } from '@/utils/formatTime'
 import DeptTree from '@/views/system/user/DeptTree.vue'
-import { CACHE_KEY, useCache } from '@/hooks/web/useCache'
+import { useCache } from '@/hooks/web/useCache'
 import { buildSortingField } from '@/utils'
 import { defaultProps, handleTree } from '@/utils/tree'
 import * as ProductClassifyApi from '@/api/pms/productclassify'

+ 82 - 5
src/views/test/index.vue

@@ -1,8 +1,85 @@
-<script lang="ts" setup></script>
+<script lang="ts" setup>
+import { IotDeviceApi } from '@/api/pms/device'
+import { SortField } from '@/components/ZmTable/token'
+import { useTableComponents } from '@/components/ZmTable/useTableComponents'
+import { DICT_TYPE } from '@/utils/dict'
+
+interface List {
+  id: number
+  yfDeviceCode: string // 油服编码
+  deviceCode: string // 历史编码
+  deviceName: string // 设备名称
+  deptName: string // 所在部门
+  deviceStatus: string // 设备状态
+  assetProperty: string // 资产性质
+  assetClassName: string // 资产类别
+  manufacturer: string // 制造商
+  brandName: string // 品牌
+  model: string // 规格型号
+  chargeName: string // 负责人
+  useProject: string // 使用项目
+  assetOwnership: string // 资产归属
+}
+
+const loading = ref(false)
+const list = ref<List[]>([])
+const total = ref(0)
+
+const query = ref<Partial<List> & { pageNo: number; pageSize: number; sortingFields: SortField[] }>(
+  {
+    pageNo: 1,
+    pageSize: 10,
+    sortingFields: []
+  }
+)
+
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await IotDeviceApi.getIotDevicePage(query.value)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+getList()
+
+const { ZmTable, ZmTableColumn } = useTableComponents<List>()
+</script>
+
 <template>
-  <div
-    class="h-[calc(100vh-20px-var(--top-tool-height)-var(--tags-view-height)-var(--app-footer-height))]"
-  >
-    123123123123
+  <div class="bg-white dark:bg-[#1d1e1f] shadow rounded-lg p-4 flex flex-col mt-4">
+    <zm-table
+      :data="list"
+      :loading="loading"
+      :handle-query="getList"
+      v-model:sorting-fields="query.sortingFields"
+    >
+      <zm-table-column
+        zm-filterable
+        v-model:filter-model-value="query.yfDeviceCode"
+        zm-sortable
+        prop="yfDeviceCode"
+        label="油服编码"
+      />
+      <zm-table-column zm-filterable zm-sortable prop="deviceCode" label="历史编码" />
+      <zm-table-column zm-filterable zm-sortable prop="deviceName" label="设备名称" />
+      <zm-table-column zm-filterable zm-sortable prop="deptName" label="所在部门" />
+      <zm-table-column zm-filterable zm-sortable prop="deviceStatus" label="设备状态" />
+      <zm-table-column zm-filterable zm-sortable prop="assetProperty" label="资产性质">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.PMS_ASSET_PROPERTY" :value="scope.row.assetProperty" />
+        </template>
+      </zm-table-column>
+      <zm-table-column zm-filterable zm-sortable prop="assetClassName" label="资产类别" />
+      <zm-table-column zm-filterable zm-sortable prop="manufacturer" label="制造商" />
+      <zm-table-column zm-filterable zm-sortable prop="brandName" label="品牌" />
+      <zm-table-column zm-filterable zm-sortable prop="model" label="规格型号" />
+      <zm-table-column zm-filterable zm-sortable prop="chargeName" label="负责人" />
+      <zm-table-column zm-filterable zm-sortable prop="useProject" label="使用项目" />
+      <zm-table-column zm-filterable zm-sortable prop="assetOwnership" label="资产归属" />
+    </zm-table>
   </div>
 </template>