| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440 |
- <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
- 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
- })
- const bindProps = computed(() => {
- const {
- action,
- 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>
|