Files
GERP/src/components/CrudTable.vue
T
TermiNexus 0fdaf4afba fix(gerp): 日期字段统一回传纯日期串,修正 DateField 提交报错
date 类型(后端 DateField)此前按 Date 对象绑定,提交时被序列化成带时区的 ISO 时刻(2019-08-31T16:00:00.000Z),后端只接受 YYYY-MM-DD 而 500;改为 el-date-picker 显式 value-format=YYYY-MM-DD,跨时区也不会算偏一天。datetime 保持原样。列表与档案只读展示对 date 类型原样输出,不再走 UTC→本地时区转换。
2026-10-01 19:57:46 +08:00

1131 lines
42 KiB
Vue
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.
<script setup lang="ts" generic="T extends Record<string, unknown>">
import { ref, computed, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useCrudTable, type UseCrudTableOptions } from '@/composables/useCrudTable'
import { useDraftAutosave } from '@/composables/useDraftAutosave'
import type { ColumnSchema } from '@/types/api'
import { formatUtcTime, nowUtcString } from '@/utils/dateTime'
import { formApi } from '@/api/form'
import CrudFormPanel from './CrudFormPanel.vue'
/** 自动时间戳字段(CreateTime 等):由服务器填写,前端自动预填当前时间且不要求输入 */
function isAutoTimestampField(field: string): boolean {
return /CreateTime$/.test(field) || field === 'AvatarUploadTime'
}
/**
* 通用 CRUD 表格组件
* 基于 vxe-table + Element Plus,配合后端 FormAPI 协议
*
* 用法:<CrudTable table="User" />
* 带行选择与详情跳转:<CrudTable table="User" selectable :on-view="goDetail" />
*/
const props = withDefaults(defineProps<{
/** 表名(对应后端实体) */
table: string
/** 标题 */
title?: string
/** 额外查询参数 */
query?: Record<string, unknown>
/** 是否显示搜索栏 */
searchable?: boolean
/** 是否显示分页 */
pageable?: boolean
/** 是否显示复选列(行选择 + 批量删除) */
selectable?: boolean
/** 主键字段名(不传则从 columns 推导) */
pkField?: string
/** 查看按钮回调(详情页跳转) */
onView?: (row: T) => void
/** 行点击回调 */
onRowClick?: (row: T) => void
/**
* 是否撑满父容器(整页表格场景)。
* 开启时表格 height=100% + 容器 flex 撑满,产生内部滚动;
* 关闭时表格自适应内容高度(详情页嵌套场景,随页面滚动)。
*/
stretch?: boolean
/**
* 表格最大高度(px,可选)。
* 详情页嵌套子表场景传入(如 400),行数超过时表格内部滚动,
* 避免所有行撑开导致页面无限加长。不传则不限制。
*/
maxHeight?: number
/**
* 卡片模式:以卡片网格展示数据(替代 vxe-table 表格)。
* 保留搜索栏/新增/批量删除/刷新/分页/行内编辑/删除/查看。
* 用于公司/租户等字段少、适合卡片阅读的页面。
*/
cardMode?: boolean
/** 卡片主标题字段(默认取首个非 pk 文本列,如 CompanyName/TenantName) */
cardTitleField?: string
/** 卡片展示字段(默认取全部可见列;传了则按此顺序展示,标题字段除外) */
cardFields?: string[]
/**
* 卡片字段值映射(优先级高于列 options,用于后端未配置 options 的状态码等):
* 如 { ReviewStatus: { 0: '待审核', 1: '已通过', 2: '已拒绝' } }
*/
cardValueMaps?: Record<string, Record<string | number, string>>
/**
* 卡片树状依赖展示:指定父级字段名(如 Company 的 ParentCompanyUUID)时,
* 卡片模式自动按该字段组装树,子卡片缩进 + 连接线展示层级关系。
* 开启后建议 pageable=false(树需全量数据)。
*/
cardTreeParentField?: string
/** 卡片标题旁图标字段(如 CompanyIcon:值为图片 URL,有值则显示图片,无则忽略) */
cardIconField?: string
/** 卡片图标点击回调(如公司列表点击图标 → 上传/修改图标)。传了则图标可点击并显示遮罩 */
onCardIconClick?: (row: T) => void
/** 默认每页条数(默认 20;树形卡片等需全量数据的场景可调大,如 1000) */
defaultPageSize?: number
/** 新增前拦截回调:返回 false 阻止打开新增弹窗(如依赖父级筛选条件的页面需先选条件) */
beforeCreate?: () => boolean | void
/**
* 是否允许新增/编辑/删除(默认 true)。
* 传 false 时隐藏新增、批量删除、行内编辑/删除按钮,实现只读视图
* (用于 role_scope 非管理员场景,如 Products/Orders 仅管理员可改)。
*/
editable?: boolean
/**
* 是否允许新增(默认 true)。只隐藏「新增」按钮,保留编辑/删除/批量删除,
* 用于"记录不由本页新增"的场景(如部门详情的部门成员须走入职流程)。
*/
creatable?: boolean
/**
* 新增/编辑表单容器形态(默认 'drawer' 右侧抽屉):
* 传 'dialog' 改回居中弹窗 —— 表格自身嵌在弹窗里时必须传 'dialog'
* (弹窗内再叠抽屉会错位,如应用市场的"应用-职务绑定"弹窗)。
*/
formMode?: 'dialog' | 'drawer'
}>(), {
// editable 必须显式给默认值 true:Vue 会把「未传的 Boolean prop」强制转成 false,
// 不给默认值时 props.editable 恒为 false,canEdit 判定失败 → 所有未传 editable 的
// 表格都退化成只读(新增/批量删除/行内编辑/删除全消失,操作栏只剩「查看」)
editable: true,
// 同上:Boolean prop 不显式给默认值会被强制成 false,所有表格都会丢掉「新增」按钮
creatable: true,
// 默认抽屉:GERP 列表页的新增/编辑统一走右侧抽屉(详情页子表同理)
formMode: 'drawer',
})
// 是否可写:editable 未传视为可写;传 false 时隐藏新增/编辑/删除入口(只读视图)
const canEdit = computed(() => props.editable !== false)
// 表格实际使用的最大高度:显式 maxHeight 优先;
// stretch(整页列表)场景默认取视口高度 55%,保证表格与卡片一体、
// 不产生页面级滚动栏(行多时表格内部滚动)
const tableMaxHeight = computed(() => props.maxHeight ?? (props.stretch ? Math.floor(window.innerHeight * 0.55) : undefined))
// 表格模式高度绑定:
// - stretch(整页列表):用固定 height(= 视口 55%),行多时表格内部滚动,高度恒定
// - 非 stretch(详情页嵌套):用 max-height,行数少则自适应内容高度,多则限制滚动
// 拆成两个 computed 单独绑定(避免模板直接引用 props.stretch 触发泛型组件解析缺陷)
const tableFixedHeight = computed(() => (props.stretch ? tableMaxHeight.value : undefined))
const tableLimitHeight = computed(() => (props.stretch ? undefined : tableMaxHeight.value))
const options: UseCrudTableOptions<T> = {
table: props.table,
// 以 Ref 传入:父组件筛选条件变化(如工单状态筛选)时,fetchData 能读到最新 query
query: computed(() => props.query),
defaultPageSize: props.defaultPageSize,
pkField: props.pkField,
selectable: props.selectable,
onView: props.onView,
onRowClick: props.onRowClick,
}
const {
loading,
submitting,
columns,
dataList,
total,
currentPage,
pageSize,
searchKeyword,
selectedRows,
dialogVisible,
dialogTitle,
editingRow,
fetchData,
handleCreate,
handleEdit,
handleView,
handleRowClick,
handleSubmit,
handleDelete,
handleBatchDelete,
handleSelectionChange,
handleSearch,
handleReset,
handlePageChange,
handleSizeChange,
getPkField,
isEdit,
} = useCrudTable<T>(options)
// vxe-table 实例引用(用于获取选中行)
const xTableRef = ref()
// 父级 query 中匹配列定义的关联字段(如 DeptUUID/UserUUID/WorkgroupUUID):
// 详情页子表场景自动注入新增/编辑表单,保证新记录归属当前详情上下文;
// 注入后隐藏表单项(值由父级上下文决定,不允许用户修改)。
const inheritedQueryFields = computed(() => {
const q = props.query ?? {}
const colFields = new Set(columns.value.map((c) => c.field))
const result: Record<string, unknown> = {}
for (const [k, v] of Object.entries(q)) {
if (v !== undefined && v !== null && v !== '' && colFields.has(k)) {
result[k] = v
}
}
return result
})
// 可见表格列(排除 hidden)
const tableColumns = computed(() =>
columns.value.filter((c: ColumnSchema) => c.visible !== false && c.type !== 'hidden'),
)
// 弹窗表单可见列(排除隐藏/主键隐藏/不可编辑/divider/html/父级注入字段;
// 编辑态额外排除 hideInEditForm 的虚拟级联列;新增态额外排除 hideInCreateForm 的"仅编辑"列)
// 自动时间戳字段(CreateTime 等)强制 required=false:由服务器填写,不要求用户输入
const formColumns = computed(() => {
const pkField = columns.value.find((c) => c.isPrimary || c.primaryKey)?.field || 'Id'
return columns.value
.filter(
(c: ColumnSchema) =>
c.editable !== false &&
!c.hideInForm &&
!(isEdit.value && c.hideInEditForm) &&
!(!isEdit.value && c.hideInCreateForm) &&
c.type !== 'hidden' &&
c.type !== 'divider' &&
c.type !== 'html' &&
c.field !== pkField &&
!(c.field in inheritedQueryFields.value),
)
.map((c: ColumnSchema) => (isAutoTimestampField(c.field) ? { ...c, required: false } : c))
})
// ===== 新增表单自动草稿(独立 composable:useDraftAutosave) =====
// 防止误关闭/误刷新丢失录入内容;按表维度存 localStorage,
// 再次打开"新增"时提示「恢复草稿 / 删除草稿」(空草稿直接丢弃,不打扰);提交成功后清除。
const draft = useDraftAutosave({
table: props.table,
editingRow: editingRow as Record<string, unknown>,
dialogVisible: () => dialogVisible.value,
isEdit: () => isEdit.value,
// 只有"表单里用户可填的列"变化才算草稿内容:
// 隐藏列/父级注入列/自动时间戳(CreateTime)的变化一律忽略,避免生成"看起来是空的"草稿
editableFields: () => formColumns.value.map((c) => c.field),
})
// 新增:先走 useCrudTable 初始化,再注入父级上下文关联字段;
// 自动时间戳字段(CreateTime 等)预填当前 UTC 时间——不要求用户输入(后端仍以服务器时间为准);
// 若存在未提交草稿(且草稿里有用户真正录入的内容),提示恢复或删除;
// 空草稿(可填列没有有效录入)直接丢弃,不弹窗
async function onHandleCreate(): Promise<void> {
// 新增前拦截:父页面可通过 beforeCreate 前置校验(返回 false 阻止打开弹窗)
if (props.beforeCreate && props.beforeCreate() === false) return
handleCreate()
Object.assign(editingRow, inheritedQueryFields.value)
for (const col of columns.value) {
if (isAutoTimestampField(col.field) && (editingRow[col.field] == null || editingRow[col.field] === '')) {
editingRow[col.field] = nowUtcString()
}
}
// 记录表单基线:未录入内容时不落盘草稿
draft.markBaseline()
await draft.restoreDraftIfAny()
}
// 提交:保证父级上下文关联字段始终存在(编辑时用户可能误删);
// 提交成功即清除草稿,失败保留(拦截器已提示)
async function onHandleSubmit(): Promise<void> {
Object.assign(editingRow, inheritedQueryFields.value)
try {
await handleSubmit()
draft.clearDraft()
} catch {
// 失败:保留草稿供再次提交
}
}
// 查询条件变化(如工单管理页的状态筛选):重置到第一页并重新加载。
// 父组件传响应式 query(computed)时触发;无 query 的页面不受影响。
watch(
() => props.query,
() => {
currentPage.value = 1
void fetchData()
},
{ deep: true },
)
// 多选暂存批量新增:EntityPicker 树面板"全部提交"触发,为每个暂存成员创建一行
// items[].type 为暂存区设置的类型(RoleType,默认 0=成员)
async function handlePickerSubmitMulti(
field: string,
items: Array<{ value: string; label: string; secondary?: string; type?: number }>,
): Promise<void> {
if (isEdit.value || !items?.length) return
Object.assign(editingRow, inheritedQueryFields.value)
let created = 0
for (const item of items) {
const row: Record<string, unknown> = { ...editingRow }
row[field] = item.value
row[`${field}_label`] = item.label
if (item.type !== undefined) row.RoleType = item.type
await formApi.create(props.table, row as Partial<T>)
created += 1
}
ElMessage.success(`成功新增 ${created} 条`)
dialogVisible.value = false
await fetchData()
}
// vxe-table checkbox 变化:同步到 selectedRows
function onCheckboxChange(): void {
const records = xTableRef.value?.getCheckboxRecords?.() || []
handleSelectionChange(records as T[])
}
// 实体选择器回显标签:优先 row[field+'_label'],否则前 8 位 + ...
// UserRole 多对多:_label=角色名 + _UserName=用户名,拼接为"角色名(用户名)"
// 非 UUID 值(如 RoleName 覆盖名)原样显示,不做截断
function entityLabel(row: Record<string, unknown>, field: string): string {
const label = row[`${field}_label`] ?? row[`${field}__label`]
const userName = row[`${field}_UserName`] ?? row[`${field}__UserName`]
if (label) {
const base = String(label)
return userName ? `${base}(${String(userName)})` : base
}
const val = row[field]
if (!val) return ''
const s = String(val)
// UUID 格式(8-4-4-4-12)才截断,普通字符串原样显示
return /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(s)
? s.slice(0, 8) + '...'
: s
}
// multiValue 实体选择器:回显 label 按顿号/逗号拆多个标签(身兼数职等场景)
function multiLabels(row: Record<string, unknown>, field: string): string[] {
const label = row[`${field}_label`] ?? row[`${field}__label`]
if (!label) return []
return String(label)
.split(/[、,]/)
.map((s) => s.trim())
.filter(Boolean)
}
// tags 渲染:字符串按分隔符拆分
function renderTags(value: unknown, separator = ','): string[] {
if (!value) return []
return String(value).split(separator).map((s) => s.trim()).filter(Boolean)
}
// tags 表单:逗号分隔字符串 ↔ 多选数组互转
// richtext 预览:去除 HTML 标签保留纯文本,超长截断
function stripHtml(value: unknown): string {
if (!value) return ''
const text = String(value)
.replace(/<[^>]*>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/\s+/g, ' ')
.trim()
return text.length > 120 ? `${text.slice(0, 120)}...` : text
}
// uuid 渲染:前 8 位 + ...
function renderUuid(value: unknown): string {
return value ? String(value).slice(0, 8) + '...' : ''
}
// select 类型渲染:按 options 匹配 label 回显(状态码等只读展示为中文)
function renderSelectLabel(col: ColumnSchema, value: unknown): string {
const opt = col.options?.find((o) => String(o.value) === String(value))
return opt ? opt.label : value == null || value === '' ? '' : String(value)
}
// === 卡片模式 ===
// 卡片主标题字段:显式 cardTitleField > 首个非 pk 文本列 > 首个可见列
const cardTitleField = computed(() => {
if (props.cardTitleField) return props.cardTitleField
const pkField = columns.value.find((c) => c.isPrimary || c.primaryKey)?.field || ''
const visible = tableColumns.value
const textCol = visible.find(
(c) => c.field !== pkField && (c.type === 'text' || c.type === 'string' || !c.type) && c.field !== 'CompanyUUID',
)
return textCol?.field || visible[0]?.field || ''
})
// 卡片展示字段:显式 cardFields > 全部可见列(排除主键、标题字段、hideInForm 的关联字段)
const cardDisplayFields = computed(() => {
if (props.cardFields && props.cardFields.length > 0) return props.cardFields
const pkField = columns.value.find((c) => c.isPrimary || c.primaryKey)?.field || ''
return tableColumns.value
.filter((c) => c.field !== pkField && c.field !== cardTitleField.value && c.visible !== false)
.map((c) => c.field)
})
// 卡片字段值格式化:cardValueMaps > 列 options(select/status)> 原值
function formatCardValue(row: Record<string, unknown>, field: string): string {
const value = row[field]
if (value == null || value === '') return '-'
// 显式映射优先(后端可能未配置 options 的状态码)
const map = props.cardValueMaps?.[field]
if (map && map[String(value)] !== undefined) return map[String(value)]
const col = columns.value.find((c) => c.field === field)
if (!col) return String(value)
if (col.type === 'select' || col.type === 'status') {
const opt = col.options?.find((o) => String(o.value) === String(value))
if (opt) return opt.label
}
// entityPicker:用回显 label(row[field+'_label'])
if (col.type === 'entityPicker') {
const label = entityLabel(row, field)
return label || String(value)
}
// uuid:截断
if (col.type === 'uuid') return renderUuid(value)
return String(value)
}
// 卡片字段中文标题:从列定义取 title
function columnTitle(field: string): string {
return columns.value.find((c) => c.field === field)?.title || field
}
// 卡片字段控件类型:用于卡片正文对 select/status 字段渲染标签
function cardFieldType(field: string): string | undefined {
return columns.value.find((c) => c.field === field)?.type
}
// 卡片复选:同步到 selectedRows(对齐 vxe-table checkbox)
function onCardCheck(row: T, checked: boolean): void {
const rows = checked
? [...selectedRows.value.filter((r) => r !== row), row]
: selectedRows.value.filter((r) => r !== row)
handleSelectionChange(rows)
}
// === 卡片树状依赖 ===
/** 树节点:原数据 + 子节点 */
interface CardTreeNode {
row: T
depth: number
children: CardTreeNode[]
}
// 扁平化的树节点列表(含 depth),供卡片模式按层级缩进渲染
const cardTreeNodes = computed<CardTreeNode[]>(() => {
const parentField = props.cardTreeParentField
if (!parentField) return []
const pkField = getPkField()
const byPk = new Map<string, T>()
for (const r of dataList.value) {
const pk = (r as Record<string, unknown>)[pkField]
if (pk != null) byPk.set(String(pk), r)
}
// 构建子映射:parent → children(当前页数据范围内)
const childrenMap = new Map<string, T[]>()
const roots: T[] = []
for (const r of dataList.value) {
const parent = (r as Record<string, unknown>)[parentField]
if (parent && byPk.has(String(parent))) {
const key = String(parent)
if (!childrenMap.has(key)) childrenMap.set(key, [])
childrenMap.get(key)!.push(r)
} else {
roots.push(r) // 无父或父不在当前页 → 视为根
}
}
// DFS 组装树(限制层级防环,默认 10 层)
// 前序遍历:先输出父节点,再递归子节点 → 顺序为"父→子→孙"
const MAX_DEPTH = 10
const result: CardTreeNode[] = []
const visited = new Set<string>()
function build(nodes: T[], depth: number): void {
if (depth > MAX_DEPTH) return
for (const n of nodes) {
const pk = String((n as Record<string, unknown>)[pkField])
if (visited.has(pk)) continue
visited.add(pk)
// 先输出当前节点(父),再递归子节点
result.push({ row: n, depth, children: [] })
const kids = childrenMap.get(pk)
if (kids && kids.length) build(kids, depth + 1)
}
}
build(roots, 0)
// 若存在环导致部分节点未收录,把未访问的节点追加为根(兜底)
for (const r of dataList.value) {
const pk = String((r as Record<string, unknown>)[pkField])
if (!visited.has(pk)) {
result.push({ row: r, depth: 0, children: [] })
visited.add(pk)
}
}
return result
})
// 卡片树是否启用(有父级字段且组装出节点)
const isCardTree = computed(() => !!props.cardTreeParentField)
// 树内单条记录的 key(pk 兜底 JSON)
function cardRowKey(row: Record<string, unknown>): string {
const pk = row[getPkField() ?? '']
return String(pk ?? JSON.stringify(row))
}
// 树节点当前是否选中(用于卡片高亮)
function isCardSelected(row: Record<string, unknown>): boolean {
const pk = row[getPkField() ?? '']
return selectedRows.value.some((r) => (r as Record<string, unknown>)[getPkField() ?? ''] === pk)
}
// 暴露给父组件:选中行 / 当前页数据 / 刷新(供工具栏插槽等批量操作使用)
defineExpose({
selectedRows,
dataList,
fetchData,
refresh: fetchData,
})
</script>
<template>
<div class="crud-table" :class="{ 'crud-table-stretch': props.stretch }">
<!-- 搜索栏 -->
<el-card v-if="searchable" shadow="never" class="search-bar">
<el-input
v-model="searchKeyword"
placeholder="关键字搜索"
clearable
style="width: 240px"
@keyup.enter="handleSearch"
/>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>搜索
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-card>
<!-- 表格 + 操作栏 -->
<el-card shadow="never" class="table-card">
<template #header>
<div class="table-header">
<span class="table-title">{{ title || table }}</span>
<div class="table-actions">
<el-button v-if="canEdit && creatable" type="primary" @click="onHandleCreate">
<el-icon><Plus /></el-icon>新增
</el-button>
<el-button
v-if="canEdit && selectable"
type="danger"
:disabled="selectedRows.length === 0"
@click="handleBatchDelete"
>
<el-icon><Delete /></el-icon>批量删除{{ selectedRows.length ? `(${selectedRows.length})` : '' }}
</el-button>
<el-button @click="fetchData">
<el-icon><Refresh /></el-icon>刷新
</el-button>
<!-- 工具栏插槽:父页面注入批量操作按钮(如批量调整职务),透传选中行/数据/刷新 -->
<slot
name="toolbar"
:selected-rows="selectedRows"
:data-list="dataList"
:refresh="fetchData"
/>
</div>
</div>
</template>
<div class="table-body">
<!-- ===== 表格模式(默认) ===== -->
<vxe-table
v-if="!cardMode"
ref="xTableRef"
class="gerp-table"
:data="dataList"
v-loading="loading"
:height="tableFixedHeight"
:max-height="tableLimitHeight"
:row-config="{ isHover: true }"
:column-config="{ resizable: true }"
:checkbox-config="{ range: true }"
@row-click="(params: { row: T }) => handleRowClick(params.row)"
@checkbox-change="onCheckboxChange"
@checkbox-all="onCheckboxChange"
>
<!-- 复选列 -->
<vxe-column v-if="selectable" type="checkbox" width="50" />
<vxe-column type="seq" width="60" title="#" />
<!-- 数据列:根据 type/statusMap/entityPicker 分支渲染 -->
<!-- 列宽对齐原生 TableEngine:无 width 时弹性填充,minWidth 默认 200 -->
<vxe-column
v-for="col in tableColumns"
:key="col.field"
:field="col.field"
:title="col.title"
:width="col.width"
:min-width="col.minWidth ?? 200"
:fixed="col.fixed"
:sortable="col.sortable"
>
<template #default="{ row }">
<!-- entityPicker:显示回显标签;multiValue(多值)拆多个标签 -->
<template v-if="col.type === 'entityPicker'">
<template v-if="col.multiValue">
<el-tag
v-for="(lbl, i) in multiLabels(row as Record<string, unknown>, col.field)"
:key="i"
size="small"
effect="plain"
class="crud-multi-tag"
>
{{ lbl }}
</el-tag>
</template>
<span v-else>{{ entityLabel(row as Record<string, unknown>, col.field) }}</span>
</template>
<!-- status:el-tag 渲染 -->
<el-tag
v-else-if="col.type === 'status' && col.statusMap"
:type="col.statusMap[String((row as Record<string, unknown>)[col.field])]?.type || 'info'"
:color="col.statusMap[String((row as Record<string, unknown>)[col.field])]?.color"
size="small"
>
{{ col.statusMap[String((row as Record<string, unknown>)[col.field])]?.text || (row as Record<string, unknown>)[col.field] }}
</el-tag>
<!-- tags:标签组渲染 -->
<template v-else-if="col.type === 'tags'">
<el-tag
v-for="tag in renderTags((row as Record<string, unknown>)[col.field], col.tagsSeparator)"
:key="tag"
size="small"
style="margin-right: 4px"
>
{{ tag }}
</el-tag>
</template>
<!-- uuid:前 8 位 + ... -->
<span v-else-if="col.type === 'uuid'">{{ renderUuid((row as Record<string, unknown>)[col.field]) }}</span>
<!-- select:options 匹配显示 label(状态码等) -->
<span v-else-if="col.type === 'select'">{{ renderSelectLabel(col, (row as Record<string, unknown>)[col.field]) }}</span>
<!-- datetime:后端 UTC-0 → 用户本地时区显示 -->
<span v-else-if="col.type === 'datetime'">{{ formatUtcTime((row as Record<string, unknown>)[col.field]) }}</span>
<!-- date:后端 DateField 纯日期(YYYY-MM-DD),无时区语义,原样展示 -->
<span v-else-if="col.type === 'date'">{{ (row as Record<string, unknown>)[col.field] || '-' }}</span>
<!-- richtext:去 HTML 纯文本预览 -->
<span v-else-if="col.type === 'richtext'">{{ stripHtml((row as Record<string, unknown>)[col.field]) }}</span>
<!-- 默认:原值 -->
<span v-else>{{ (row as Record<string, unknown>)[col.field] }}</span>
</template>
</vxe-column>
<!-- 操作列(查看/编辑/删除全不可用时整列隐藏) -->
<vxe-column v-if="onView || canEdit" title="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button v-if="onView" link type="primary" @click="handleView(row as T)">查看</el-button>
<el-button v-if="canEdit" link type="primary" @click="handleEdit(row as T)">编辑</el-button>
<el-button v-if="canEdit" link type="danger" @click="handleDelete(row as T)">删除</el-button>
</template>
</vxe-column>
</vxe-table>
<!-- ===== 卡片模式:卡片网格展示(替代表格) ===== -->
<div
v-else-if="cardMode"
v-loading="loading"
class="card-grid"
:class="{ 'card-grid-empty': !loading && dataList.length === 0 }"
>
<!-- 空态 -->
<el-empty
v-if="!loading && dataList.length === 0"
description="暂无数据"
:image-size="80"
/>
<!-- 树状依赖模式:按 DFS 前序顺序线性排列(父→子→孙,同级按数据顺序) -->
<template v-if="isCardTree">
<div
v-for="node in cardTreeNodes"
:key="cardRowKey(node.row as Record<string, unknown>)"
class="crud-card"
:class="{ 'crud-card-selectable': selectable && isCardSelected(node.row as Record<string, unknown>) }"
@click="handleRowClick(node.row as T)"
@dblclick="handleView(node.row as T)"
>
<!-- 卡片头部:复选 + 层级标签 + 标题 + 子节点数 -->
<div class="crud-card-header">
<div class="crud-card-title-wrap">
<el-checkbox
v-if="selectable"
class="crud-card-check"
:model-value="isCardSelected(node.row as Record<string, unknown>)"
@change="(v: unknown) => onCardCheck(node.row as T, Boolean(v))"
@click.stop
@dblclick.stop
/>
<el-tag
v-if="node.depth > 0"
size="small"
type="warning"
effect="plain"
>
{{ node.depth === 1 ? '子级' : `第${node.depth}层` }}
</el-tag>
<div
v-if="cardIconField"
class="crud-card-icon-wrap"
:class="{ 'crud-card-icon-clickable': !!props.onCardIconClick }"
:title="props.onCardIconClick ? '点击设置图标' : ''"
@click.stop="props.onCardIconClick?.(node.row as T)"
@dblclick.stop
>
<img
v-if="(node.row as Record<string, unknown>)[cardIconField]"
:src="String((node.row as Record<string, unknown>)[cardIconField])"
class="crud-card-icon"
alt=""
/>
<el-icon v-else class="crud-card-icon-placeholder"><OfficeBuilding /></el-icon>
<span v-if="props.onCardIconClick" class="crud-card-icon-mask">
<el-icon><Camera /></el-icon>
</span>
</div>
<span class="crud-card-title">{{ (node.row as Record<string, unknown>)[cardTitleField] || '-' }}</span>
<el-tag v-if="node.children.length" type="info" size="small" effect="plain">
{{ node.children.length }} 子级
</el-tag>
</div>
</div>
<!-- 卡片主体:字段键值列表 -->
<div class="crud-card-body">
<div
v-for="field in cardDisplayFields"
:key="field"
class="crud-card-field"
>
<span class="crud-card-field-label">{{ columnTitle(field) }}</span>
<!-- select/status:标签渲染(状态/优先级一目了然) -->
<el-tag
v-if="cardFieldType(field) === 'select' || cardFieldType(field) === 'status'"
size="small"
effect="plain"
>
{{ formatCardValue(node.row as Record<string, unknown>, field) }}
</el-tag>
<span v-else class="crud-card-field-value" :title="formatCardValue(node.row as Record<string, unknown>, field)">
{{ formatCardValue(node.row as Record<string, unknown>, field) }}
</span>
</div>
</div>
<!-- 卡片操作:查看 / 编辑 / 删除 -->
<div v-if="onView || canEdit" class="crud-card-actions">
<el-button v-if="onView" link type="primary" @dblclick.stop @click.stop="handleView(node.row as T)">
<el-icon><View /></el-icon>查看
</el-button>
<el-button v-if="canEdit" link type="primary" @dblclick.stop @click.stop="handleEdit(node.row as T)">
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button v-if="canEdit" link type="danger" @dblclick.stop @click.stop="handleDelete(node.row as T)">
<el-icon><Delete /></el-icon>删除
</el-button>
</div>
</div>
</template>
<!-- 普通卡片模式(无树) -->
<template v-else>
<div
v-for="row in dataList"
:key="String((row as Record<string, unknown>)[getPkField() ?? ''] ?? JSON.stringify(row))"
class="crud-card"
:class="{ 'crud-card-selectable': selectable && selectedRows.some((r) => (r as Record<string, unknown>)[getPkField() ?? ''] === (row as Record<string, unknown>)[getPkField() ?? '']) }"
@click="handleRowClick(row as T)"
@dblclick="handleView(row as T)"
>
<!-- 卡片头部:复选 + 标题 + 状态标签 -->
<div class="crud-card-header">
<div class="crud-card-title-wrap">
<el-checkbox
v-if="selectable"
class="crud-card-check"
:model-value="selectedRows.some((r) => (r as Record<string, unknown>)[getPkField() ?? ''] === (row as Record<string, unknown>)[getPkField() ?? ''])"
@change="(v: unknown) => onCardCheck(row as T, Boolean(v))"
@click.stop
@dblclick.stop
/>
<div
v-if="cardIconField"
class="crud-card-icon-wrap"
:class="{ 'crud-card-icon-clickable': !!props.onCardIconClick }"
:title="props.onCardIconClick ? '点击设置图标' : ''"
@click.stop="props.onCardIconClick?.(row as T)"
@dblclick.stop
>
<img
v-if="(row as Record<string, unknown>)[cardIconField]"
:src="String((row as Record<string, unknown>)[cardIconField])"
class="crud-card-icon"
alt=""
/>
<el-icon v-else class="crud-card-icon-placeholder"><OfficeBuilding /></el-icon>
<span v-if="props.onCardIconClick" class="crud-card-icon-mask">
<el-icon><Camera /></el-icon>
</span>
</div>
<span class="crud-card-title">{{ (row as Record<string, unknown>)[cardTitleField] || '-' }}</span>
</div>
</div>
<!-- 卡片主体:字段键值列表 -->
<div class="crud-card-body">
<div
v-for="field in cardDisplayFields"
:key="field"
class="crud-card-field"
>
<span class="crud-card-field-label">{{ columnTitle(field) }}</span>
<!-- select/status:标签渲染(状态/优先级一目了然) -->
<el-tag
v-if="cardFieldType(field) === 'select' || cardFieldType(field) === 'status'"
size="small"
effect="plain"
>
{{ formatCardValue(row as Record<string, unknown>, field) }}
</el-tag>
<span v-else class="crud-card-field-value" :title="formatCardValue(row as Record<string, unknown>, field)">
{{ formatCardValue(row as Record<string, unknown>, field) }}
</span>
</div>
</div>
<!-- 卡片操作:查看 / 编辑 / 删除 -->
<div v-if="onView || canEdit" class="crud-card-actions">
<el-button v-if="onView" link type="primary" @dblclick.stop @click.stop="handleView(row as T)">
<el-icon><View /></el-icon>查看
</el-button>
<el-button v-if="canEdit" link type="primary" @dblclick.stop @click.stop="handleEdit(row as T)">
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button v-if="canEdit" link type="danger" @dblclick.stop @click.stop="handleDelete(row as T)">
<el-icon><Delete /></el-icon>删除
</el-button>
</div>
</div>
</template>
</div>
<!-- 分页 -->
<div v-if="pageable" class="pagination">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</div>
</el-card>
<!-- 新增/编辑表单(独立组件 CrudFormPanel:动态表单 + required 校验);
容器形态由 formMode 决定:默认抽屉(drawer),内嵌弹窗的表格传 'dialog' -->
<CrudFormPanel
:visible="dialogVisible"
:title="dialogTitle"
:submitting="submitting"
:columns="formColumns"
:form-model="editingRow"
:mode="formMode"
@update:visible="(v: boolean) => (dialogVisible = v)"
@submit="onHandleSubmit"
@picker-multi="handlePickerSubmitMulti"
/>
</div>
</template>
<style scoped lang="scss">
.crud-table {
display: flex;
flex-direction: column;
gap: 12px;
}
// stretch 模式:撑满父容器,表格内部滚动(整页列表场景)
// 用 flex:1 而非 height:100%:父容器均为 flex column,
// 当存在兄弟元素(AdminBadge / scope-banner)时,height:100% 会取父容器全高导致溢出,
// flex:1 则正确分配剩余空间;无兄弟元素时两者等效。
.crud-table-stretch {
flex: 1;
min-height: 0;
}
// 仅 .table-card 撑满;.search-bar 保持自然高度(flex-shrink:0 防压缩)
.crud-table-stretch .table-card {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
:deep(.el-card__body) {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
}
// 表格主体区域:stretch 模式下占满卡片剩余空间(分页之后的全部高度),
// 行数不足时表格自动收缩,不再被分页组件挤高产生页面级滚动栏
.table-body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
// 非 stretch(详情页嵌套)场景不撑满,随内容自适应高度
.crud-table:not(.crud-table-stretch) & {
flex: none;
min-height: auto;
}
}
.crud-table-stretch .search-bar {
flex-shrink: 0;
}
.search-bar {
:deep(.el-card__body) {
display: flex;
gap: 8px;
align-items: center;
}
}
.table-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.table-title {
font-size: 15px;
font-weight: 600;
}
.table-actions {
display: flex;
gap: 8px;
}
.pagination {
margin-top: 12px;
display: flex;
justify-content: flex-end;
}
// ===== 卡片模式 =====
// 卡片网格:响应式列数,min-width 自动换行
// 树状依赖模式复用同一网格:节点按 DFS 前序顺序输出,顺序即"父→子→孙"
// 卡片网格:固定列宽(300px),宽度变化只改变每行列数,不影响卡片内部布局。
// 固定宽度保证:短文本换行数恒定 → 卡片高度恒定,不随容器宽度波动。
// min(300px, 100%) 处理容器极窄时的收缩,避免横向溢出。
.card-grid {
flex: 1;
min-height: 0;
overflow-y: auto;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(300px, 100%), 300px));
gap: 14px;
padding: 2px;
align-content: start;
// 卡片按自身内容高度对齐,不拉伸到同行最高卡片(高度完全由内容决定)
align-items: start;
}
// 空态:无卡片数据时切换 flex 布局,el-empty 水平垂直居中
// (grid 布局下 auto 行轨道高度=内容高度,margin:auto 无法垂直居中,故单独切 flex)
.card-grid-empty {
display: flex;
align-items: center;
justify-content: center;
}
// 单张卡片:高度由自身内容决定(header + body + actions 自然堆叠)
// 不设 overflow:hidden,避免任何外部拉伸/压缩裁剪内容;
// 网格父级 align-items:start 保证不拉伸,flex 纵向排列内容完整可见。
.crud-card {
display: flex;
flex-direction: column;
border: 1px solid var(--el-border-color-light);
border-radius: 10px;
background: var(--el-bg-color);
transition:
box-shadow 0.2s ease,
border-color 0.2s ease;
&:hover {
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.08);
border-color: var(--el-color-primary-light-5);
}
// 选中态:主色描边
&.crud-card-selectable {
border-color: var(--el-color-primary);
box-shadow: 0 0 0 1px var(--el-color-primary) inset;
}
}
// 卡片头部:标题 + 查看
.crud-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 12px 14px 8px;
border-bottom: 1px dashed var(--el-border-color-lighter);
}
.crud-card-title-wrap {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.crud-card-title {
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
// 卡片标题旁的公司图标
.crud-card-icon {
width: 22px;
height: 22px;
border-radius: 6px;
object-fit: cover;
flex-shrink: 0;
display: block;
}
// 图标容器(可点击上传时显示遮罩)
.crud-card-icon-wrap {
position: relative;
width: 22px;
height: 22px;
flex-shrink: 0;
overflow: hidden;
border-radius: 6px;
}
// 无图标占位:主色淡底 + 建筑图标
.crud-card-icon-placeholder {
width: 22px;
height: 22px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
// 可点击图标:悬停显示相机遮罩
.crud-card-icon-clickable {
cursor: pointer;
&:hover .crud-card-icon-mask {
opacity: 1;
}
}
.crud-card-icon-mask {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
color: #fff;
font-size: 12px;
opacity: 0;
transition: opacity 0.15s;
}
// 卡片主体:键值列表(高度由内容决定,不参与拉伸/压缩)
.crud-card-body {
flex: none;
padding: 10px 14px;
display: flex;
flex-direction: column;
gap: 8px;
}
.crud-card-field {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
min-width: 0;
}
// multiValue 多值标签(表格列内多个职务/多标签)
.crud-multi-tag {
margin-right: 6px;
margin-bottom: 2px;
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
}
.crud-card-field-label {
flex-shrink: 0;
color: var(--el-text-color-secondary);
font-size: 12px;
}
.crud-card-field-value {
text-align: right;
color: var(--el-text-color-primary);
font-size: 13px;
min-width: 0;
word-break: break-word;
overflow-wrap: anywhere;
white-space: normal;
line-height: 1.45;
}
// 卡片操作:查看 / 编辑 / 删除(补底部圆角,避免背景色盖住卡片圆角变尖角)
// 重置 EP 的 .el-button + .el-button { margin-left: 12px },改用 flex gap 统一等距
.crud-card-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 6px 10px;
border-top: 1px solid var(--el-border-color-lighter);
background: var(--el-fill-color-light);
border-radius: 0 0 10px 10px;
:deep(.el-button + .el-button) {
margin-left: 0;
}
}
</style>