diff --git a/src/components/CrudFormPanel.vue b/src/components/CrudFormPanel.vue index de0105c..063ba82 100644 --- a/src/components/CrudFormPanel.vue +++ b/src/components/CrudFormPanel.vue @@ -5,6 +5,7 @@ import type { FormInstance, FormRules } from 'element-plus' import type { ColumnSchema } from '@/types/api' import EntityPicker from './EntityPicker.vue' import MultiEntityPicker from './MultiEntityPicker.vue' +import SubFormTable from './SubFormTable.vue' import RichTextEditor from './RichTextEditor.vue' import { get } from '@/api/request' @@ -173,6 +174,13 @@ watch( :disabled="col.disabled" @update:label="(label: string) => onMultiLabel(col.field, label)" /> + + +/** + * SubFormTable 子表单(明细子表) + * + * 用于 ColumnSchema.type === 'subform' 的列:把「主表一条记录 → N 条明细」渲染成 + * 可增删行的明细区(如员工档案的「联系方式」)。 + * + * 值模型:v-model 绑定的就是明细行数组(挂在主表 formModel[field] 上), + * 随主表一起提交;后端保存主表后按该数组整体重写子表。 + * + * 明细行字段定义由后端下发(col.subform.fields),此处只负责渲染与增删, + * 不额外定义任何业务字段。 + */ +import { computed } from 'vue' +import type { ColumnSchema, SubFormConfig } from '@/types/api' + +const props = withDefaults(defineProps<{ + /** 明细行数组(v-model) */ + modelValue?: Record[] + /** 子表单配置(标题/按钮文案/明细行字段定义) */ + config?: SubFormConfig + /** 是否禁用(禁用时不能增删改) */ + disabled?: boolean +}>(), { + modelValue: () => [], + config: undefined, + disabled: false, +}) + +const emit = defineEmits<{ + (e: 'update:modelValue', rows: Record[]): void +}>() + +/** 实际可渲染的明细行字段(跳过 hideInForm/hidden:如 ContactSort 由后端按下标重排) */ +const fields = computed(() => + (props.config?.fields || []).filter( + (f) => f.hideInForm !== true && f.type !== 'hidden' && f.type !== 'divider' && f.type !== 'html', + ), +) + +/** 明细行(始终是数组,避免 undefined 导致表格报错) */ +const rows = computed[]>(() => props.modelValue || []) + +/** 更新明细行(整体替换:新增/删除/改值都走这里,父组件 formModel 同步) */ +function commit(next: Record[]): void { + emit('update:modelValue', next) +} + +/** 新增一行:按字段默认值初始化(select 取首项,其余空) */ +function addRow(): void { + const row: Record = {} + for (const f of fields.value) { + row[f.field] = f.type === 'select' ? (f.options?.[0]?.value ?? '') : '' + } + commit([...rows.value, row]) +} + +/** 删除第 index 行 */ +function removeRow(index: number): void { + const next = rows.value.slice() + next.splice(index, 1) + commit(next) +} + +/** 单元格取值变化:只改对应行的对应字段 */ +function updateCell(index: number, field: string, value: unknown): void { + const next = rows.value.slice() + next[index] = { ...next[index], [field]: value } + commit(next) +} + +/** 单元格宽度:select 稍宽,数字/日期窄一点 */ +function colWidth(field: ColumnSchema): number { + if (field.type === 'select') return 160 + if (field.type === 'number' || field.type === 'date' || field.type === 'datetime') return 140 + return 220 +} + + + + + diff --git a/src/types/api.ts b/src/types/api.ts index f69bf9e..bd4cb6c 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -107,6 +107,24 @@ export interface EntityPickerConfig { } } +/** + * 子表单配置(ColumnSchema.type === 'subform') + * + * 语义:主表一条记录对应 N 条明细(如员工档案 → 联系方式)。表单里渲染成一个 + * 「可增删行的明细区」,明细行数组挂在主表表单的 `formModel[该列 field]` 上, + * 随主表一起提交(后端在保存主表后整体重写子表)。 + */ +export interface SubFormConfig { + /** 子表名(对应后端 table,便于调试/扩展) */ + table?: string + /** 区块标题(默认取列 title) */ + title?: string + /** 「添加」按钮文案 */ + addText?: string + /** 明细行字段定义(复用列结构:field/title/type/options/required/placeholder) */ + fields: ColumnSchema[] +} + /** * 后端 FormAPI Columns 返回的列定义 * 兼容旧 GERP TableEngine 的 column schema,前端用 vxe-table / FormKit 渲染 @@ -128,7 +146,7 @@ export interface ColumnSchema { * 控件/渲染类型 * 渲染 + 编辑共用:text/string/number/textarea/select/date/datetime/time/ * boolean/switch/checkbox/radio/tags/password/email/tel/phone/url/color/ - * file/image/hidden/entityPicker/readonly/divider/html/status/uuid + * file/image/hidden/entityPicker/subform/readonly/divider/html/status/uuid */ type?: string @@ -223,6 +241,10 @@ export interface ColumnSchema { /** 表单项下方说明文字 */ description?: string + // === 子表单(明细子表) === + /** type=subform 时的配置(如员工档案的「联系方式」明细行) */ + subform?: SubFormConfig + // === 实体选择器 === /** type=entityPicker 时的配置 */ entityPicker?: EntityPickerConfig diff --git a/src/views/system/UserDetail.vue b/src/views/system/UserDetail.vue index 80d133f..57e433a 100644 --- a/src/views/system/UserDetail.vue +++ b/src/views/system/UserDetail.vue @@ -208,8 +208,9 @@ async function handleSubmit(): Promise { /** eHRM 档案管理菜单路由(清单 MenuLink /ehrm/profile 规范化后的 Path) */ const EHRM_PROFILE_PATH = '/ehrm/profile' -/** 档案只读展示时排除的列:主键与纯 UUID 引用列(员工=本页用户,部门已有 DeptName 展示) */ -const PROFILE_HIDDEN_FIELDS = ['EmployeeProfileUUID', 'UserUUID', 'DeptUUID', 'TenantUUID', 'CompanyUUID'] +/** 档案只读展示时排除的列:主键与纯 UUID 引用列 + *(员工=本页用户;部门用 DeptUUID 的关联标签展示为「部门名称」;联系方式子表单独成块) */ +const PROFILE_HIDDEN_FIELDS = ['EmployeeProfileUUID', 'UserUUID', 'TenantUUID', 'CompanyUUID'] const profileLoading = ref(false) /** 该员工的档案记录(一人一份,最多一条) */ @@ -258,22 +259,47 @@ const profileFormColumns = computed(() => ), ) -/** 只读展示值:实体标签(*_label)优先 → 选项映射 → 日期格式化 → 原值 */ +/** 只读展示值:实体标签(*_label)优先 → 选项映射 → 布尔/多选/日期 → 原值 */ function profileValue(col: ColumnSchema): string { const row = profileRecord.value if (!row) return '-' const label = row[`${col.field}_label`] if (label) return String(label) const raw = row[col.field] + // 布尔意愿字段(switch/boolean)先判:false 也是有效值,不能当空处理 + if (col.type === 'switch' || col.type === 'boolean') return raw ? '是' : '否' if (raw === null || raw === undefined || raw === '') return '-' if (col.options?.length) { const hit = col.options.find((o) => String(o.value) === String(raw)) if (hit) return hit.label } + // 多选(tags):逗号串 → 顿号分隔展示 + if (col.type === 'tags') { + const items = String(raw).split(col.tagsSeparator || ',').map((s) => s.trim()).filter(Boolean) + return items.length ? items.join('、') : '-' + } if (col.type === 'date' || col.type === 'datetime') return formatTime(String(raw)) return String(raw) } +/** 档案联系方式明细(子表 Contacts:后端随档案一并返回,按 ContactSort 排序) */ +const profileContacts = computed[]>(() => { + const rows = profileRecord.value?.Contacts + return Array.isArray(rows) ? rows : [] +}) + +/** 联系方式类型选项:来自后端下发的子表列定义,前端不另写一份映射 */ +const contactTypeOptions = computed(() => { + const col = profileColumns.value.find((c) => c.subform?.table === 'EmployeeContact') + return col?.subform?.fields.find((f) => f.field === 'ContactType')?.options || [] +}) + +/** 联系方式类型值 → 中文名 */ +function contactTypeLabel(value: unknown): string { + const hit = contactTypeOptions.value.find((o) => String(o.value) === String(value)) + return hit ? hit.label : String(value ?? '-') +} + /** 加载该员工的档案(列表按 UserUUID 过滤,取首条) */ async function loadProfile(): Promise { if (!userUUID.value) { @@ -522,16 +548,29 @@ watch(userUUID, () => {
- - - - {{ profileValue(col) }} - - + +
@@ -727,6 +766,18 @@ watch(userUUID, () => { } } +// 联系方式明细(子表):与档案键值表保持上下间距 +.profile-contacts { + margin-top: 12px; +} + +.profile-contacts-title { + margin-bottom: 6px; + font-size: 13px; + font-weight: 600; + color: var(--el-text-color-regular); +} + // 无档案空态:居中「点击填写」 .profile-empty { display: flex;