ZmTableColumn.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440
  1. <script lang="ts" setup generic="T">
  2. import { type TableColumnCtx } from 'element-plus'
  3. import { computed, inject, nextTick, ref, useAttrs, useSlots, watch } from 'vue'
  4. import { TableContextKey } from './token'
  5. import ZmTableColumnSettingTree from './ZmTableColumnSettingTree.vue'
  6. import type { ColumnAlign, ColumnSettingItem, SortChangePayload, SortOrder } from './token'
  7. import type { DefaultRow } from 'element-plus/es/components/table/src/table/defaults'
  8. interface Props
  9. extends /* @vue-ignore */ Partial<
  10. Omit<TableColumnCtx<T extends DefaultRow ? T : DefaultRow>, 'prop'>
  11. > {
  12. prop?: (keyof T & string) | (string & {})
  13. action?: boolean
  14. hideInColumnSettings?: boolean
  15. isParent?: boolean
  16. zmSortable?: boolean
  17. zmFilterable?: boolean
  18. sortOrder?: SortOrder | null
  19. defaultSortOrder?: SortOrder | null
  20. zmSortMethod?: (prop: string, order: SortOrder | null) => void
  21. filterActive?: boolean
  22. filterModelValue?: any
  23. realValue?: (...args: any[]) => any
  24. coverFormatter?: boolean
  25. }
  26. const emits = defineEmits<{
  27. 'update:filterModelValue': [value: any]
  28. 'update:sortOrder': [order: SortOrder | null]
  29. 'sort-change': [payload: SortChangePayload]
  30. 'filter-click': [payload: { prop?: string }]
  31. 'filter-visible-change': [visible: boolean]
  32. }>()
  33. const props = defineProps<Props>()
  34. const attrs = useAttrs()
  35. const slots = useSlots()
  36. const tableContext = inject(TableContextKey, {
  37. data: ref([]),
  38. loading: ref(false),
  39. columnAlign: ref<ColumnAlign>('center'),
  40. columnMaxWidth: ref(360),
  41. columnSettings: ref([]),
  42. updateColumnVisible: () => {},
  43. updateColumnFixed: () => {},
  44. updateColumnOrder: () => {},
  45. resetColumnSettings: () => {}
  46. })
  47. const innerSortOrder = ref<SortOrder | null>(props.defaultSortOrder ?? null)
  48. const filterVisible = ref(false)
  49. const settingVisible = ref(false)
  50. const hasHeaderAction = computed(() => props.action || props.zmSortable || props.zmFilterable)
  51. const forwardedSlots = computed(() => {
  52. const { header: _header, ...restSlots } = slots
  53. return restSlots
  54. })
  55. const defaultOptions = ref<Partial<Props>>({
  56. align: 'center',
  57. resizable: true
  58. })
  59. const bindProps = computed(() => {
  60. const {
  61. action,
  62. hideInColumnSettings,
  63. zmSortable,
  64. zmFilterable,
  65. sortOrder,
  66. defaultSortOrder,
  67. zmSortMethod,
  68. filterActive,
  69. filterModelValue,
  70. realValue,
  71. coverFormatter,
  72. isParent,
  73. ...columnProps
  74. } = props
  75. const columnAlign = props.align || (attrs.align as ColumnAlign | undefined)
  76. const resolvedAlign = columnAlign || tableContext.columnAlign.value || defaultOptions.value.align
  77. return {
  78. ...defaultOptions.value,
  79. ...attrs,
  80. ...columnProps,
  81. prop: props.prop,
  82. align: resolvedAlign,
  83. className: [props.className, props.prop].filter(Boolean).join(' '),
  84. formatter: coverFormatter ? realValue : props.formatter
  85. }
  86. })
  87. const alignMap: Record<string, string> = {
  88. center: 'justify-center',
  89. left: 'justify-between',
  90. right: 'justify-end'
  91. }
  92. const headerFlexClass = computed(() => {
  93. if (hasHeaderAction.value) return 'justify-between'
  94. return alignMap[String(bindProps.value.align)] || 'justify-center'
  95. })
  96. const isSortControlled = computed(() => props.sortOrder !== undefined)
  97. const currentOrder = computed<SortOrder | null>(() => {
  98. return isSortControlled.value ? (props.sortOrder ?? null) : innerSortOrder.value
  99. })
  100. const isSortActive = computed(() => currentOrder.value !== null)
  101. const hasFilterValue = (value: any) => {
  102. if (Array.isArray(value)) return value.length > 0
  103. if (typeof value === 'string') return value.length > 0
  104. return value !== undefined && value !== null && value !== ''
  105. }
  106. const isFilterActive = computed(() => props.filterActive ?? hasFilterValue(props.filterModelValue))
  107. const columnSettingsModel = computed<ColumnSettingItem[]>({
  108. get: () => tableContext.columnSettings.value,
  109. set: (items) => {
  110. tableContext.updateColumnOrder(items.map((item) => item.key))
  111. }
  112. })
  113. const handleSortClick = () => {
  114. if (!props.prop) return
  115. let nextOrder: SortOrder | null = 'asc'
  116. if (currentOrder.value === 'asc') {
  117. nextOrder = 'desc'
  118. } else if (currentOrder.value === 'desc') {
  119. nextOrder = null
  120. }
  121. if (!isSortControlled.value) {
  122. innerSortOrder.value = nextOrder
  123. }
  124. emits('update:sortOrder', nextOrder)
  125. emits('sort-change', { prop: props.prop, order: nextOrder })
  126. props.zmSortMethod?.(props.prop, nextOrder)
  127. }
  128. const updateFilterModelValue = (value: any) => {
  129. emits('update:filterModelValue', value)
  130. }
  131. const closeFilterPopover = () => {
  132. filterVisible.value = false
  133. }
  134. const setFilterVisible = (visible: boolean) => {
  135. filterVisible.value = visible
  136. }
  137. const getFilterSlotProps = (scope: any) => ({
  138. ...scope,
  139. prop: props.prop,
  140. filterModelValue: props.filterModelValue,
  141. close: closeFilterPopover,
  142. setVisible: setFilterVisible,
  143. updateFilterModelValue
  144. })
  145. const handleFilterReferenceClick = () => {
  146. emits('filter-click', { prop: props.prop })
  147. }
  148. watch(
  149. () => props.defaultSortOrder,
  150. (order) => {
  151. if (!isSortControlled.value) {
  152. innerSortOrder.value = order ?? null
  153. }
  154. }
  155. )
  156. watch(filterVisible, (visible) => {
  157. emits('filter-visible-change', visible)
  158. })
  159. const getTableComputedStyle = () => {
  160. const tableElement = document.querySelector('.zm-table') as HTMLElement | null
  161. return getComputedStyle(tableElement ?? document.documentElement)
  162. }
  163. const getTableStyleVariable = (name: string, fallback: string) => {
  164. const style = getTableComputedStyle()
  165. return style.getPropertyValue(name).trim() || fallback
  166. }
  167. const getTextWidth = (text: string) => {
  168. const tableStyle = getTableComputedStyle()
  169. const span = document.createElement('span')
  170. span.style.visibility = 'hidden'
  171. span.style.position = 'absolute'
  172. span.style.whiteSpace = 'nowrap'
  173. span.style.fontSize = getTableStyleVariable(
  174. '--zm-table-column-width-measure-font-size',
  175. getTableStyleVariable('--zm-table-font-size', tableStyle.fontSize || '12px')
  176. )
  177. span.style.fontFamily = getTableStyleVariable(
  178. '--zm-table-column-width-measure-font-family',
  179. tableStyle.fontFamily || 'Noto Sans SC'
  180. )
  181. span.innerText = text
  182. document.body.appendChild(span)
  183. const width = span.offsetWidth
  184. document.body.removeChild(span)
  185. return width
  186. }
  187. const calculativeWidth = () => {
  188. if (!props.prop) return
  189. const values = tableContext.data.value
  190. .map((item) => props.realValue?.(item) ?? item[props.prop as keyof typeof item])
  191. .filter(hasFilterValue)
  192. let labelWidth = getTextWidth(bindProps.value.label || '') + 32
  193. if (hasHeaderAction.value) labelWidth += 8
  194. if (props.zmFilterable) labelWidth += 22
  195. if (props.zmSortable) labelWidth += 22
  196. const maxWidth = Math.min(
  197. Math.max(...values.map((value) => getTextWidth(String(value)) + 38), labelWidth),
  198. tableContext.columnMaxWidth.value
  199. )
  200. defaultOptions.value.minWidth = maxWidth
  201. }
  202. watch(
  203. [() => tableContext.loading.value, () => tableContext.columnMaxWidth.value],
  204. () => {
  205. nextTick(() => {
  206. calculativeWidth()
  207. })
  208. },
  209. { immediate: true }
  210. )
  211. </script>
  212. <template>
  213. <el-table-column ref="columnRef" v-bind="bindProps">
  214. <template v-for="(_, name) in forwardedSlots" :key="name" #[name]="slotData">
  215. <slot :name="name" v-bind="slotData || {}"></slot>
  216. </template>
  217. <template #header="scope">
  218. <slot name="header" v-bind="scope">
  219. <div class="header-wrapper" :class="headerFlexClass">
  220. <span class="truncate" :title="scope.column.label">{{ scope.column.label }}</span>
  221. <div v-if="hasHeaderAction" class="action-area">
  222. <el-tooltip
  223. v-if="props.zmSortable"
  224. :content="
  225. currentOrder === 'asc'
  226. ? '点击降序'
  227. : currentOrder === 'desc'
  228. ? '取消排序'
  229. : '点击升序'
  230. "
  231. placement="top"
  232. :show-after="500"
  233. >
  234. <button
  235. type="button"
  236. class="icon-btn"
  237. :class="{ 'is-active': isSortActive }"
  238. @click.stop="handleSortClick"
  239. >
  240. <div v-if="currentOrder === 'asc'" class="sort-icon i-lucide:arrow-up-narrow-wide">
  241. </div>
  242. <div
  243. v-else-if="currentOrder === 'desc'"
  244. class="sort-icon i-lucide:arrow-down-wide-narrow"
  245. >
  246. </div>
  247. <div v-else class="sort-icon i-lucide:arrow-up-down"></div>
  248. </button>
  249. </el-tooltip>
  250. <el-popover
  251. v-if="props.zmFilterable"
  252. v-model:visible="filterVisible"
  253. placement="top"
  254. :popper-options="{ modifiers: [{ name: 'offset', options: { offset: [16, 18] } }] }"
  255. trigger="click"
  256. :width="260"
  257. :show-arrow="false"
  258. >
  259. <template #reference>
  260. <button
  261. type="button"
  262. class="icon-btn"
  263. :class="{ 'is-active': isFilterActive }"
  264. @click.stop="handleFilterReferenceClick"
  265. >
  266. <div class="filter-icon i-lucide:list-filter"></div>
  267. </button>
  268. </template>
  269. <slot name="filter" v-bind="getFilterSlotProps(scope)"></slot>
  270. </el-popover>
  271. <el-popover
  272. v-if="props.action"
  273. v-model:visible="settingVisible"
  274. placement="bottom-end"
  275. trigger="click"
  276. :width="360"
  277. :show-arrow="false"
  278. popper-class="zm-table-column-setting-popper"
  279. >
  280. <template #reference>
  281. <button type="button" class="icon-btn" title="列设置" @click.stop>
  282. <div class="setting-icon i-lucide:settings"></div>
  283. </button>
  284. </template>
  285. <div class="column-setting-panel">
  286. <ZmTableColumnSettingTree v-model="columnSettingsModel" />
  287. <div class="column-setting-footer">
  288. <el-button
  289. link
  290. type="primary"
  291. size="small"
  292. @click="tableContext.resetColumnSettings"
  293. >
  294. 重置
  295. </el-button>
  296. </div>
  297. </div>
  298. </el-popover>
  299. </div>
  300. </div>
  301. </slot>
  302. </template>
  303. </el-table-column>
  304. </template>
  305. <style scoped lang="scss">
  306. // 表头整体容器:左侧是标题,右侧是按钮区。
  307. .header-wrapper {
  308. display: flex;
  309. align-items: center;
  310. width: 100%;
  311. height: 100%;
  312. min-width: 0;
  313. gap: 6px;
  314. font-size: var(--zm-table-header-font-size, var(--zm-table-font-size, 12px));
  315. font-weight: var(--zm-table-header-font-weight, 600);
  316. line-height: var(--zm-table-header-line-height, 16px);
  317. color: var(--zm-table-header-text-color, #6b7f99);
  318. user-select: none;
  319. .truncate {
  320. min-width: 0;
  321. }
  322. }
  323. // 表头右侧按钮区。
  324. .action-area {
  325. display: flex;
  326. flex: 0 0 auto;
  327. height: 100%;
  328. margin-left: 4px;
  329. align-items: center;
  330. gap: 3px;
  331. }
  332. // 表头小图标按钮的通用样式。
  333. .icon-btn {
  334. display: flex;
  335. width: var(--zm-table-header-icon-btn-size, 18px);
  336. height: var(--zm-table-header-icon-btn-size, 18px);
  337. padding: 0;
  338. color: var(--zm-table-header-icon-btn-color, #8aa0b8);
  339. cursor: pointer;
  340. background: transparent;
  341. border: 0;
  342. border-radius: var(--zm-table-header-icon-btn-radius, 4px);
  343. transition:
  344. color 0.16s ease,
  345. background-color 0.16s ease;
  346. align-items: center;
  347. justify-content: center;
  348. &:hover {
  349. color: var(--zm-table-header-icon-btn-hover-color, var(--el-color-primary));
  350. background-color: var(--zm-table-header-icon-btn-hover-bg, var(--el-color-primary-light-9));
  351. }
  352. &.is-active {
  353. color: var(--zm-table-header-icon-btn-active-color, var(--el-color-primary));
  354. background-color: var(--zm-table-header-icon-btn-active-bg, var(--el-color-primary-light-9));
  355. }
  356. }
  357. // 排序、筛选、设置这几个图标统一大小。
  358. .sort-icon,
  359. .filter-icon,
  360. .setting-icon {
  361. width: var(--zm-table-header-icon-size, 16px);
  362. height: var(--zm-table-header-icon-size, 16px);
  363. }
  364. // 列设置面板容器。
  365. .column-setting-panel {
  366. min-width: 0;
  367. }
  368. // 面板底部区域,放重置按钮。
  369. .column-setting-footer {
  370. display: flex;
  371. justify-content: flex-end;
  372. padding-top: 8px;
  373. margin-top: 8px;
  374. border-top: 1px solid var(--zm-table-column-setting-border-color, var(--el-border-color-lighter));
  375. }
  376. :global(.zm-table-column-setting-popper) {
  377. --zm-table-column-setting-font-size: var(--zm-table-font-size, 12px);
  378. --zm-table-column-setting-text-color: var(--el-text-color-regular);
  379. --zm-table-column-setting-border-color: var(--el-border-color);
  380. --zm-table-column-setting-hover-bg: var(--el-fill-color-lighter);
  381. --zm-table-column-setting-radius: 6px;
  382. --zm-table-column-setting-gap: 4px;
  383. --zm-table-column-setting-max-height: 360px;
  384. --zm-table-column-setting-row-height: 32px;
  385. --zm-table-column-setting-child-row-height: 30px;
  386. --zm-table-column-setting-item-font-weight: 400;
  387. --zm-table-column-setting-group-font-weight: 600;
  388. --zm-table-column-setting-icon-btn-size: 22px;
  389. --zm-table-column-setting-icon-size: 15px;
  390. --zm-table-column-setting-icon-color: #8aa0b8;
  391. --zm-table-column-setting-icon-active-color: var(--el-color-primary);
  392. --zm-table-column-setting-icon-active-bg: var(--el-color-primary-light-9);
  393. --zm-table-column-setting-ghost-bg: var(--el-color-primary-light-9);
  394. --zm-table-column-setting-ghost-opacity: 0.55;
  395. }
  396. </style>