Files
GERP/src/composables/useCrudTable.ts
T

320 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { ref, reactive, onMounted, isRef, unref, type Ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { formApi, type TableName, type QueryParams } from '@/api/form'
import { useProgressStore } from '@/stores/progress'
import type { ColumnSchema, PagedData } from '@/types/api'
/** CRUD 表格 composable 选项 */
export interface UseCrudTableOptions<T> {
/** 表名(对应后端实体) */
table: TableName
/** 查询参数(支持传 Ref:父组件筛选条件变化时 fetchData 读到最新值) */
query?: QueryParams | Ref<QueryParams | undefined>
/** 默认每页条数(默认 20;树形卡片等需全量数据的场景可调大) */
defaultPageSize?: number
/** 主键字段名(不传则从 columns 推导 isPrimary/primaryKey,默认 Id) */
pkField?: string
/** 是否显示复选列(行选择) */
selectable?: boolean
/** 新增前钩子:返回 false 取消 */
beforeCreate?: (row: Partial<T>) => boolean | Promise<boolean>
/** 编辑前钩子:返回 false 取消 */
beforeEdit?: (row: T) => boolean | Promise<boolean>
/** 删除前确认 */
beforeDelete?: (row: T) => boolean | Promise<boolean>
/** 批量删除前确认 */
beforeBatchDelete?: (rows: T[]) => boolean | Promise<boolean>
/** 行点击回调 */
onRowClick?: (row: T) => void
/** 查看按钮回调(详情页跳转) */
onView?: (row: T) => void
}
/**
* CRUD 表格通用逻辑
*
* 对齐原生 TableEngine 的公共能力:
* - 列表/分页/排序/搜索(LoadData/GoToPage/HandleSort)
* - 新增/编辑弹窗(ShowAddDialog/ShowEditDialog/HandleFormSubmit)
* - 删除/批量删除(HandleDelete/HandleBatchDelete)
* - 行选择(GetSelectedRows/ClearSelection/ToggleSelectAll)
* - 外部数据注入(SetData/SetLoading)
* - 行点击/查看(onRowClick/onView)
*/
export function useCrudTable<T extends Record<string, unknown> = Record<string, unknown>>(
options: UseCrudTableOptions<T>,
) {
const { table } = options
// 查询参数:支持 Ref(父组件筛选联动)或普通对象;fetchData 时统一取最新值
const query = isRef(options.query) ? options.query : ref(options.query ?? {})
// === 状态 ===
const loading = ref(false)
const submitting = ref(false)
const columns = ref<ColumnSchema[]>([]) as Ref<ColumnSchema[]>
const dataList = ref<T[]>([]) as Ref<T[]>
const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(options.defaultPageSize ?? 20)
const sortField = ref('')
const sortOrder = ref<'asc' | 'desc'>('asc')
const searchKeyword = ref('')
// === 行选择 ===
const selectedRows = ref<T[]>([]) as Ref<T[]>
// === 弹窗 ===
const dialogVisible = ref(false)
const dialogTitle = ref('新增')
// 使用 Record<string, any> 兼容 v-model 在模板上的类型推断
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const editingRow = reactive<Record<string, any>>({})
const isEdit = ref(false)
// === 主键字段推导 ===
function getPkField(): string {
if (options.pkField) return options.pkField
const pkCol = columns.value.find((c) => c.isPrimary || c.primaryKey)
return pkCol?.field || 'Id'
}
// === 加载列定义 ===
async function fetchColumns(): Promise<void> {
try {
columns.value = await formApi.getColumns(table)
} catch {
// 错误已在拦截器统一处理
}
}
// === 加载数据 ===
async function fetchData(): Promise<void> {
loading.value = true
try {
const params: QueryParams = {
page: currentPage.value,
pageSize: pageSize.value,
sortField: sortField.value || undefined,
sortOrder: sortOrder.value,
keyword: searchKeyword.value || undefined,
...(unref(query) ?? {}),
}
const result = await formApi.list<T>(table, params)
// 兼容两种返回结构:PagedData 或裸数组
if (Array.isArray(result)) {
dataList.value = result
total.value = result.length
} else {
const paged = result as unknown as PagedData<T>
dataList.value = paged.list || []
total.value = paged.total ?? 0
}
} finally {
loading.value = false
}
}
// === 新增 ===
function handleCreate(): void {
isEdit.value = false
dialogTitle.value = '新增'
// 用列定义初始化默认值
const row: Record<string, unknown> = {}
for (const col of columns.value) {
if (col.defaultValue !== undefined) row[col.field] = col.defaultValue
}
Object.keys(editingRow).forEach((k) => delete editingRow[k])
Object.assign(editingRow, row)
dialogVisible.value = true
}
// === 编辑 ===
async function handleEdit(row: T): Promise<void> {
if (options.beforeEdit && !(await options.beforeEdit(row))) return
isEdit.value = true
dialogTitle.value = '编辑'
Object.keys(editingRow).forEach((k) => delete editingRow[k])
Object.assign(editingRow, row)
dialogVisible.value = true
}
// === 查看(跳详情) ===
function handleView(row: T): void {
if (options.onView) {
options.onView(row)
}
}
// === 行点击 ===
function handleRowClick(row: T): void {
if (options.onRowClick) options.onRowClick(row)
}
// === 提交(新增/编辑统一入口) ===
async function handleSubmit(): Promise<void> {
if (options.beforeCreate && !isEdit.value) {
if (!(await options.beforeCreate(editingRow as Partial<T>))) return
}
submitting.value = true
try {
if (isEdit.value) {
const pk = getPkField()
const id = editingRow[pk]
await formApi.update<T>(table, id as string | number, editingRow as Partial<T>)
ElMessage.success('更新成功')
} else {
await formApi.create<T>(table, editingRow as Partial<T>)
ElMessage.success('新增成功')
}
dialogVisible.value = false
await fetchData()
} finally {
submitting.value = false
}
}
// === 删除 ===
async function handleDelete(row: T): Promise<void> {
if (options.beforeDelete && !(await options.beforeDelete(row))) return
try {
await ElMessageBox.confirm('确认删除该记录?删除后不可恢复', '提示', {
type: 'warning',
confirmButtonText: '确认删除',
cancelButtonText: '取消',
})
} catch {
return // 用户取消
}
const pk = getPkField()
const id = (row as Record<string, unknown>)[pk]
await formApi.remove(table, id as string | number)
ElMessage.success('删除成功')
await fetchData()
}
// === 批量删除 ===
async function handleBatchDelete(): Promise<void> {
if (selectedRows.value.length === 0) {
ElMessage.warning('请先选择要删除的记录')
return
}
if (options.beforeBatchDelete && !(await options.beforeBatchDelete(selectedRows.value))) return
try {
await ElMessageBox.confirm(
`确认删除选中的 ${selectedRows.value.length} 条记录?删除后不可恢复`,
'提示',
{ type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消' },
)
} catch {
return
}
const pk = getPkField()
// 串行删除(后端无批量接口时);失败中断
for (const row of selectedRows.value) {
const id = (row as Record<string, unknown>)[pk]
await formApi.remove(table, id as string | number)
}
ElMessage.success('批量删除成功')
selectedRows.value = []
await fetchData()
}
// === 行选择 ===
function handleSelectionChange(rows: T[]): void {
selectedRows.value = rows
}
/** 获取选中行(对齐原生 GetSelectedRows) */
function getSelectedRows(): T[] {
return selectedRows.value
}
/** 清空选择(对齐原生 ClearSelection) */
function clearSelection(): void {
selectedRows.value = []
}
// === 搜索 ===
function handleSearch(): void {
currentPage.value = 1
fetchData()
}
// === 重置搜索 ===
function handleReset(): void {
searchKeyword.value = ''
currentPage.value = 1
fetchData()
}
// === 分页变化 ===
function handlePageChange(page: number): void {
currentPage.value = page
fetchData()
}
function handleSizeChange(size: number): void {
pageSize.value = size
currentPage.value = 1
fetchData()
}
// === 外部数据注入(对齐原生 SetData) ===
function setData(data: T[], totalCount?: number): void {
dataList.value = data
total.value = totalCount ?? data.length
}
// === 初始化 ===
// v-show 多实例方案:组件首次挂载时加载数据并隐藏路由 loading
// 之后 tab 切换仅 v-show(display),不重新挂载、不重新请求、不触发本钩子
onMounted(async () => {
// 并行加载列定义与数据,减少页面首屏等待(串行需两次网络往返)
try {
await Promise.all([fetchColumns(), fetchData()])
} finally {
// 数据加载完成(或失败),隐藏路由切换 loading
useProgressStore().done()
}
})
return {
// 状态
loading,
submitting,
columns,
dataList,
total,
currentPage,
pageSize,
sortField,
sortOrder,
searchKeyword,
selectedRows,
dialogVisible,
dialogTitle,
editingRow,
isEdit,
// 主键
getPkField,
// 动作
fetchColumns,
fetchData,
handleCreate,
handleEdit,
handleView,
handleRowClick,
handleSubmit,
handleDelete,
handleBatchDelete,
handleSelectionChange,
getSelectedRows,
clearSelection,
handleSearch,
handleReset,
handlePageChange,
handleSizeChange,
setData,
}
}