320 lines
9.6 KiB
TypeScript
320 lines
9.6 KiB
TypeScript
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,
|
||
}
|
||
}
|