refactor: 将组件前缀从Art统一替换为Fa

- 更新所有FA前缀的组件文件
- 新增Fa前缀的对应组件
- 更新所有引用路径和组件名
- 修改相关样式和变量名
- 调整部分组件目录结构
This commit is contained in:
zhangtao
2026-05-11 09:23:31 +08:00
parent 68b50359c5
commit 03a8151513
185 changed files with 2912 additions and 1283 deletions
@@ -0,0 +1,134 @@
<!-- 列表页左侧工具栏:1) configButtons 与 CrudContent 配置驱动一致 2) perm 预设「新增/导入/导出/批删/更多」 3) 默认插槽可整块替换 -->
<template>
<div class="data-table__toolbar--left inline-flex flex-wrap items-center gap-2">
<template v-if="configButtons && configButtons.length">
<template v-for="(btn, index) in configButtons" :key="index">
<ElButton
v-hasPerm="btn.perm ?? '*:*:*'"
v-bind="btn.attrs"
:disabled="btn.name === 'delete' && removeIds.length === 0"
@click="$emit('toolbar', btn.name)"
>
{{ btn.text }}
</ElButton>
</template>
</template>
<slot v-else>
<slot>
<ElSpace>
<ElButton
v-if="permCreate"
v-hasPerm="permCreate"
type="success"
:icon="Plus"
@click="$emit('add')"
plain
>
新增
</ElButton>
<ElButton
v-if="permImport"
v-hasPerm="permImport"
v-ripple
type="warning"
:loading="importLoading"
:icon="Upload"
@click="$emit('import')"
plain
>
导入
</ElButton>
<ElButton
v-if="permExport"
v-hasPerm="permExport"
v-ripple
type="primary"
:loading="exportLoading"
:icon="Download"
@click="$emit('export')"
plain
>
导出
</ElButton>
<ElButton
v-if="permDelete"
v-hasPerm="permDelete"
type="danger"
:loading="deleteLoading"
:disabled="removeIds.length === 0"
:icon="Delete"
@click="$emit('delete')"
plain
>
批量删除
</ElButton>
<ElDropdown v-if="permPatch" v-hasPerm="permPatch" trigger="click">
<ElButton type="default" :disabled="removeIds.length === 0 || moreDisabled">
<template #icon>
<ArrowDown />
</template>
更多
</ElButton>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem icon="Check" @click="$emit('more', '0')">批量启用</ElDropdownItem>
<ElDropdownItem icon="CircleClose" @click="$emit('more', '1')">
批量停用
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</ElSpace>
</slot>
</slot>
</div>
</template>
<script setup lang="ts">
import { ArrowDown, Delete, Download, Plus, Upload } from "@element-plus/icons-vue";
import { computed } from "vue";
import type { FaTableHeaderLeftConfigButton } from "@/components/modal/types";
const props = withDefaults(
defineProps<{
/** 与 CrudContent `toolbarLeftBtn` 一致时走配置驱动(与 handleToolbar 对齐) */
configButtons?: FaTableHeaderLeftConfigButton[];
/** 勾选行主键,用于禁用批删 / 更多(插槽完全自定义时可不传) */
removeIds?: Array<string | number>;
/** 新增按钮权限,不传则不显示(configButtons 未传时) */
permCreate?: string | string[];
/** 导入按钮权限,不传则不显示;顺序在新增之后、批量删除之前 */
permImport?: string | string[];
/** 导出按钮权限,不传则不显示 */
permExport?: string | string[];
/** 导入按钮 loading */
importLoading?: boolean;
/** 导出按钮 loading */
exportLoading?: boolean;
/** 批量删除权限,不传则不显示 */
permDelete?: string | string[];
/** 「更多」下拉权限,不传则不显示 */
permPatch?: string | string[];
/** 批量删除中(按钮 loading,并禁用「更多」) */
deleteLoading?: boolean;
}>(),
{
removeIds: () => [],
deleteLoading: false,
importLoading: false,
exportLoading: false,
}
);
defineEmits<{
/** 配置模式:与 CrudContent handleToolbar 一致 */
toolbar: [name: string];
add: [];
import: [];
export: [];
delete: [];
more: [value: string];
}>();
const moreDisabled = computed(() => props.removeIds.length === 0 || props.deleteLoading);
</script>
@@ -0,0 +1,365 @@
<!-- 表格头部,包含表格大小、刷新、全屏、列设置、其他设置 -->
<template>
<div class="flex-cb max-md:!block" id="fa-table-header">
<div class="flex-wrap">
<slot name="left"></slot>
</div>
<div class="flex-c md:justify-end max-md:mt-3 max-sm:!hidden">
<!-- 搜索区域显示/隐藏:默认展示搜索(未高亮);点按收起后高亮表示当前为隐藏状态 -->
<ElTooltip
v-if="showSearchBar != null"
placement="bottom"
:content="showSearchBar ? t('table.toolbar.hideSearch') : t('table.toolbar.showSearch')"
>
<div
class="button"
@click="search"
:class="!showSearchBar ? 'active !bg-theme hover:!bg-theme/80' : ''"
>
<FaSvgIcon icon="ri:search-line" :class="!showSearchBar ? 'text-white' : 'text-g-700'" />
</div>
</ElTooltip>
<!-- 刷新 -->
<div
v-if="shouldShow('refresh')"
class="button"
@click="refresh"
:class="{ loading: loading && isManualRefresh }"
>
<FaSvgIcon
icon="ri:refresh-line"
:class="loading && isManualRefresh ? 'animate-spin text-g-600' : ''"
/>
</div>
<!-- 表格大小 -->
<ElDropdown v-if="shouldShow('size')" @command="handleTableSizeChange">
<div class="button">
<FaSvgIcon icon="ri:arrow-up-down-fill" />
</div>
<template #dropdown>
<ElDropdownMenu>
<div
v-for="item in tableSizeOptions"
:key="item.value"
class="table-size-btn-item [&_.el-dropdown-menu__item]:!mb-[3px] last:[&_.el-dropdown-menu__item]:!mb-0"
>
<ElDropdownItem
:key="item.value"
:command="item.value"
:class="tableSize === item.value ? '!bg-g-300/55' : ''"
>
{{ item.label }}
</ElDropdownItem>
</div>
</ElDropdownMenu>
</template>
</ElDropdown>
<!-- 全屏 -->
<div v-if="shouldShow('fullscreen')" class="button" @click="toggleFullScreen">
<FaSvgIcon :icon="isFullScreen ? 'ri:fullscreen-exit-line' : 'ri:fullscreen-line'" />
</div>
<!-- 行拖拽排序 -->
<ElTooltip
v-if="shouldShow('rowDrag')"
placement="bottom"
:content="isRowDrag ? t('table.toolbar.disableRowDrag') : t('table.toolbar.enableRowDrag')"
>
<div
class="button"
@click="toggleRowDrag"
:class="isRowDrag ? 'active !bg-theme hover:!bg-theme/80' : ''"
>
<FaSvgIcon icon="ri:drag-move-line" :class="isRowDrag ? 'text-white' : 'text-g-700'" />
</div>
</ElTooltip>
<!-- 列设置 -->
<ElPopover v-if="shouldShow('columns')" placement="bottom" trigger="click">
<template #reference>
<div class="button">
<FaSvgIcon icon="ri:align-right" />
</div>
</template>
<div>
<ElScrollbar max-height="380px">
<VueDraggable
v-model="columns"
:disabled="false"
filter=".fixed-column"
:prevent-on-filter="false"
@move="checkColumnMove"
>
<div
v-for="item in columns"
:key="item.prop || item.type"
class="column-option flex-c"
:class="{ 'fixed-column': item.fixed }"
>
<div
class="drag-icon mr-2 h-4.5 flex-cc text-g-500"
:class="item.fixed ? 'cursor-default text-g-300' : 'cursor-move'"
>
<FaSvgIcon
:icon="item.fixed ? 'ri:unpin-line' : 'ri:drag-move-2-fill'"
class="text-base"
/>
</div>
<ElCheckbox
:model-value="getColumnVisibility(item)"
@update:model-value="(val) => updateColumnVisibility(item, val)"
:disabled="item.disabled"
class="flex-1 min-w-0 [&_.el-checkbox__label]:overflow-hidden [&_.el-checkbox__label]:text-ellipsis [&_.el-checkbox__label]:whitespace-nowrap"
>
{{ item.label || (item.type === "selection" ? t("table.selection") : "") }}
</ElCheckbox>
</div>
</VueDraggable>
</ElScrollbar>
</div>
</ElPopover>
<!-- 其他设置 -->
<ElPopover v-if="shouldShow('settings')" placement="bottom" trigger="click">
<template #reference>
<div class="button">
<FaSvgIcon icon="ri:settings-line" />
</div>
</template>
<div class="flex min-w-[200px] flex-col gap-2">
<ElCheckbox v-model="isZebra" :value="true">
{{ t("table.zebra") }}
</ElCheckbox>
<ElCheckbox v-model="isBorder" :value="true">
{{ t("table.border") }}
</ElCheckbox>
<ElCheckbox v-model="isHeaderBackground" :value="true">
{{ t("table.headerBackground") }}
</ElCheckbox>
</div>
</ElPopover>
<slot name="right"></slot>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, onMounted, onUnmounted } from "vue";
import { storeToRefs } from "pinia";
import { TableSizeEnum } from "@/enums/formEnum";
import { useTableStore } from "@stores/modules/table.store";
import { VueDraggable } from "vue-draggable-plus";
import { useI18n } from "vue-i18n";
import type { ColumnOption } from "@/types/component";
import { ElScrollbar } from "element-plus";
defineOptions({ name: "FaTableHeader" });
// 显式声明插槽类型
defineSlots<{
default?: () => void;
left?: () => void;
right?: () => void;
}>();
const { t } = useI18n();
interface Props {
/** 全屏 class */
fullClass?: string;
/** 组件布局,子组件名用逗号分隔 */
layout?: string;
/** 加载中 */
loading?: boolean;
/** 搜索栏显示状态 */
showSearchBar?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
fullClass: "fa-page-view",
layout: "search,refresh,size,fullscreen,columns,rowDrag,settings",
showSearchBar: undefined,
});
const columns = defineModel<ColumnOption[]>("columns", {
required: false,
default: () => [],
});
const emit = defineEmits<{
(e: "refresh"): void;
(e: "search"): void;
(e: "update:showSearchBar", value: boolean): void;
}>();
/**
* 获取列的显示状态
* 优先使用 visible 字段,如果不存在则使用 checked 字段
*/
const getColumnVisibility = (col: ColumnOption): boolean => {
if (col.visible !== undefined) {
return col.visible;
}
return col.checked ?? true;
};
/**
* 更新列的显示状态
* 同时更新 checked 和 visible 字段以保持兼容性
*/
const updateColumnVisibility = (col: ColumnOption, value: boolean | string | number): void => {
const boolValue = !!value;
col.checked = boolValue;
col.visible = boolValue;
};
/** 表格大小选项配置 */
const tableSizeOptions = [
{ value: TableSizeEnum.SMALL, label: t("table.sizeOptions.small") },
{ value: TableSizeEnum.DEFAULT, label: t("table.sizeOptions.default") },
{ value: TableSizeEnum.LARGE, label: t("table.sizeOptions.large") },
];
const tableStore = useTableStore();
const { tableSize, isZebra, isBorder, isHeaderBackground, isRowDrag } = storeToRefs(tableStore);
const toggleRowDrag = () => {
tableStore.setIsRowDrag(!isRowDrag.value);
};
/** 解析 layout 属性,转换为数组 */
const layoutItems = computed(() => {
return props.layout.split(",").map((item) => item.trim());
});
/**
* 检查组件是否应该显示
* @param componentName 组件名称
* @returns 是否显示
*/
const shouldShow = (componentName: string) => {
return layoutItems.value.includes(componentName);
};
/**
* 拖拽移动事件处理 - 防止固定列位置改变
* @param evt move事件对象
* @returns 是否允许移动
*/
const checkColumnMove = (event: any) => {
// 拖拽进入的目标 DOM 元素
const toElement = event.related as HTMLElement;
// 如果目标位置是 fixed 列,则不允许移动
if (toElement && toElement.classList.contains("fixed-column")) {
return false;
}
return true;
};
/** 搜索事件处理 */
const search = () => {
// 切换搜索栏显示状态
emit("update:showSearchBar", !props.showSearchBar);
emit("search");
};
/** 刷新事件处理 */
const refresh = () => {
isManualRefresh.value = true;
emit("refresh");
};
/**
* 表格大小变化处理
* @param command 表格大小枚举值
*/
const handleTableSizeChange = (command: TableSizeEnum) => {
useTableStore().setTableSize(command);
};
/** 是否手动点击刷新 */
const isManualRefresh = ref(false);
/** 加载中 */
const isFullScreen = ref(false);
/** 保存原始的 overflow 样式,用于退出全屏时恢复 */
const originalOverflow = ref("");
/**
* 切换全屏状态
* 进入全屏时会隐藏页面滚动条,退出时恢复原状态
*/
const toggleFullScreen = () => {
const el = document.querySelector(`.${props.fullClass}`);
if (!el) return;
isFullScreen.value = !isFullScreen.value;
if (isFullScreen.value) {
// 进入全屏:保存原始样式并隐藏滚动条
originalOverflow.value = document.body.style.overflow;
document.body.style.overflow = "hidden";
el.classList.add("el-full-screen");
tableStore.setIsFullScreen(true);
} else {
// 退出全屏:恢复原始样式
document.body.style.overflow = originalOverflow.value;
el.classList.remove("el-full-screen");
tableStore.setIsFullScreen(false);
}
};
/**
* ESC键退出全屏的事件处理器
* 需要保存引用以便在组件卸载时正确移除监听器
*/
const handleEscapeKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && isFullScreen.value) {
toggleFullScreen();
}
};
/** 组件挂载时注册全局事件监听器 */
onMounted(() => {
document.addEventListener("keydown", handleEscapeKey);
});
/** 组件卸载时清理资源 */
onUnmounted(() => {
// 移除事件监听器
document.removeEventListener("keydown", handleEscapeKey);
// 如果组件在全屏状态下被卸载,恢复页面滚动状态
if (isFullScreen.value) {
document.body.style.overflow = originalOverflow.value;
const el = document.querySelector(`.${props.fullClass}`);
if (el) {
el.classList.remove("el-full-screen");
}
}
});
</script>
<style scoped>
@reference '@styles/core/tailwind.css';
.button {
@apply ml-2
size-8
flex
items-center
justify-center
cursor-pointer
rounded-md
bg-g-300/55
dark:bg-g-300/40
text-g-700
hover:bg-g-300
md:ml-0
md:mr-2.5;
}
</style>
@@ -0,0 +1,488 @@
<!--
FaTable:ElTable 透传 + 分页 + 列渲染约定。
- 属性 / 事件 / 插槽与 EP 文档一致;样式可由 tableStore 与 props 覆盖。
- 暴露 `elTableRef`、`scrollToTop`;formatter 列见 TableFormatterOutlet 注释。
-->
<template>
<div class="fa-table">
<div class="fa-table__main">
<VueDraggable
class="fa-table__drag-wrap"
target="tbody"
v-model="dragModel"
:animation="150"
:disabled="rowDragDisabled"
@end="onRowDragEnd"
>
<ElTable ref="elTableRef" v-loading="!!loading" v-bind="mergedTableProps">
<template v-for="col in columns" :key="col.prop || col.type">
<!-- 渲染全局序号列 -->
<ElTableColumn v-if="col.type === 'globalIndex'" v-bind="{ ...col }">
<template #default="{ $index }">
<span>{{ getGlobalIndex($index) }}</span>
</template>
</ElTableColumn>
<!-- 渲染展开行 -->
<ElTableColumn v-else-if="col.type === 'expand'" v-bind="cleanColumnProps(col)">
<template #default="{ row: expandRow }">
<component :is="col.formatter ? col.formatter(expandRow) : null" />
</template>
</ElTableColumn>
<!-- 渲染普通列:default 插槽须紧凑书写,避免空白文本抢占 formatter(见 renderColumnFormatter 注释) -->
<ElTableColumn v-else v-bind="cleanBodyColumnProps(col)">
<template v-if="col.useHeaderSlot && col.prop" #header="headerScope">
<slot
:name="col.headerSlotName || `${col.prop}-header`"
v-bind="{ ...headerScope, prop: col.prop, label: col.label }"
>
{{ col.label }}
</slot>
</template>
<!-- 整段单行:插槽内兄弟节点之间的空白文本会让 EP 误判单元格内容 -->
<!-- eslint-disable-next-line vue/max-attributes-per-line, prettier/prettier -->
<template #default="slotScope">
<slot
v-if="col.useSlot && col.prop && shouldRenderSlotScope(slotScope)"
:name="col.slotName || col.prop"
v-bind="{
...slotScope,
prop: col.prop,
value: col.prop ? slotScope.row[col.prop] : undefined,
}"
/>
<TableFormatterOutlet
v-else-if="col.formatter && !col.useSlot && shouldRenderSlotScope(slotScope)"
:column="col"
:record="slotScope.row"
/>
</template>
</ElTableColumn>
</template>
<template v-if="$slots.default" #default><slot /></template>
<template #empty>
<div v-if="loading"></div>
<ElEmpty v-else :description="emptyText" :image-size="120" />
</template>
</ElTable>
</VueDraggable>
<div
class="pagination custom-pagination"
v-if="showPagination"
:class="mergedPaginationOptions?.align"
ref="paginationRef"
>
<FaPagination
v-if="pagination"
:page="pagination.current"
:limit="pagination.size"
:total="pagination.total"
:page-sizes="mergedPaginationOptions.pageSizes"
:layout="mergedPaginationOptions.layout"
:background="mergedPaginationOptions.background ?? true"
:disabled="!!loading"
:hidden="paginationHidden"
:pager-count="mergedPaginationOptions.pagerCount"
:size="mergedPaginationOptions.size"
@pagination="handlePaginationEvent"
/>
</div>
</div>
</template>
<script setup lang="ts">
import {
ref,
computed,
nextTick,
watchEffect,
getCurrentInstance,
useAttrs,
isVNode,
h,
defineComponent,
type PropType,
} from "vue";
import type { ElTable, TableProps } from "element-plus";
import { storeToRefs } from "pinia";
import { ColumnOption } from "@/types";
import { useTableStore } from "@stores/modules/table.store";
import { useCommon } from "@/hooks/core/useCommon";
import { useWindowSize } from "@vueuse/core";
import FaPagination from "@/components/others/fa-pagination/index.vue";
import { VueDraggable } from "vue-draggable-plus";
/** 空状态插画高度(列表页统一常量) */
const ART_TABLE_EMPTY_IMAGE_SIZE = 120;
defineOptions({ name: "FaTable" });
const { width } = useWindowSize();
const elTableRef = ref<InstanceType<typeof ElTable> | null>(null);
const paginationRef = ref<HTMLElement>();
const tableHeaderRef = ref<HTMLElement>();
const tableStore = useTableStore();
const { isBorder, isZebra, tableSize, isFullScreen, isHeaderBackground, isRowDrag } =
storeToRefs(tableStore);
/** 分页配置接口 */
interface PaginationConfig {
/** 当前页码 */
current: number;
/** 每页显示条目个数 */
size: number;
/** 总条目数 */
total: number;
}
/** 分页器配置选项接口 */
interface PaginationOptions {
/** 每页显示个数选择器的选项列表 */
pageSizes?: number[];
/** 分页器的对齐方式 */
align?: "left" | "center" | "right";
/** 分页器的布局 */
layout?: string;
/** 是否显示分页器背景 */
background?: boolean;
/** 只有一页时是否隐藏分页器 */
hideOnSinglePage?: boolean;
/** 分页器的大小 */
size?: "small" | "default" | "large";
/** 分页器的页码数量 */
pagerCount?: number;
}
/** FaTable 组件的 Props 接口 */
interface FaTableProps extends TableProps<Record<string, any>> {
/** 加载状态 */
loading?: boolean;
/** 列渲染配置 */
columns?: ColumnOption[];
/** 分页状态 */
pagination?: PaginationConfig;
/** 分页配置 */
paginationOptions?: PaginationOptions;
/** 空数据表格高度 */
emptyHeight?: string;
/** 空数据时显示的文本 */
emptyText?: string;
/** 是否开启 FaTableHeader,解决表格高度自适应问题 */
showTableHeader?: boolean;
/** 为 true 时关闭行拖拽(忽略工具栏「行拖拽」开关) */
disableRowDrag?: boolean;
}
const props = withDefaults(defineProps<FaTableProps>(), {
columns: () => [],
fit: true,
showHeader: true,
stripe: undefined,
border: undefined,
size: undefined,
emptyHeight: "100%",
emptyText: "暂无数据",
showTableHeader: true,
disableRowDrag: false,
});
const instance = getCurrentInstance();
const attrs = useAttrs();
/** 仅当调用方显式传入对应 prop 时视为「固定」,否则交由表格 store */
const hasExplicitTableProp = (propName: string): boolean => {
const rawProps = (instance?.vnode.props || {}) as Record<string, unknown>;
const kebabName = propName.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`);
return propName in rawProps || kebabName in rawProps;
};
const LAYOUT = {
MOBILE: "prev, pager, next, sizes, jumper, total",
IPAD: "prev, pager, next, jumper, total",
DESKTOP: "total, prev, pager, next, sizes, jumper",
};
const layout = computed(() => {
if (width.value < 768) {
return LAYOUT.MOBILE;
} else if (width.value < 1024) {
return LAYOUT.IPAD;
} else {
return LAYOUT.DESKTOP;
}
});
// 默认分页常量
const DEFAULT_PAGINATION_OPTIONS: PaginationOptions = {
pageSizes: [10, 20, 30, 50, 100],
align: "center",
background: true,
layout: layout.value,
hideOnSinglePage: false,
size: "default",
pagerCount: width.value > 1200 ? 7 : 5,
};
// 合并分页配置
const mergedPaginationOptions = computed(() => ({
...DEFAULT_PAGINATION_OPTIONS,
...props.paginationOptions,
}));
/** 对齐 ElPagination hide-on-single-page,交给封装组件的 hidden */
const paginationHidden = computed(() => {
const p = props.pagination;
const opts = mergedPaginationOptions.value;
if (!p || !opts.hideOnSinglePage) return false;
const size = p.size || 10;
const total = p.total ?? 0;
if (total <= 0) return false;
return Math.ceil(total / size) <= 1;
});
// 边框 (优先级:props > store)
const border = computed(() => props.border ?? isBorder.value);
// 斑马纹
const stripe = computed(() => props.stripe ?? isZebra.value);
// 表格尺寸
const size = computed(() => props.size ?? tableSize.value);
// 数据是否为空
const isEmpty = computed(() => props.data?.length === 0);
const paginationHeight = ref(0);
const tableHeaderHeight = ref(0);
// 使用 useResizeObserver 监听分页器高度变化
useResizeObserver(paginationRef, (entries) => {
const entry = entries[0];
if (entry) {
// 使用 requestAnimationFrame 避免 ResizeObserver loop 警告
requestAnimationFrame(() => {
paginationHeight.value = entry.contentRect.height;
});
}
});
// 使用 useResizeObserver 监听表格头部高度变化
useResizeObserver(tableHeaderRef, (entries) => {
const entry = entries[0];
if (entry) {
// 使用 requestAnimationFrame 避免 ResizeObserver loop 警告
requestAnimationFrame(() => {
tableHeaderHeight.value = entry.contentRect.height;
});
}
});
// 分页器与表格之间的间距常量(计算属性,响应 showTableHeader 变化)
const PAGINATION_SPACING = computed(() => (props.showTableHeader ? 6 : 15));
// 使用表格高度计算 Hook
const { containerHeight } = useTableHeight({
showTableHeader: computed(() => props.showTableHeader),
paginationHeight,
tableHeaderHeight,
paginationSpacing: PAGINATION_SPACING,
});
// 表格高度逻辑
const height = computed(() => {
// 全屏模式下占满全屏
if (isFullScreen.value) return "100%";
// 空数据且非加载状态时固定高度
if (isEmpty.value && !props.loading) return props.emptyHeight;
// 使用传入的高度
if (props.height) return props.height;
// 默认占满容器高度
return "100%";
});
// 表头背景颜色样式
const headerCellStyle = computed(() => ({
background: isHeaderBackground.value
? "var(--el-fill-color-lighter)"
: "var(--default-box-color)",
...(props.headerCellStyle || {}), // 合并用户传入的样式
}));
const mergedTableProps = computed(() => ({
...attrs,
...props,
height: height.value,
stripe: stripe.value,
border: border.value,
size: size.value,
headerCellStyle: headerCellStyle.value,
// Element Plus 默认值为 true,未显式传入时不应被 FaTable 覆盖成 false。
selectOnIndeterminate: hasExplicitTableProp("selectOnIndeterminate")
? props.selectOnIndeterminate
: undefined,
}));
const emit = defineEmits<{
(e: "pagination:size-change", val: number): void;
(e: "pagination:current-change", val: number): void;
(e: "update:data", val: Record<string, unknown>[]): void;
(e: "row-order-change", val: Record<string, unknown>[]): void;
}>();
/** 无 data 时用固定空数组,避免 v-model 每次拿到新 [] */
const emptyDataStub = ref<Record<string, unknown>[]>([]);
const dragModel = computed({
get() {
const d = props.data;
if (Array.isArray(d)) return d;
return emptyDataStub.value;
},
set(val) {
emit("update:data", val);
},
});
const rowDragActive = computed(() => !props.disableRowDrag && isRowDrag.value);
const rowDragDisabled = computed(() => !rowDragActive.value || !!props.loading);
const onRowDragEnd = () => {
const d = props.data;
if (Array.isArray(d)) {
emit("row-order-change", d as Record<string, unknown>[]);
}
};
// 是否显示分页器
const showPagination = computed(() => props.pagination && !isEmpty.value);
// Element Plus 在部分场景会先用 $index = -1 进行预渲染。
// 这对普通展示无影响,但会让 ElForm 错误注册出 lineList.-1.xxx 这类字段。
const shouldRenderSlotScope = (slotScope: { $index?: number }) => {
return slotScope.$index === undefined || slotScope.$index >= 0;
};
/**
* ElTableColumn 若存在 default 插槽且插槽产物含任意非 Comment 的 vnode(含空白文本节点),
* 将不会执行 formatter(见 element-plus render-helper setColumnRenders)。
* FaTable 中 ElTableColumn 与子节点之间的换行/缩进可能被编译进默认插槽,导致 formatter(如操作列里的按钮)永远不渲染。
* 对声明了 formatter 且未使用 useSlot 的列,在此显式渲染 formatter 返回值。
*/
const renderColumnFormatter = (col: ColumnOption, row: Record<string, unknown>) => {
if (!col.formatter) return null;
const result = col.formatter(row as never);
if (isVNode(result)) return result;
if (result === null || result === undefined) return null;
return h("span", String(result));
};
/**
* 在 render 里调用 formatter(row) 生成 VNode;勿把 VNode 当 props 传入(克隆后会失效)。
* 与 renderColumnFormatter 同文件定义,保证闭包一致。
*/
const TableFormatterOutlet = defineComponent({
name: "TableFormatterOutlet",
props: {
column: { type: Object as PropType<ColumnOption>, required: true },
/** 避免 prop 名 row 与插槽解构冲突 */
record: { type: Object as PropType<Record<string, unknown>>, required: true },
},
setup(props) {
return () => renderColumnFormatter(props.column, props.record);
},
});
// 清理列属性,移除插槽相关的自定义属性,确保它们不会被 ElTableColumn 错误解释
const cleanColumnProps = (col: ColumnOption) => {
const columnProps = { ...col };
// 删除自定义的插槽控制属性
delete columnProps.useHeaderSlot;
delete columnProps.headerSlotName;
delete columnProps.useSlot;
delete columnProps.slotName;
return columnProps;
};
/** 普通列:单元格已由插槽内 TableFormatterOutlet 渲染,勿再把 formatter 传给 ElTableColumn,避免与 EP 内置 renderCell 混用 */
const cleanBodyColumnProps = (col: ColumnOption) => {
const columnProps = cleanColumnProps(col);
delete columnProps.formatter;
return columnProps;
};
const { scrollToTop: scrollPageToTop } = useCommon();
// 滚动表格内容到顶部,并可以联动页面滚动到顶部
const scrollToTop = () => {
nextTick(() => {
elTableRef.value?.setScrollTop(0); // 滚动 ElTable 内部滚动条到顶部
scrollPageToTop(); // 调用公共 composable 滚动页面到顶部
});
};
/** 对接封装分页 @pagination,保持对外仍为 size-change / current-change 事件 */
const handlePaginationEvent = (payload: { page: number; limit: number }) => {
const p = props.pagination;
if (!p) return;
if (payload.limit !== p.size) {
emit("pagination:size-change", payload.limit);
return;
}
if (payload.page !== p.current) {
emit("pagination:current-change", payload.page);
scrollToTop();
}
};
// 全局序号
const getGlobalIndex = (index: number) => {
if (!props.pagination) return index + 1;
const { current, size } = props.pagination;
return (current - 1) * size + index + 1;
};
// 查找并绑定表格头部元素 - 使用 VueUse 优化
const findTableHeader = () => {
if (!props.showTableHeader) {
tableHeaderRef.value = undefined;
return;
}
const tableHeader = document.getElementById("art-table-header");
if (tableHeader) {
tableHeaderRef.value = tableHeader;
} else {
// 如果找不到表格头部,设置为 undefined,useElementSize 会返回 0
tableHeaderRef.value = undefined;
}
};
watchEffect(
() => {
// 访问响应式数据以建立依赖追踪
void props.data?.length; // 追踪数据变化
const shouldShow = props.showTableHeader;
// 只有在需要显示表格头部时才查找
if (shouldShow) {
nextTick(() => {
findTableHeader();
});
} else {
// 不显示时清空引用
tableHeaderRef.value = undefined;
}
},
{ flush: "post" }
);
defineExpose({
scrollToTop,
elTableRef,
});
</script>
<style lang="scss" scoped>
@use "./style";
</style>
@@ -0,0 +1,99 @@
.art-table {
position: relative;
height: 100%;
.el-table {
height: 100%;
margin-top: 10px;
}
:deep(.el-loading-mask) {
z-index: 100;
background-color: var(--default-box-color) !important;
}
// Loading 过渡动画 - 消失时淡出
.loading-fade-leave-active {
transition: opacity 0.3s ease-out;
}
.loading-fade-leave-to {
opacity: 0;
}
// 空状态垂直居中
&.is-empty {
:deep(.el-scrollbar__wrap) {
display: flex;
}
}
.pagination {
display: flex;
margin-top: 13px;
:deep(.el-select) {
width: 102px !important;
}
// 分页对齐方式
&.left {
justify-content: flex-start;
}
&.center {
justify-content: center;
}
&.right {
justify-content: flex-end;
}
// 自定义分页组件样式
&.custom-pagination {
:deep(.el-pagination) {
.btn-prev,
.btn-next {
background-color: transparent;
border: 1px solid var(--art-gray-300);
transition: border-color 0.15s;
&:hover:not(.is-disabled) {
color: var(--theme-color);
border-color: var(--theme-color);
}
}
li {
box-sizing: border-box;
font-weight: 400 !important;
background-color: transparent;
border: 1px solid var(--art-gray-300);
transition: border-color 0.15s;
&.is-active {
font-weight: 400;
color: #fff;
background-color: var(--theme-color);
border: 1px solid var(--theme-color);
}
&:hover:not(.is-disabled) {
border-color: var(--theme-color);
}
}
}
}
}
}
// 移动端分页
@media (width <= 640px) {
:deep(.el-pagination) {
display: flex;
flex-wrap: wrap;
gap: 15px 0;
align-items: center;
justify-content: center;
}
}