mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-10-15 21:35:34 +00:00
refactor: 将组件前缀从Art统一替换为Fa
- 更新所有FA前缀的组件文件 - 新增Fa前缀的对应组件 - 更新所有引用路径和组件名 - 修改相关样式和变量名 - 调整部分组件目录结构
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user