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→本地时区转换。
1131 lines
42 KiB
Vue
1131 lines
42 KiB
Vue
<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(/ /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>
|