feat(gerp): 新增子表单控件,员工档案展示新字段

CrudFormPanel 支持 type=subform(明细子表可增删行,明细数组随主表提交);新增 SubFormTable 组件;types 增加 SubFormConfig;UserDetail 档案面板按后端下发选项展示(布尔显示是/否、多选顿号分隔、部门名走关联标签、联系方式明细单独成块,类型名取后端选项)。
This commit is contained in:
TermiNexus committed 2026-10-01 19:36:51 +08:00
1 parent f806106c88
commit 6bb1edba88
4 files changed
+264 -14

No files matched your search

+8
View File
@@ -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)"
/>
<!-- subform:子表单(明细子表,如员工档案的「联系方式」),明细行随主表一起提交 -->
<SubFormTable
v-else-if="col.type === 'subform'"
v-model="formModel[col.field]"
:config="col.subform"
:disabled="col.disabled"
/>
<!-- entityPicker:EntityPicker 组件(支持级联:dependsOn 字段值 + dependCompany 公司值) -->
<EntityPicker
v-else-if="col.type === 'entityPicker' && col.entityPicker && !col.multiValue"
+169
View File
@@ -0,0 +1,169 @@
<script setup lang="ts">
/**
* 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<string, unknown>[]
/** 子表单配置(标题/按钮文案/明细行字段定义) */
config?: SubFormConfig
/** 是否禁用(禁用时不能增删改) */
disabled?: boolean
}>(), {
modelValue: () => [],
config: undefined,
disabled: false,
})
const emit = defineEmits<{
(e: 'update:modelValue', rows: Record<string, unknown>[]): void
}>()
/** 实际可渲染的明细行字段(跳过 hideInForm/hidden:如 ContactSort 由后端按下标重排) */
const fields = computed<ColumnSchema[]>(() =>
(props.config?.fields || []).filter(
(f) => f.hideInForm !== true && f.type !== 'hidden' && f.type !== 'divider' && f.type !== 'html',
),
)
/** 明细行(始终是数组,避免 undefined 导致表格报错) */
const rows = computed<Record<string, unknown>[]>(() => props.modelValue || [])
/** 更新明细行(整体替换:新增/删除/改值都走这里,父组件 formModel 同步) */
function commit(next: Record<string, unknown>[]): void {
emit('update:modelValue', next)
}
/** 新增一行:按字段默认值初始化(select 取首项,其余空) */
function addRow(): void {
const row: Record<string, unknown> = {}
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
}
</script>
<template>
<div class="sub-form-table">
<div class="sub-form-head">
<span class="sub-form-title">{{ config?.title || '明细' }}({{ rows.length }})</span>
<el-button type="primary" plain size="small" :disabled="disabled" @click="addRow">
<el-icon><Plus /></el-icon>{{ config?.addText || '添加一行' }}
</el-button>
</div>
<el-table :data="rows" size="small" border class="sub-form-body">
<el-table-column
v-for="f in fields"
:key="f.field"
:label="f.title"
:min-width="colWidth(f)"
>
<template #default="{ row, $index }">
<el-select
v-if="f.type === 'select'"
:model-value="row[f.field]"
:placeholder="f.placeholder || `选择${f.title}`"
:disabled="disabled"
style="width: 100%"
@update:model-value="(v: unknown) => updateCell($index, f.field, v)"
>
<el-option
v-for="opt in f.options || []"
:key="String(opt.value)"
:value="opt.value"
:label="opt.label"
/>
</el-select>
<el-input-number
v-else-if="f.type === 'number'"
:model-value="row[f.field] as number"
:disabled="disabled"
controls-position="right"
style="width: 100%"
@update:model-value="(v: number | undefined) => updateCell($index, f.field, v)"
/>
<el-input
v-else
:model-value="String(row[f.field] ?? '')"
:placeholder="f.placeholder || `请输入${f.title}`"
:disabled="disabled"
@update:model-value="(v: string) => updateCell($index, f.field, v)"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center">
<template #default="{ $index }">
<el-button link type="danger" :disabled="disabled" @click="removeRow($index)">删除</el-button>
</template>
</el-table-column>
<!-- 空态:表格自带空数据展示,这里给更明确的引导 -->
<template #empty>
<span class="sub-form-empty">暂无明细,点「{{ config?.addText || '添加一行' }}」新增</span>
</template>
</el-table>
</div>
</template>
<style scoped lang="scss">
.sub-form-table {
width: 100%;
}
.sub-form-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.sub-form-title {
font-size: 13px;
color: var(--el-text-color-secondary);
}
.sub-form-body {
width: 100%;
}
.sub-form-empty {
color: var(--el-text-color-placeholder);
font-size: 13px;
}
</style>
+23 -1
View File
@@ -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
+64 -13
View File
@@ -208,8 +208,9 @@ async function handleSubmit(): Promise<void> {
/** 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<Record<string, any>[]>(() => {
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<void> {
if (!userUUID.value) {
@@ -522,16 +548,29 @@ watch(userUUID, () => {
</template>
<div v-loading="profileLoading" class="profile-panel">
<!-- 已有档案:键值只读展示(不可更改) -->
<el-descriptions v-if="profileRecord" :column="3" border class="profile-desc">
<el-descriptions-item
v-for="col in profileDisplayColumns"
:key="col.field"
:label="col.title"
>
{{ profileValue(col) }}
</el-descriptions-item>
</el-descriptions>
<!-- 已有档案:键值只读展示(不可更改)+ 联系方式明细 -->
<template v-if="profileRecord">
<el-descriptions :column="3" border class="profile-desc">
<el-descriptions-item
v-for="col in profileDisplayColumns"
:key="col.field"
:label="col.title"
>
{{ profileValue(col) }}
</el-descriptions-item>
</el-descriptions>
<!-- 联系方式明细(EmployeeContact 子表):只读列出,类型名用后端下发的选项 -->
<div v-if="profileContacts.length" class="profile-contacts">
<div class="profile-contacts-title">联系方式</div>
<el-table :data="profileContacts" size="small" border>
<el-table-column label="类型" width="160">
<template #default="{ row }">{{ contactTypeLabel(row.ContactType) }}</template>
</el-table-column>
<el-table-column label="联系方式" prop="ContactValue" />
</el-table>
</div>
</template>
<!-- 无档案 + 有 eHRM 修改权限:居中「点击填写」 -->
<div v-else-if="canEditProfile" class="profile-empty">
@@ -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;