Compare commits

..
4 Commits
Author SHA1 Message Date
fuenmao f1a910c25a 11 2026-08-26 11:00:10 +08:00
fuenmao eab359d9fa 11 2026-08-25 20:37:24 +08:00
fuenmao aec6c1f36c 调整 2026-08-25 13:54:25 +08:00
fuenmao 49a1a3ee6a 逻辑调整 2026-08-25 10:20:59 +08:00
7 changed files with 3092 additions and 1997 deletions
+120 -85
View File
@@ -3,6 +3,7 @@ import type { ReqId, ResPage } from "@/api/interface";
const taskHttp = moduleRequest("/biz/onboarding/task/"); const taskHttp = moduleRequest("/biz/onboarding/task/");
const auditHttp = moduleRequest("/biz/onboarding/audit/"); const auditHttp = moduleRequest("/biz/onboarding/audit/");
const fileHttp = moduleRequest("/sys/dev/file/");
export interface OnboardingTask { export interface OnboardingTask {
id: number; id: number;
@@ -80,16 +81,6 @@ export interface OnboardingTemplateVariable {
remark?: string; remark?: string;
} }
export interface OnboardingContractScope {
id: number;
companyId: number;
positionId?: number;
templateId: number;
templateVersionId: number;
required: boolean;
sortCode?: number;
}
export interface OnboardingTaskContractVariableItem { export interface OnboardingTaskContractVariableItem {
id: number; id: number;
variableCode: string; variableCode: string;
@@ -142,24 +133,43 @@ export interface OnboardingMaterialType {
status: string; status: string;
} }
export interface OnboardingFormRequirement { export interface OnboardingTemplateContractConfig {
id: number; contractTemplateId: number;
companyId: number; contractTemplateVersionId: number;
positionId?: number; required: boolean;
printCopies: number;
sortCode?: number;
}
export interface OnboardingTemplateFieldConfig {
fieldId: number; fieldId: number;
required: boolean; required: boolean;
sortCode?: number; sortCode?: number;
} }
export interface OnboardingMaterialRequirement { export interface OnboardingTemplateMaterialConfig {
id: number;
companyId: number;
positionId?: number;
materialTypeId: number; materialTypeId: number;
required: boolean; required: boolean;
sortCode?: number; sortCode?: number;
} }
export interface OnboardingTemplate {
id: number;
templateCode: string;
templateName: string;
companyId: number;
companyName?: string;
positionIds: number[];
positionNames?: string[];
contracts: OnboardingTemplateContractConfig[];
formFields: OnboardingTemplateFieldConfig[];
materials: OnboardingTemplateMaterialConfig[];
contractCount?: number;
formFieldCount?: number;
materialCount?: number;
status: string;
description?: string;
}
export const onboardingTaskApi = { export const onboardingTaskApi = {
page(params: Record<string, unknown>) { page(params: Record<string, unknown>) {
return taskHttp.get<ResPage<OnboardingTask>>("page", params); return taskHttp.get<ResPage<OnboardingTask>>("page", params);
@@ -169,6 +179,15 @@ export const onboardingTaskApi = {
return taskHttp.get("detail", params); return taskHttp.get("detail", params);
}, },
/** 使用后台登录令牌读取文件流,供入职材料下载和图片预览使用。 */
downloadFile(fileId: number) {
return fileHttp.get<any>(
"download",
{ id: fileId },
{ responseType: "blob", loading: false, cancel: false }
);
},
create(params: Record<string, unknown>) { create(params: Record<string, unknown>) {
return taskHttp.post("create", params); return taskHttp.post("create", params);
}, },
@@ -208,6 +227,10 @@ export const onboardingTaskApi = {
return taskHttp.post("revoke", params); return taskHttp.post("revoke", params);
}, },
deleteTask(id: number) {
return taskHttp.post("delete", { id });
},
reviewMaterial(params: { reviewMaterial(params: {
taskMaterialId: number; taskMaterialId: number;
reviewStatus: string; reviewStatus: string;
@@ -291,6 +314,40 @@ export const onboardingTaskApi = {
); );
}, },
onboardingTemplatePage(params: Record<string, unknown>) {
return moduleRequest("/biz/onboarding/template/").get<
ResPage<OnboardingTemplate>
>("page", params);
},
onboardingTemplateDetail(params: ReqId) {
return moduleRequest("/biz/onboarding/template/").get<OnboardingTemplate>(
"detail",
params
);
},
saveOnboardingTemplate(params: Partial<OnboardingTemplate>) {
return moduleRequest("/biz/onboarding/template/").post<number>(
"save",
params
);
},
deleteOnboardingTemplate(ids: number[]) {
return moduleRequest("/biz/onboarding/template/").post(
"delete",
{ ids }
);
},
listEnabledOnboardingTemplates(companyId: number, positionId: number) {
return moduleRequest("/biz/onboarding/template/").get<OnboardingTemplate[]>(
"list-enabled",
{ companyId, positionId }
);
},
templatePage(params: Record<string, unknown>) { templatePage(params: Record<string, unknown>) {
return moduleRequest("/biz/onboarding/contract-template/").get< return moduleRequest("/biz/onboarding/contract-template/").get<
ResPage<OnboardingContractTemplate> ResPage<OnboardingContractTemplate>
@@ -347,6 +404,13 @@ export const onboardingTaskApi = {
); );
}, },
deleteVersion(id: number) {
return moduleRequest("/biz/onboarding/contract-template/").post(
"version/delete",
{ id }
);
},
variablePage(params: { variablePage(params: {
templateVersionId: number; templateVersionId: number;
pageNum: number; pageNum: number;
@@ -357,47 +421,13 @@ export const onboardingTaskApi = {
>("variable/page", params); >("variable/page", params);
}, },
saveVariable( editVariable(params: Partial<OnboardingTemplateVariable>) {
params: Partial<OnboardingTemplateVariable>,
edit = false
) {
return moduleRequest("/biz/onboarding/contract-template/").post( return moduleRequest("/biz/onboarding/contract-template/").post(
edit ? "variable/edit" : "variable/add", "variable/edit",
params params
); );
}, },
deleteVariable(ids: number[]) {
return moduleRequest("/biz/onboarding/contract-template/").post(
"variable/delete",
{ ids }
);
},
scopePage(params: {
templateId: number;
pageNum: number;
pageSize: number;
}) {
return moduleRequest("/biz/onboarding/contract-template/").get<
ResPage<OnboardingContractScope>
>("scope/page", params);
},
addScope(params: Partial<OnboardingContractScope>) {
return moduleRequest("/biz/onboarding/contract-template/").post(
"scope/add",
params
);
},
deleteScope(ids: number[]) {
return moduleRequest("/biz/onboarding/contract-template/").post(
"scope/delete",
{ ids }
);
},
uploadDocx(params: FormData) { uploadDocx(params: FormData) {
return moduleRequest("/biz/onboarding/contract-template/").post<number>( return moduleRequest("/biz/onboarding/contract-template/").post<number>(
"file/upload-docx", "file/upload-docx",
@@ -477,45 +507,50 @@ export const onboardingTaskApi = {
); );
}, },
listFormRequirements(companyId: number) { // 合同变量字典定义
return moduleRequest("/biz/onboarding/entry-config/").get< variableDefinitionPage(params: { keyword?: string; variableCategory?: string; pageNum: number; pageSize: number }) {
OnboardingFormRequirement[] return moduleRequest("/biz/onboarding/variable-definition/").get<ResPage<any>>("page", params);
>("form-requirement/list", { companyId });
}, },
saveFormRequirement(params: Partial<OnboardingFormRequirement>) { variableDefinitionList() {
return moduleRequest("/biz/onboarding/entry-config/").post( return moduleRequest("/biz/onboarding/variable-definition/").get<any[]>("list");
"form-requirement/save",
params
);
}, },
deleteFormRequirements(ids: number[]) { addVariableDefinition(params: Record<string, unknown>) {
return moduleRequest("/biz/onboarding/entry-config/").post( return moduleRequest("/biz/onboarding/variable-definition/").post("add", params);
"form-requirement/delete",
{ ids }
);
}, },
listMaterialRequirements(companyId: number) { editVariableDefinition(params: Record<string, unknown>) {
return moduleRequest("/biz/onboarding/entry-config/").get< return moduleRequest("/biz/onboarding/variable-definition/").post("edit", params);
OnboardingMaterialRequirement[]
>("material-requirement/list", { companyId });
}, },
saveMaterialRequirement( deleteVariableDefinitions(ids: number[]) {
params: Partial<OnboardingMaterialRequirement> return moduleRequest("/biz/onboarding/variable-definition/").post("delete", { ids });
) { }
return moduleRequest("/biz/onboarding/entry-config/").post( };
"material-requirement/save",
params const publicHttp = moduleRequest("/open/onboarding/");
);
export const onboardingPublicApi = {
detail(params: { token: string }) {
return publicHttp.post<any>("detail", params);
},
saveEmployeeInfo(params: Record<string, unknown>) {
return publicHttp.post("save-employee-info", params);
},
saveMaterial(params: { token: string; materialTypeId: number; fileId: number }) {
return publicHttp.post("save-material", params);
}, },
confirmContract(params: { token: string; taskContractId: number }) {
deleteMaterialRequirements(ids: number[]) { return publicHttp.post("confirm-contract", params);
return moduleRequest("/biz/onboarding/entry-config/").post( },
"material-requirement/delete", submit(params: { token: string }) {
{ ids } return publicHttp.post("submit", params);
); },
contractSourceUrl(params: { token: string; taskContractId: number }) {
return `/api/open/onboarding/contract-source?token=${encodeURIComponent(params.token)}&taskContractId=${params.taskContractId}`;
},
deleteMaterial(params: { token: string; taskMaterialId: number }) {
return publicHttp.post("delete-material", params);
} }
}; };
+132 -297
View File
@@ -82,7 +82,7 @@
<el-drawer <el-drawer
v-model="drawerVisible" v-model="drawerVisible"
:title="`合同模板管理 · ${selectedTemplate?.templateName || ''}`" :title="`合同模板管理 · ${selectedTemplate?.templateName || ''}`"
size="960px" size="1150px"
destroy-on-close destroy-on-close
> >
<div v-if="selectedTemplate" class="drawer-content-box"> <div v-if="selectedTemplate" class="drawer-content-box">
@@ -135,8 +135,9 @@
<el-table-column prop="expiryDate" label="失效日期" width="130" align="center" /> <el-table-column prop="expiryDate" label="失效日期" width="130" align="center" />
<el-table-column prop="remark" label="版本说明" min-width="150" /> <el-table-column prop="remark" label="版本说明" min-width="150" />
<el-table-column label="操作" width="280" fixed="right"> <el-table-column label="操作" width="340" fixed="right" align="center">
<template #default="scope"> <template #default="scope">
<div class="table-actions">
<el-button <el-button
link link
type="primary" type="primary"
@@ -174,104 +175,16 @@
> >
停用 停用
</el-button> </el-button>
</template>
</el-table-column> <el-button
</el-table> link
type="danger"
icon="Delete"
@click="removeVersion(scope.row)"
>
删除
</el-button>
</div> </div>
</el-tab-pane>
<!-- 2. 适用范围配置 -->
<el-tab-pane label="适用范围 (公司与岗位)" name="scopes">
<div class="tab-pane-content">
<el-card shadow="never" class="scope-add-card">
<template #header>
<span class="font-bold">为公司/岗位分配此合同模板</span>
</template>
<el-form :model="scopeForm" inline class="scope-inline-form">
<el-form-item label="所属公司" required>
<el-select
v-model="scopeForm.companyId"
placeholder="请选择公司"
filterable
style="width: 200px"
@change="onScopeCompanyChange"
>
<el-option
v-for="company in companies"
:key="company.id"
:label="company.companyName"
:value="company.id"
/>
</el-select>
</el-form-item>
<el-form-item label="适用岗位">
<el-select
v-model="scopeForm.positionId"
placeholder="全公司所有岗位"
clearable
style="width: 200px"
>
<el-option
v-for="position in scopePositions"
:key="position.id"
:label="position.positionName"
:value="position.id"
/>
</el-select>
</el-form-item>
<el-form-item label="绑定版本" required>
<el-select
v-model="scopeForm.templateVersionId"
placeholder="请选择合同版本"
style="width: 180px"
>
<el-option
v-for="ver in enabledVersions"
:key="ver.id"
:label="`版本 ${ver.versionNo}`"
:value="ver.id"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="Plus" @click="saveScope">
添加适用范围
</el-button>
</el-form-item>
</el-form>
</el-card>
<el-table :data="scopes" border stripe class="mt-4">
<el-table-column label="所属公司" min-width="180">
<template #default="scope">
<el-tag effect="plain" type="primary">
{{ companyName(scope.row.companyId) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="适用岗位" min-width="180">
<template #default="scope">
{{ positionName(scope.row.positionId) }}
</template>
</el-table-column>
<el-table-column label="生效版本" width="140" align="center">
<template #default="scope">
<el-tag size="small" type="success">
版本 {{ versionName(scope.row.templateVersionId) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="110" align="center">
<template #default="scope">
<el-button link type="danger" icon="Delete" @click="removeScope(scope.row)">
移除
</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -294,7 +207,7 @@
append-to-body append-to-body
> >
<el-alert <el-alert
title="仅支持 .docx 文件。请在 Word 文档中使用 {{变量编码}}(如 {{EmployeeName}}、{{Salary}})作为占位符。" title="仅支持 .docx。占位符按来源编写:{{employee.EmployeeName}}、{{company.CompanyName}}、{{hr.MonthlySalary}}、{{system.SignDate}};保存版本时系统会自动识别。"
type="info" type="info"
:closable="false" :closable="false"
style="margin-bottom: 16px" style="margin-bottom: 16px"
@@ -346,28 +259,35 @@
<el-dialog <el-dialog
v-model="variablesVisible" v-model="variablesVisible"
:title="`合同变量配置 · 版本 ${selectedVersion?.versionNo || ''}`" :title="`合同变量配置 · 版本 ${selectedVersion?.versionNo || ''}`"
width="1050px" width="1220px"
top="4vh"
destroy-on-close destroy-on-close
append-to-body append-to-body
class="variables-dialog"
> >
<div class="toolbar-flex">
<el-button type="primary" icon="Plus" @click="openVariableCreate">
新增变量定义
</el-button>
<el-alert <el-alert
title="在 DOCX 模板中使用 {{变量编码}} 作为占位符。点击变量编码旁复制按钮可直接复制占位符。" title="变量已从 DOCX 自动识别与解析。请核对来源分类、显示名称及数据类型;确保全部配置正确后即可启用版本。"
type="info" type="info"
show-icon show-icon
:closable="false" :closable="false"
style="flex: 1; margin: 0 0 0 16px;" class="mb-3"
/> />
</div>
<el-table :data="variables" border stripe class="mt-3"> <!-- 分组 Tabs 切换 -->
<el-table-column prop="variableCode" label="变量编码 (DOCX占位符)" min-width="220"> <el-tabs v-model="variableTab" type="card" class="variable-filter-tabs">
<el-tab-pane :label="`全部变量 (${variableCounts.all})`" name="all" />
<el-tab-pane :label="`员工填写 (${variableCounts.employee})`" name="employee" />
<el-tab-pane :label="`人事合同参数 (${variableCounts.hr})`" name="hr" />
<el-tab-pane :label="`公司信息 (${variableCounts.company})`" name="company" />
<el-tab-pane :label="`系统预置 (${variableCounts.system})`" name="system" />
</el-tabs>
<div class="variables-table-wrapper">
<el-table :data="filteredVariables" border stripe height="500px">
<el-table-column prop="variableCode" label="变量全编码 (DOCX占位符)" min-width="260">
<template #default="scope"> <template #default="scope">
<div style="display: flex; align-items: center; justify-content: space-between;"> <div class="variable-code-cell">
<code style="font-weight: 600; color: #2563eb;">{{ formatPlaceholder(scope.row.variableCode) }}</code> <code class="code-font">{{ formatPlaceholder(scope.row.variableCode) }}</code>
<el-button <el-button
size="small" size="small"
link link
@@ -381,9 +301,9 @@
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="variableName" label="变量显示名称" min-width="160" /> <el-table-column prop="variableName" label="变量显示名称" min-width="170" />
<el-table-column prop="variableType" label="来源类型" width="130" align="center"> <el-table-column prop="variableType" label="来源板块" width="130" align="center">
<template #default="scope"> <template #default="scope">
<el-tag size="small" :type="variableTypeTag(scope.row.variableType)"> <el-tag size="small" :type="variableTypeTag(scope.row.variableType)">
{{ variableTypeText(scope.row.variableType) }} {{ variableTypeText(scope.row.variableType) }}
@@ -393,11 +313,11 @@
<el-table-column prop="dataType" label="数据类型" width="100" align="center"> <el-table-column prop="dataType" label="数据类型" width="100" align="center">
<template #default="scope"> <template #default="scope">
<el-tag size="small" type="info">{{ scope.row.dataType }}</el-tag> <el-tag size="small" type="info">{{ scope.row.dataType || 'string' }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="employeeField" label="对应员工字段" min-width="140" /> <el-table-column prop="employeeField" label="对应字段" min-width="160" show-overflow-tooltip />
<el-table-column label="必填" width="80" align="center"> <el-table-column label="必填" width="80" align="center">
<template #default="scope"> <template #default="scope">
@@ -407,31 +327,32 @@
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="150" fixed="right"> <el-table-column label="操作" width="120" align="center" fixed="right">
<template #default="scope"> <template #default="scope">
<el-button link type="primary" icon="Edit" @click="openVariableEdit(scope.row)"> <el-button link type="primary" icon="Edit" @click="openVariableEdit(scope.row)">
编辑 检查调整
</el-button>
<el-button link type="danger" icon="Delete" @click="removeVariable(scope.row)">
删除
</el-button> </el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</div>
<template #footer>
<el-button type="primary" @click="variablesVisible = false">关闭</el-button>
</template>
</el-dialog> </el-dialog>
<!-- 新增 / 编辑单个变量 --> <!-- 新增 / 编辑单个变量 -->
<el-dialog <el-dialog
v-model="variableVisible" v-model="variableVisible"
:title="variableEditing ? '编辑变量' : '新增变量'" title="检查并调整变量"
width="640px" width="640px"
destroy-on-close destroy-on-close
append-to-body append-to-body
> >
<el-form :model="variableForm" label-width="130px"> <el-form :model="variableForm" label-width="130px">
<el-form-item label="变量编码" required> <el-form-item label="变量编码" required>
<el-input v-model="variableForm.variableCode" placeholder="如:EMPLOYEE_NAME" /> <el-input v-model="variableForm.variableCode" disabled />
</el-form-item> </el-form-item>
<el-form-item label="变量名称" required> <el-form-item label="变量名称" required>
@@ -456,7 +377,7 @@
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item v-if="variableForm.variableType === 'employee'" label="员工字段"> <el-form-item v-if="['employee', 'company'].includes(variableForm.variableType || '')" label="取值字段">
<el-input v-model="variableForm.employeeField" placeholder="如:EmployeeName、IdCardNumber、Phone" /> <el-input v-model="variableForm.employeeField" placeholder="如:EmployeeName、IdCardNumber、Phone" />
</el-form-item> </el-form-item>
@@ -490,43 +411,48 @@
</template> </template>
<script setup lang="ts" name="onboardingContract"> <script setup lang="ts" name="onboardingContract">
import { ref, reactive, computed, onMounted } from "vue"; import { computed, reactive, ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import { Plus, Edit, Delete, Files, Upload, Setting, Download, Check, CopyDocument } from "@element-plus/icons-vue"; import { Check } from "@element-plus/icons-vue";
import { import {
onboardingTaskApi, onboardingTaskApi,
type OnboardingCompanyOption,
type OnboardingContractScope,
type OnboardingContractTemplate, type OnboardingContractTemplate,
type OnboardingContractVersion, type OnboardingContractVersion,
type OnboardingPositionOption,
type OnboardingTemplateVariable type OnboardingTemplateVariable
} from "@/api/modules/biz/onboarding"; } from "@/api/modules/biz/onboarding";
import type { ColumnProps, ProTableInstance } from "@/components/ProTable/interface"; import type { ColumnProps, ProTableInstance } from "@/components/ProTable/interface";
const templateTable = ref<ProTableInstance>(); const templateTable = ref<ProTableInstance>();
const companies = ref<OnboardingCompanyOption[]>([]);
const scopePositions = ref<OnboardingPositionOption[]>([]);
const selectedTemplate = ref<OnboardingContractTemplate>(); const selectedTemplate = ref<OnboardingContractTemplate>();
const selectedVersion = ref<OnboardingContractVersion>(); const selectedVersion = ref<OnboardingContractVersion>();
const drawerVisible = ref(false); const drawerVisible = ref(false);
const drawerActiveTab = ref("versions"); const drawerActiveTab = ref("versions");
const templateVisible = ref(false); const templateVisible = ref(false);
const templateEditing = ref(false); const templateEditing = ref(false);
const versions = ref<OnboardingContractVersion[]>([]); const versions = ref<OnboardingContractVersion[]>([]);
const versionVisible = ref(false); const versionVisible = ref(false);
const variablesVisible = ref(false); const variablesVisible = ref(false);
const variableEditing = ref(false);
const variables = ref<OnboardingTemplateVariable[]>([]); const variables = ref<OnboardingTemplateVariable[]>([]);
const variableVisible = ref(false); const variableVisible = ref(false);
const variableTab = ref("all");
const scopes = ref<OnboardingContractScope[]>([]); const filteredVariables = computed(() => {
if (variableTab.value === "all") return variables.value;
if (variableTab.value === "employee") return variables.value.filter(v => v.variableType === "employee");
if (variableTab.value === "hr") return variables.value.filter(v => v.variableType === "hr" || v.variableType === "contract" || v.variableCode.startsWith("hr."));
if (variableTab.value === "company") return variables.value.filter(v => v.variableType === "company");
if (variableTab.value === "system") return variables.value.filter(v => v.variableType === "system");
return variables.value;
});
const variableCounts = computed(() => {
const all = variables.value.length;
const employee = variables.value.filter(v => v.variableType === "employee").length;
const hr = variables.value.filter(v => v.variableType === "hr" || v.variableType === "contract" || v.variableCode.startsWith("hr.")).length;
const company = variables.value.filter(v => v.variableType === "company").length;
const system = variables.value.filter(v => v.variableType === "system").length;
return { all, employee, hr, company, system };
});
const emptyTemplate = (): Partial<OnboardingContractTemplate> => ({ const emptyTemplate = (): Partial<OnboardingContractTemplate> => ({
templateCode: "", templateCode: "",
@@ -547,50 +473,24 @@ const emptyVersion = (): Partial<OnboardingContractVersion> => ({
const emptyVariable = (): Partial<OnboardingTemplateVariable> => ({ const emptyVariable = (): Partial<OnboardingTemplateVariable> => ({
variableCode: "", variableCode: "",
variableName: "", variableName: "",
variableType: "employee", variableType: "unresolved",
dataType: "string", dataType: "string",
employeeField: "", employeeField: "",
defaultValue: "", defaultValue: "",
sortCode: 10, sortCode: 10,
required: true, required: true,
hrEditable: true, hrEditable: false,
remark: "" remark: ""
}); });
const emptyScope = (): Partial<OnboardingContractScope> => ({
companyId: undefined,
positionId: undefined,
templateVersionId: undefined
});
const templateForm = reactive<Partial<OnboardingContractTemplate>>(emptyTemplate()); const templateForm = reactive<Partial<OnboardingContractTemplate>>(emptyTemplate());
const versionForm = reactive<Partial<OnboardingContractVersion>>(emptyVersion()); const versionForm = reactive<Partial<OnboardingContractVersion>>(emptyVersion());
const variableForm = reactive<Partial<OnboardingTemplateVariable>>(emptyVariable()); const variableForm = reactive<Partial<OnboardingTemplateVariable>>(emptyVariable());
const scopeForm = reactive<Partial<OnboardingContractScope>>(emptyScope());
const enabledVersions = computed(() =>
versions.value.filter(item => item.versionStatus === "enabled")
);
const templateColumns: ColumnProps<OnboardingContractTemplate>[] = [ const templateColumns: ColumnProps<OnboardingContractTemplate>[] = [
{ { prop: "templateCode", label: "模板编码", minWidth: 160, search: { el: "input" } },
prop: "templateCode", { prop: "templateName", label: "模板名称", minWidth: 200, search: { el: "input" } },
label: "模板编码", { prop: "contractType", label: "合同类型", width: 140, search: { el: "input" } },
minWidth: 160,
search: { el: "input" }
},
{
prop: "templateName",
label: "模板名称",
minWidth: 200,
search: { el: "input" }
},
{
prop: "contractType",
label: "合同类型",
width: 140,
search: { el: "input" }
},
{ {
prop: "status", prop: "status",
label: "状态", label: "状态",
@@ -602,24 +502,10 @@ const templateColumns: ColumnProps<OnboardingContractTemplate>[] = [
], ],
search: { el: "select" } search: { el: "select" }
}, },
{ { prop: "description", label: "模板说明", minWidth: 200 },
prop: "description", { prop: "operation", label: "操作", fixed: "right", width: 260 }
label: "模板说明",
minWidth: 200
},
{
prop: "operation",
label: "操作",
fixed: "right",
width: 260
}
]; ];
function formatTime(timeStr?: string) {
if (!timeStr) return "-";
return timeStr.replace("T", " ").substring(0, 19);
}
function resetTemplate() { function resetTemplate() {
Object.assign(templateForm, emptyTemplate()); Object.assign(templateForm, emptyTemplate());
} }
@@ -628,19 +514,6 @@ function resetVersion() {
Object.assign(versionForm, emptyVersion()); Object.assign(versionForm, emptyVersion());
} }
function resetVariable() {
Object.assign(variableForm, emptyVariable());
}
function resetScope() {
Object.assign(scopeForm, emptyScope());
}
async function loadCompanies() {
const { data } = await onboardingTaskApi.listCompanies();
companies.value = data;
}
async function loadVersions() { async function loadVersions() {
if (!selectedTemplate.value) return; if (!selectedTemplate.value) return;
const { data } = await onboardingTaskApi.versionPage({ const { data } = await onboardingTaskApi.versionPage({
@@ -648,7 +521,7 @@ async function loadVersions() {
pageNum: 1, pageNum: 1,
pageSize: 100 pageSize: 100
}); });
versions.value = data.list; versions.value = data?.list || [];
} }
async function loadVariables() { async function loadVariables() {
@@ -658,23 +531,13 @@ async function loadVariables() {
pageNum: 1, pageNum: 1,
pageSize: 100 pageSize: 100
}); });
variables.value = data.list; variables.value = data?.list || [];
}
async function loadScopes() {
if (!selectedTemplate.value) return;
const { data } = await onboardingTaskApi.scopePage({
templateId: selectedTemplate.value.id,
pageNum: 1,
pageSize: 100
});
scopes.value = data.list;
} }
async function openManagementDrawer(row: OnboardingContractTemplate) { async function openManagementDrawer(row: OnboardingContractTemplate) {
selectedTemplate.value = row; selectedTemplate.value = row;
drawerActiveTab.value = "versions"; drawerActiveTab.value = "versions";
await Promise.all([loadVersions(), loadScopes()]); await loadVersions();
drawerVisible.value = true; drawerVisible.value = true;
} }
@@ -695,7 +558,6 @@ async function saveTemplate() {
if (!templateForm.templateCode || !templateForm.templateName || !templateForm.contractType) { if (!templateForm.templateCode || !templateForm.templateName || !templateForm.contractType) {
return ElMessage.warning("请填写模板编码、名称和合同类型"); return ElMessage.warning("请填写模板编码、名称和合同类型");
} }
await onboardingTaskApi.saveTemplate(templateForm, templateEditing.value); await onboardingTaskApi.saveTemplate(templateForm, templateEditing.value);
templateVisible.value = false; templateVisible.value = false;
templateTable.value?.refresh(); templateTable.value?.refresh();
@@ -723,10 +585,10 @@ function beforeDocxUpload(file: File) {
} }
async function uploadDocx(options: any) { async function uploadDocx(options: any) {
const form = new FormData(); const formData = new FormData();
form.append("file", options.file); formData.append("file", options.file);
try { try {
const { data } = await onboardingTaskApi.uploadDocx(form); const { data } = await onboardingTaskApi.uploadDocx(formData);
versionForm.sourceDocxFileId = data; versionForm.sourceDocxFileId = data;
options.onSuccess?.(data); options.onSuccess?.(data);
ElMessage.success("DOCX 文件上传成功"); ElMessage.success("DOCX 文件上传成功");
@@ -739,15 +601,10 @@ async function saveVersion() {
if (!selectedTemplate.value || !versionForm.versionNo || !versionForm.sourceDocxFileId) { if (!selectedTemplate.value || !versionForm.versionNo || !versionForm.sourceDocxFileId) {
return ElMessage.warning("请填写版本号并上传 DOCX 文件"); return ElMessage.warning("请填写版本号并上传 DOCX 文件");
} }
await onboardingTaskApi.addVersion({ ...versionForm, templateId: selectedTemplate.value.id });
await onboardingTaskApi.addVersion({
...versionForm,
templateId: selectedTemplate.value.id
});
versionVisible.value = false; versionVisible.value = false;
await loadVersions(); await loadVersions();
ElMessage.success("版本已保存"); ElMessage.success("版本已保存,DOCX 变量已自动识别,请进入变量列表检查后再启用");
} }
async function changeVersionStatus(row: OnboardingContractVersion, status: string) { async function changeVersionStatus(row: OnboardingContractVersion, status: string) {
@@ -756,6 +613,13 @@ async function changeVersionStatus(row: OnboardingContractVersion, status: strin
ElMessage.success(`版本状态已更新为:${versionText(status)}`); ElMessage.success(`版本状态已更新为:${versionText(status)}`);
} }
async function removeVersion(row: OnboardingContractVersion) {
await ElMessageBox.confirm(`确认删除合同版本“${row.versionNo}”?此操作不可撤销。`, "删除确认", { type: "warning" });
await onboardingTaskApi.deleteVersion(row.id);
await loadVersions();
ElMessage.success("版本删除成功");
}
function downloadDocx(fileId?: number) { function downloadDocx(fileId?: number) {
if (!fileId) return ElMessage.warning("暂无 DOCX 源文件"); if (!fileId) return ElMessage.warning("暂无 DOCX 源文件");
window.open(`/api/sys/dev/file/download?id=${fileId}`, "_blank"); window.open(`/api/sys/dev/file/download?id=${fileId}`, "_blank");
@@ -776,7 +640,8 @@ function variableTypeTag(type: string): EpTagType {
employee: "primary", employee: "primary",
company: "success", company: "success",
contract: "warning", contract: "warning",
system: "info" system: "info",
unresolved: "danger"
}; };
return map[type] || "info"; return map[type] || "info";
} }
@@ -786,7 +651,8 @@ function variableTypeText(type: string) {
employee: "员工填写", employee: "员工填写",
company: "公司信息", company: "公司信息",
contract: "人事合同参数", contract: "人事合同参数",
system: "系统自动" system: "系统自动",
unresolved: "待确认"
}; };
return map[type] || type; return map[type] || type;
} }
@@ -806,87 +672,22 @@ async function openVariables(row: OnboardingContractVersion) {
variablesVisible.value = true; variablesVisible.value = true;
} }
function openVariableCreate() {
variableEditing.value = false;
resetVariable();
variableVisible.value = true;
}
function openVariableEdit(row: OnboardingTemplateVariable) { function openVariableEdit(row: OnboardingTemplateVariable) {
variableEditing.value = true; Object.assign(variableForm, emptyVariable(), row);
Object.assign(variableForm, row);
variableVisible.value = true; variableVisible.value = true;
} }
async function saveVariable() { async function saveVariable() {
if ( if (!selectedVersion.value || !variableForm.id || !variableForm.variableCode || !variableForm.variableName || !variableForm.variableType || !variableForm.dataType) {
!selectedVersion.value || return ElMessage.warning("请完整确认变量名称、来源类型和数据类型");
!variableForm.variableCode ||
!variableForm.variableName ||
!variableForm.variableType ||
!variableForm.dataType
) {
return ElMessage.warning("请填写变量编码、名称、类型和数据类型");
} }
await onboardingTaskApi.editVariable(
await onboardingTaskApi.saveVariable( { ...variableForm, templateVersionId: selectedVersion.value.id }
{
...variableForm,
templateVersionId: selectedVersion.value.id
},
variableEditing.value
); );
variableVisible.value = false; variableVisible.value = false;
await loadVariables(); await loadVariables();
ElMessage.success("变量已保存"); ElMessage.success("变量已确认并保存");
} }
async function removeVariable(row: OnboardingTemplateVariable) {
await ElMessageBox.confirm(`确认删除变量“${row.variableName}”?`, "删除确认", { type: "warning" });
await onboardingTaskApi.deleteVariable([row.id]);
await loadVariables();
ElMessage.success("删除成功");
}
async function onScopeCompanyChange(companyId: number) {
scopeForm.positionId = undefined;
const { data } = await onboardingTaskApi.listPositions(companyId);
scopePositions.value = data;
}
async function saveScope() {
if (!selectedTemplate.value || !scopeForm.companyId || !scopeForm.templateVersionId) {
return ElMessage.warning("请选择公司和已启用的合同版本");
}
await onboardingTaskApi.addScope({
...scopeForm,
templateId: selectedTemplate.value.id
});
await loadScopes();
resetScope();
ElMessage.success("适用范围已添加");
}
async function removeScope(row: OnboardingContractScope) {
await ElMessageBox.confirm("确认删除该适用范围?", "删除确认", { type: "warning" });
await onboardingTaskApi.deleteScope([row.id]);
await loadScopes();
ElMessage.success("删除成功");
}
const companyName = (id: number) => companies.value.find(c => c.id === id)?.companyName || id;
const positionName = (id?: number) => {
if (!id) return "全公司 (所有岗位)";
return scopePositions.value.find(p => p.id === id)?.positionName || id;
};
const versionName = (id: number) => versions.value.find(v => v.id === id)?.versionNo || id;
onMounted(loadCompanies);
</script> </script>
<style scoped> <style scoped>
@@ -972,4 +773,38 @@ onMounted(loadCompanies);
display: flex; display: flex;
align-items: center; align-items: center;
} }
.table-actions {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
white-space: nowrap;
}
.variable-filter-tabs {
margin-bottom: 12px;
}
.variables-table-wrapper {
border-radius: 6px;
overflow: hidden;
}
.variable-code-cell {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
white-space: nowrap;
}
.code-font {
font-family: Consolas, Monaco, monospace;
font-weight: 600;
color: #2563eb;
background: #eff6ff;
padding: 2px 6px;
border-radius: 4px;
}
</style> </style>
@@ -0,0 +1,524 @@
<template>
<div class="template-panel">
<div class="toolbar">
<el-input v-model="query.keyword" clearable placeholder="搜索模板编码或名称" class="keyword-input"
@keyup.enter="loadTemplates" />
<el-select v-model="query.companyId" clearable filterable placeholder="全部公司" class="company-filter">
<el-option v-for="item in companies" :key="item.id" :label="item.companyName" :value="item.id" />
</el-select>
<el-button type="primary" @click="loadTemplates">查询</el-button>
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
<div class="toolbar-spacer" />
<el-button type="primary" icon="Plus" @click="openCreate">新增入职模板</el-button>
</div>
<el-alert title="入职模板统一管理公司、岗位、合同文件与员工上传材料;员工入职档案自动采用标准《入职登记表》。" type="info" :closable="false" show-icon
class="tip" />
<div class="table-container">
<el-table v-loading="loading" :data="rows" border stripe height="100%">
<el-table-column prop="templateCode" label="模板编码" min-width="150" />
<el-table-column prop="templateName" label="模板名称" min-width="180" />
<el-table-column prop="companyName" label="所属公司" min-width="160" />
<el-table-column label="适用岗位" min-width="220">
<template #default="scope">
<el-space wrap>
<el-tag v-for="name in scope.row.positionNames" :key="name" size="small">{{ name }}</el-tag>
</el-space>
</template>
</el-table-column>
<el-table-column label="配置内容" min-width="200">
<template #default="scope">
合同 {{ scope.row.contractCount || 0 }} 份 / 材料 {{ scope.row.materialCount || 0 }} 项
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="90" align="center">
<template #default="scope">
<el-tag :type="scope.row.status === 'enable' ? 'success' : 'info'">
{{ scope.row.status === "enable" ? "启用" : "停用" }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="scope">
<el-button link type="primary" @click="openEdit(scope.row)">编辑</el-button>
<el-button link type="danger" @click="removeTemplate(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="pagination">
<el-pagination v-model:current-page="query.pageNum" v-model:page-size="query.pageSize"
layout="total, sizes, prev, pager, next" :total="total" @current-change="loadTemplates"
@size-change="loadTemplates" />
</div>
<!-- 模板编辑大弹窗 -->
<el-dialog v-model="visible" :title="form.id ? '编辑入职模板' : '新增入职模板'" width="62%" top="3vh" destroy-on-close
append-to-body class="template-dialog">
<div class="template-dialog-body">
<el-form label-width="100px" label-position="top">
<!-- 1. 基本信息卡片 -->
<div class="form-block-card">
<div class="block-card-header">
<span class="block-title">基本信息</span>
<span class="block-subtitle">配置模板编码、名称、归属公司及适用岗位范围</span>
</div>
<el-row :gutter="20">
<el-col :xs="24" :sm="8">
<el-form-item label="模板编码" required>
<el-input v-model="form.templateCode" placeholder="如:FULLTIME_DEV" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8">
<el-form-item label="模板名称" required>
<el-input v-model="form.templateName" placeholder="如:全职技术人员入职模板" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8">
<el-form-item label="状态" required>
<el-radio-group v-model="form.status">
<el-radio value="enable">启用</el-radio>
<el-radio value="disable">停用</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="10">
<el-form-item label="所属公司" required>
<el-select v-model="form.companyId" filterable class="full-width" placeholder="请选择公司"
@change="onCompanyChange">
<el-option v-for="item in companies" :key="item.id" :label="item.companyName" :value="item.id" />
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="14">
<el-form-item label="适用岗位" required>
<el-select v-model="form.positionIds" multiple filterable collapse-tags collapse-tags-tooltip
class="full-width" :disabled="!form.companyId" placeholder="请选择适用的岗位(可多选)">
<el-option v-for="item in positions" :key="item.id" :label="item.positionName" :value="item.id" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="模板说明">
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="填写该入职模板的适用范围或注意事项" />
</el-form-item>
</el-col>
</el-row>
</div>
<!-- 2. 关联合同文件卡片 -->
<div class="form-block-card">
<div class="block-card-header flex-between">
<div>
<span class="block-title">关联合同文件</span>
<span class="block-subtitle">选择该岗位入职时需要员工确认与签署的合同模板及版本</span>
</div>
<el-button type="primary" plain icon="Plus" size="small" @click="addContract">
添加合同文件
</el-button>
</div>
<el-table :data="form.contracts" border empty-text="暂无关联合同,请点击右上角添加" class="mt-2">
<el-table-column label="合同模板" min-width="220">
<template #default="scope">
<el-select v-model="scope.row.contractTemplateId" filterable class="full-width" placeholder="选择合同模板"
@change="onContractTemplateChange(scope.row)">
<el-option v-for="item in contractTemplates" :key="item.id" :label="item.templateName"
:value="item.id" />
</el-select>
</template>
</el-table-column>
<el-table-column label="启用版本" min-width="160">
<template #default="scope">
<el-select v-model="scope.row.contractTemplateVersionId" class="full-width" placeholder="选择版本"
:disabled="!scope.row.contractTemplateId">
<el-option v-for="item in versionOptions[scope.row.contractTemplateId] || []" :key="item.id"
:label="item.versionNo" :value="item.id" />
</el-select>
</template>
</el-table-column>
<el-table-column label="必须确认" width="100" align="center">
<template #default="scope">
<el-switch v-model="scope.row.required" />
</template>
</el-table-column>
<el-table-column label="打印份数" width="130" align="center">
<template #default="scope">
<el-input-number v-model="scope.row.printCopies" :min="1" :max="10" controls-position="right"
style="width: 100px;" />
</template>
</el-table-column>
<el-table-column label="排序" width="130" align="center">
<template #default="scope">
<el-input-number v-model="scope.row.sortCode" :min="0" controls-position="right"
style="width: 100px;" />
</template>
</el-table-column>
<el-table-column label="操作" width="90" align="center" fixed="right">
<template #default="scope">
<el-button link type="danger" icon="Delete" style="white-space: nowrap;"
@click="form.contracts.splice(scope.$index, 1)">
移除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- 3. 员工上传材料卡片 -->
<div class="form-block-card">
<div class="block-card-header">
<span class="block-title">员工上传材料要求</span>
<span class="block-subtitle">勾选入职时要求候选人必须或可选上传的证件材料(如身份证、毕业证、体检表等)</span>
</div>
<el-table :data="materialTypes" border stripe height="240px" class="mt-2">
<el-table-column label="选择要求" width="85" align="center">
<template #default="scope">
<el-checkbox :model-value="hasMaterial(scope.row.id)"
@change="value => toggleMaterial(scope.row.id, Boolean(value))" />
</template>
</el-table-column>
<el-table-column prop="materialCode" label="材料编码" min-width="160" />
<el-table-column prop="materialName" label="材料名称" min-width="220" />
<el-table-column prop="maxFileCount" label="最多文件" width="100" align="center" />
<el-table-column label="必传" width="100" align="center">
<template #default="scope">
<el-switch v-if="hasMaterial(scope.row.id)" v-model="materialConfig(scope.row.id)!.required" />
<span v-else class="text-gray">-</span>
</template>
</el-table-column>
<el-table-column label="排序" width="130" align="center">
<template #default="scope">
<el-input-number v-if="hasMaterial(scope.row.id)" v-model="materialConfig(scope.row.id)!.sortCode"
:min="0" controls-position="right" style="width: 100px;" />
<span v-else class="text-gray">-</span>
</template>
</el-table-column>
</el-table>
</div>
</el-form>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="save">保存入职模板</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import {
OnboardingCompany,
OnboardingContractTemplate,
OnboardingContractVersion,
OnboardingMaterialType,
OnboardingPosition,
OnboardingTemplate,
onboardingTaskApi
} from "@/api/modules/biz/onboarding";
const loading = ref(false);
const saving = ref(false);
const visible = ref(false);
const rows = ref<OnboardingTemplate[]>([]);
const total = ref(0);
const companies = ref<OnboardingCompany[]>([]);
const positions = ref<OnboardingPosition[]>([]);
const contractTemplates = ref<OnboardingContractTemplate[]>([]);
const materialTypes = ref<OnboardingMaterialType[]>([]);
const versionOptions = reactive<Record<number, OnboardingContractVersion[]>>({});
const query = reactive({
keyword: "",
companyId: undefined as number | undefined,
pageNum: 1,
pageSize: 20
});
const form = reactive<OnboardingTemplate>({
id: 0,
templateCode: "",
templateName: "",
companyId: 0,
description: "",
status: "enable",
positionIds: [],
contracts: [],
formFields: [],
materials: []
});
async function loadBaseData() {
const [companyRes, templateRes, materialRes] = await Promise.all([
onboardingTaskApi.companyPage({ pageNum: 1, pageSize: 100, status: "enable" }).catch(() => ({ data: { list: [] } })),
onboardingTaskApi.templatePage({ pageNum: 1, pageSize: 100, status: "enable" }).catch(() => ({ data: { list: [] } })),
onboardingTaskApi.listMaterialTypes().catch(() => ({ data: [] }))
]);
companies.value = companyRes.data?.list || [];
contractTemplates.value = templateRes.data?.list || [];
materialTypes.value = materialRes.data || [];
}
async function loadTemplates() {
loading.value = true;
try {
const res = await onboardingTaskApi.onboardingTemplatePage(query);
rows.value = res.data?.list || [];
total.value = res.data?.total || 0;
} finally {
loading.value = false;
}
}
function resetQuery() {
query.keyword = "";
query.companyId = undefined;
query.pageNum = 1;
loadTemplates();
}
async function onCompanyChange(companyId: number) {
form.positionIds = [];
if (!companyId) {
positions.value = [];
return;
}
const res = await onboardingTaskApi.positionPage({ companyId, pageNum: 1, pageSize: 100, status: "enable" });
positions.value = res.data?.list || [];
}
async function onContractTemplateChange(row: any) {
row.contractTemplateVersionId = undefined;
if (!row.contractTemplateId) return;
await loadContractVersions(row.contractTemplateId);
}
async function loadContractVersions(templateId: number) {
if (versionOptions[templateId]) return;
const res = await onboardingTaskApi.versionPage({ templateId, pageNum: 1, pageSize: 100 });
versionOptions[templateId] = (res.data?.list || []).filter((it: any) => it.versionStatus === "enabled");
}
function addContract() {
form.contracts.push({
contractTemplateId: undefined as any,
contractTemplateVersionId: undefined as any,
required: true,
printCopies: 1,
sortCode: (form.contracts.length + 1) * 10
});
}
function hasMaterial(materialTypeId: number) {
return form.materials.some(item => item.materialTypeId === materialTypeId);
}
function materialConfig(materialTypeId: number) {
return form.materials.find(item => item.materialTypeId === materialTypeId);
}
function toggleMaterial(materialTypeId: number, checked: boolean) {
if (checked) {
if (!hasMaterial(materialTypeId)) {
form.materials.push({ materialTypeId, required: true, sortCode: (form.materials.length + 1) * 10 });
}
} else {
form.materials = form.materials.filter(item => item.materialTypeId !== materialTypeId);
}
}
async function openCreate() {
await loadBaseData();
Object.assign(form, {
id: undefined,
templateCode: "",
templateName: "",
companyId: undefined as any,
description: "",
status: "enable",
positionIds: [],
contracts: [],
formFields: [],
materials: []
});
positions.value = [];
visible.value = true;
}
async function openEdit(row: OnboardingTemplate) {
await loadBaseData();
const res = await onboardingTaskApi.onboardingTemplateDetail({ id: row.id! });
const detail = res.data;
Object.assign(form, {
id: detail.id,
templateCode: detail.templateCode,
templateName: detail.templateName,
companyId: detail.companyId,
description: detail.description,
status: detail.status,
positionIds: detail.positionIds || [],
contracts: (detail.contracts || []).map((item: any) => ({
contractTemplateId: item.contractTemplateId,
contractTemplateVersionId: item.contractTemplateVersionId,
required: item.required,
printCopies: item.printCopies || 1,
sortCode: item.sortCode || 10
})),
formFields: [],
materials: (detail.materials || []).map((item: any) => ({
materialTypeId: item.materialTypeId,
required: item.required,
sortCode: item.sortCode || 10
}))
});
if (form.companyId) {
const posRes = await onboardingTaskApi.positionPage({ companyId: form.companyId, pageNum: 1, pageSize: 100, status: "enable" });
positions.value = posRes.data?.list || [];
}
for (const contract of form.contracts) {
if (contract.contractTemplateId) {
await loadContractVersions(contract.contractTemplateId);
}
}
visible.value = true;
}
async function save() {
if (!form.templateCode || !form.templateName || !form.companyId || !form.positionIds.length) {
return ElMessage.warning("请完整填写模板编码、名称、公司及适用岗位");
}
if (!form.contracts.length || form.contracts.some(item => !item.contractTemplateId || !item.contractTemplateVersionId)) {
return ElMessage.warning("请完整选择至少一份合同模板及版本");
}
saving.value = true;
try {
await onboardingTaskApi.saveOnboardingTemplate(form);
ElMessage.success("入职模板保存成功");
visible.value = false;
await loadTemplates();
} finally {
saving.value = false;
}
}
async function removeTemplate(row: OnboardingTemplate) {
await ElMessageBox.confirm(`确认删除入职模板“${row.templateName}”吗?`, "删除确认", { type: "warning" });
await onboardingTaskApi.deleteOnboardingTemplate([row.id!]);
ElMessage.success("删除成功");
query.pageNum = 1;
await loadTemplates();
}
onMounted(async () => {
await Promise.all([loadBaseData(), loadTemplates()]);
});
</script>
<style scoped>
.template-panel {
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
overflow: hidden;
}
.toolbar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
flex-shrink: 0;
}
.keyword-input {
width: 220px;
}
.company-filter {
width: 180px;
}
.toolbar-spacer {
flex: 1;
}
.tip {
margin-bottom: 10px;
flex-shrink: 0;
}
.table-container {
flex: 1;
height: 0;
overflow: hidden;
}
.pagination {
margin-top: 12px;
display: flex;
justify-content: flex-end;
flex-shrink: 0;
}
.full-width {
width: 100%;
}
.mt-2 {
margin-top: 8px;
}
.text-gray {
color: #94a3b8;
}
/* 弹窗内部卡片样式 */
.template-dialog-body {
max-height: 72vh;
overflow-y: auto;
padding-right: 6px;
}
.form-block-card {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 16px 18px;
margin-bottom: 16px;
}
.block-card-header {
margin-bottom: 14px;
}
.flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
.block-title {
font-size: 15px;
font-weight: 700;
color: #0f172a;
margin-right: 10px;
}
.block-subtitle {
font-size: 12px;
color: #64748b;
}
</style>
@@ -0,0 +1,359 @@
<template>
<div class="variable-panel">
<div class="toolbar">
<el-input
v-model="query.keyword"
placeholder="搜索变量编码 / 变量名称"
clearable
style="width: 240px"
@keyup.enter="loadData"
@clear="loadData"
/>
<el-select
v-model="query.variableCategory"
placeholder="变量分类"
clearable
style="width: 180px"
@change="loadData"
>
<el-option label="全部变量分类" value="" />
<el-option label="员工登记表变量 (employee)" value="employee" />
<el-option label="人事合同参数 (hr)" value="hr" />
<el-option label="公司信息变量 (company)" value="company" />
<el-option label="系统预置变量 (system)" value="system" />
</el-select>
<el-button type="primary" icon="Search" @click="loadData">查询</el-button>
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
<div class="spacer"></div>
<el-button type="primary" icon="Plus" @click="openCreate">新增自定义变量</el-button>
</div>
<el-alert
title="变量字典库定义了合同 DOCX 中所有合法的占位符(如 {{employee.EmployeeName}})。上传 Word 时系统会自动校验占位符是否在此字典中,防止拼写错误。"
type="info"
show-icon
:closable="false"
class="tip-alert"
/>
<div class="table-container">
<el-table :data="tableData" border stripe height="100%" v-loading="loading">
<el-table-column prop="variableCode" label="变量全编码 (DOCX占位符)" min-width="260">
<template #default="{ row }">
<div class="code-col">
<el-tag :type="categoryTag(row.variableCategory)" effect="light" size="small" class="tag-badge">
{{ categoryText(row.variableCategory) }}
</el-tag>
<code class="code-font">{{ formatPlaceholder(row.variableCode) }}</code>
</div>
</template>
</el-table-column>
<el-table-column prop="variableName" label="中文显示名称" min-width="160" />
<el-table-column prop="dataType" label="数据类型" width="100" align="center">
<template #default="{ row }">
<el-tag size="small" type="info">{{ row.dataType || 'string' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="isBuiltIn" label="内置标准" width="120" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.isBuiltIn ? 'success' : 'warning'">
{{ row.isBuiltIn ? '入职登记表' : '自定义' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="required" label="必填" width="80" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.required ? 'danger' : 'info'">
{{ row.required ? '是' : '否' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="remark" label="说明备注" min-width="200" show-overflow-tooltip />
<el-table-column label="操作" width="140" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" icon="Edit" @click="openEdit(row)">编辑</el-button>
<el-button
v-if="!row.isBuiltIn"
link
type="danger"
icon="Delete"
@click="remove(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="pagination-box">
<el-pagination
v-model:current-page="query.pageNum"
v-model:page-size="query.pageSize"
:total="total"
:page-sizes="[20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="loadData"
@current-change="loadData"
/>
</div>
<!-- 弹窗 -->
<el-dialog v-model="visible" :title="form.id ? '编辑变量' : '新增自定义变量'" width="560px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
<el-form-item label="变量分类" prop="variableCategory" required>
<el-select v-model="form.variableCategory" :disabled="form.isBuiltIn" style="width: 100%">
<el-option label="员工变量 (employee.)" value="employee" />
<el-option label="人事合同参数 (hr.)" value="hr" />
<el-option label="公司信息 (company.)" value="company" />
<el-option label="系统变量 (system.)" value="system" />
</el-select>
</el-form-item>
<el-form-item label="变量全编码" prop="variableCode" required>
<el-input
v-model="form.variableCode"
placeholder="如 employee.WeChat 或 hr.SpecialSubsidy"
:disabled="Boolean(form.id)"
/>
<div class="form-tip">规范格式:分类.变量名,例如 employee.CarNo</div>
</el-form-item>
<el-form-item label="中文名称" prop="variableName" required>
<el-input v-model="form.variableName" placeholder="如 微信号、特殊津贴" />
</el-form-item>
<el-form-item label="数据类型">
<el-select v-model="form.dataType" style="width: 100%">
<el-option label="文本 (string)" value="string" />
<el-option label="数字 / 金额 (number)" value="number" />
<el-option label="日期 (date)" value="date" />
</el-select>
</el-form-item>
<el-form-item label="默认值">
<el-input v-model="form.defaultValue" placeholder="未填写时的默认值" />
</el-form-item>
<el-form-item label="是否必填">
<el-switch v-model="form.required" />
</el-form-item>
<el-form-item label="排序码">
<el-input-number v-model="form.sortCode" :min="1" :max="9999" />
</el-form-item>
<el-form-item label="说明备注">
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="变量业务说明" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from "vue";
import { ElMessage, ElMessageBox, FormInstance, FormRules } from "element-plus";
import { onboardingTaskApi } from "@/api/modules/biz/onboarding";
const loading = ref(false);
const saving = ref(false);
const visible = ref(false);
const tableData = ref<any[]>([]);
const total = ref(0);
const formRef = ref<FormInstance>();
const query = reactive({
keyword: "",
variableCategory: "",
pageNum: 1,
pageSize: 50
});
const form = reactive<any>({
id: undefined,
variableCode: "",
variableName: "",
variableCategory: "employee",
dataType: "string",
defaultValue: "",
isBuiltIn: false,
required: false,
sortCode: 100,
remark: ""
});
const rules: FormRules = {
variableCode: [{ required: true, message: "请输入变量编码", trigger: "blur" }],
variableName: [{ required: true, message: "请输入中文显示名称", trigger: "blur" }],
variableCategory: [{ required: true, message: "请选择分类", trigger: "change" }]
};
function categoryTag(cat: string) {
switch (cat) {
case "employee": return "primary";
case "hr": return "warning";
case "company": return "success";
default: return "info";
}
}
function categoryText(cat: string) {
switch (cat) {
case "employee": return "员工变量";
case "hr": return "人事参数";
case "company": return "公司信息";
default: return "系统变量";
}
}
function formatPlaceholder(code?: string) {
return code ? `{{${code}}}` : "";
}
async function loadData() {
loading.value = true;
try {
const res = await onboardingTaskApi.variableDefinitionPage(query);
tableData.value = res.data?.list || [];
total.value = res.data?.total || 0;
} finally {
loading.value = false;
}
}
function resetQuery() {
query.keyword = "";
query.variableCategory = "";
query.pageNum = 1;
loadData();
}
function openCreate() {
Object.assign(form, {
id: undefined,
variableCode: "",
variableName: "",
variableCategory: "employee",
dataType: "string",
defaultValue: "",
isBuiltIn: false,
required: false,
sortCode: 100,
remark: ""
});
visible.value = true;
}
function openEdit(row: any) {
Object.assign(form, {
id: row.id,
variableCode: row.variableCode,
variableName: row.variableName,
variableCategory: row.variableCategory,
dataType: row.dataType || "string",
defaultValue: row.defaultValue || "",
isBuiltIn: row.isBuiltIn || false,
required: row.required || false,
sortCode: row.sortCode || 100,
remark: row.remark || ""
});
visible.value = true;
}
async function save() {
await formRef.value?.validate();
saving.value = true;
try {
if (form.id) {
await onboardingTaskApi.editVariableDefinition(form);
ElMessage.success("变量修改成功");
} else {
await onboardingTaskApi.addVariableDefinition(form);
ElMessage.success("自定义变量添加成功");
}
visible.value = false;
loadData();
} finally {
saving.value = false;
}
}
async function remove(row: any) {
await ElMessageBox.confirm(`确认删除自定义变量“${row.variableCode}”?`, "删除确认", { type: "warning" });
await onboardingTaskApi.deleteVariableDefinitions([row.id]);
ElMessage.success("删除成功");
loadData();
}
onMounted(loadData);
</script>
<style scoped>
.variable-panel {
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
overflow: hidden;
}
.toolbar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
flex-shrink: 0;
}
.spacer {
flex: 1;
}
.tip-alert {
margin-bottom: 10px;
flex-shrink: 0;
}
.table-container {
flex: 1;
height: 0;
overflow: hidden;
}
.code-col {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.tag-badge {
flex-shrink: 0;
}
.code-font {
font-family: Consolas, Monaco, monospace;
font-weight: 600;
color: #b45309;
background: #fef3c7;
padding: 2px 6px;
border-radius: 4px;
white-space: nowrap;
}
.pagination-box {
display: flex;
justify-content: flex-end;
margin-top: 12px;
flex-shrink: 0;
}
.form-tip {
font-size: 12px;
color: #94a3b8;
margin-top: 4px;
}
</style>
+73 -502
View File
@@ -1,58 +1,24 @@
<template> <template>
<div class="main-box config-container"> <div class="main-box config-container">
<el-tabs v-model="activeTab" class="config-tabs"> <el-tabs v-model="activeTab" class="config-tabs">
<el-tab-pane label="动态填写字段" name="fields"> <el-tab-pane label="合同变量字典" name="variables">
<div class="tab-card-wrapper"> <div class="tab-card-wrapper">
<div class="toolbar"> <VariableDefinitionPanel />
<el-button type="primary" icon="Plus" @click="openFieldCreate">
新增字段
</el-button>
</div>
<el-table :data="fields" border stripe>
<el-table-column prop="fieldCode" label="字段编码" min-width="140" />
<el-table-column prop="fieldName" label="字段名称" min-width="150" />
<el-table-column prop="dataType" label="数据类型" width="120" align="center">
<template #default="scope">
<el-tag size="small" type="info">{{ scope.row.dataType }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="placeholder" label="提示语" min-width="160" />
<el-table-column prop="status" label="状态" width="100" align="center">
<template #default="scope">
<el-tag size="small" :type="scope.row.status === 'enable' ? 'success' : 'info'">
{{ scope.row.status === 'enable' ? '启用' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="scope">
<el-button link type="primary" icon="Edit" @click="openFieldEdit(scope.row)">
编辑
</el-button>
<el-button link type="danger" icon="Delete" @click="removeField(scope.row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div> </div>
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="上传资料类型" name="materials"> <el-tab-pane label="上传资料类型" name="materials">
<div class="tab-card-wrapper"> <div class="tab-card-wrapper material-tab-wrapper">
<div class="toolbar"> <div class="toolbar">
<el-button type="primary" icon="Plus" @click="openMaterialCreate"> <el-button type="primary" icon="Plus" @click="openMaterialCreate">
新增资料类型 新增资料类型
</el-button> </el-button>
</div> </div>
<el-table :data="materialTypes" border stripe> <el-table :data="materialTypes" border stripe height="100%">
<el-table-column prop="materialCode" label="资料编码" min-width="140" /> <el-table-column prop="materialCode" label="资料编码" min-width="140" />
<el-table-column prop="materialName" label="资料名称" min-width="150" /> <el-table-column prop="materialName" label="资料名称" min-width="150" />
<el-table-column prop="allowedExtensions" label="允许扩展名" min-width="140"> <el-table-column prop="allowedExtensions" label="允许扩展名" min-width="160">
<template #default="scope"> <template #default="scope">
<code>{{ scope.row.allowedExtensions || '不限' }}</code> <code>{{ scope.row.allowedExtensions || '不限' }}</code>
</template> </template>
@@ -67,7 +33,7 @@
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="150" fixed="right"> <el-table-column label="操作" width="150" fixed="right" align="center">
<template #default="scope"> <template #default="scope">
<el-button link type="primary" icon="Edit" @click="openMaterialEdit(scope.row)"> <el-button link type="primary" icon="Edit" @click="openMaterialEdit(scope.row)">
编辑 编辑
@@ -82,161 +48,16 @@
</div> </div>
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="公司岗位要求" name="requirements"> <el-tab-pane label="入职模板" name="templates">
<div class="tab-card-wrapper"> <div class="tab-card-wrapper">
<el-alert title="请先选择公司,再为全公司或某个岗位分别配置员工必填信息和上传资料。" type="info" :closable="false" class="hint" /> <OnboardingTemplatePanel />
<el-select v-model="selectedCompanyId" placeholder="请选择公司" filterable @change="loadRequirements">
<el-option v-for="company in companies" :key="company.id" :label="company.companyName" :value="company.id" />
</el-select>
<template v-if="selectedCompanyId">
<el-divider />
<el-tabs v-model="requirementTab">
<el-tab-pane label="填写字段要求" name="form">
<div class="toolbar">
<el-button type="primary" @click="openRequirementCreate('form')">
新增填写要求
</el-button>
</div>
<el-table :data="formRequirements" border>
<el-table-column label="字段">
<template #default="scope">
{{ fieldName(scope.row.fieldId) }}
</template>
</el-table-column>
<el-table-column label="岗位">
<template #default="scope">
{{ positionName(scope.row.positionId) }}
</template>
</el-table-column>
<el-table-column label="必填">
<template #default="scope">
{{ scope.row.required ? "是" : "否" }}
</template>
</el-table-column>
<el-table-column prop="sortCode" label="排序" />
<el-table-column label="操作" width="140">
<template #default="scope">
<el-button link type="primary" @click="openRequirementEdit('form', scope.row)">
编辑
</el-button>
<el-button link type="danger" @click="removeRequirement('form', scope.row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="上传资料要求" name="material">
<div class="toolbar">
<el-button type="primary" @click="openRequirementCreate('material')">
新增资料要求
</el-button>
</div>
<el-table :data="materialRequirements" border>
<el-table-column label="资料类型">
<template #default="scope">
{{ materialName(scope.row.materialTypeId) }}
</template>
</el-table-column>
<el-table-column label="岗位">
<template #default="scope">
{{ positionName(scope.row.positionId) }}
</template>
</el-table-column>
<el-table-column label="必传">
<template #default="scope">
{{ scope.row.required ? "是" : "否" }}
</template>
</el-table-column>
<el-table-column prop="sortCode" label="排序" />
<el-table-column label="操作" width="140">
<template #default="scope">
<el-button link type="primary" @click="openRequirementEdit('material', scope.row)">
编辑
</el-button>
<el-button link type="danger" @click="removeRequirement('material', scope.row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
</template>
</div> </div>
</el-tab-pane> </el-tab-pane>
</el-tabs> </el-tabs>
<el-dialog v-model="fieldVisible" :title="fieldForm.id ? '编辑字段' : '新增字段'" width="560px" destroy-on-close> <!-- 资料类型弹窗 -->
<el-form :model="fieldForm" label-width="110px">
<el-form-item label="字段编码" required>
<el-input v-model="fieldForm.fieldCode" />
</el-form-item>
<el-form-item label="字段名称" required>
<el-input v-model="fieldForm.fieldName" />
</el-form-item>
<el-form-item label="数据类型" required>
<el-select v-model="fieldForm.dataType">
<el-option label="文本" value="string" />
<el-option label="日期" value="date" />
<el-option label="整数" value="int" />
<el-option label="数字" value="number" />
<el-option label="金额" value="decimal" />
<el-option label="下拉选择" value="select" />
</el-select>
</el-form-item>
<el-form-item label="提示语">
<el-input v-model="fieldForm.placeholder" />
</el-form-item>
<el-form-item label="选项JSON">
<el-input v-model="fieldForm.optionsJson" type="textarea" placeholder="下拉/多选字段可填写选项JSON" />
</el-form-item>
<el-form-item label="校验规则">
<el-input v-model="fieldForm.validationRule" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="fieldForm.status">
<el-option label="启用" value="enable" />
<el-option label="停用" value="disable" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="fieldVisible = false">
取消
</el-button>
<el-button type="primary" @click="saveField">
保存
</el-button>
</template>
</el-dialog>
<el-dialog v-model="materialVisible" :title="materialForm.id ? '编辑资料类型' : '新增资料类型'" width="560px" destroy-on-close> <el-dialog v-model="materialVisible" :title="materialForm.id ? '编辑资料类型' : '新增资料类型'" width="560px" destroy-on-close>
<el-form :model="materialForm" label-width="120px"> <el-form :model="materialForm" label-width="110px">
<el-form-item label="资料编码" required> <el-form-item label="资料编码" required>
<el-input v-model="materialForm.materialCode" /> <el-input v-model="materialForm.materialCode" />
</el-form-item> </el-form-item>
@@ -246,219 +67,72 @@
</el-form-item> </el-form-item>
<el-form-item label="允许扩展名"> <el-form-item label="允许扩展名">
<el-input v-model="materialForm.allowedExtensions" placeholder="如:jpg,png,pdf" /> <el-input v-model="materialForm.allowedExtensions" placeholder="逗号分隔,如:png,jpg,jpeg,pdf" />
</el-form-item> </el-form-item>
<el-form-item label="最大文件数量"> <el-form-item label="最大文件数">
<el-input-number v-model="materialForm.maxFileCount" :min="1" /> <el-input-number v-model="materialForm.maxFileCount" :min="1" :max="10" />
</el-form-item> </el-form-item>
<el-form-item label="最大大小(MB)"> <el-form-item label="单文件大小(MB)">
<el-input-number v-model="materialForm.maxFileSizeMb" :min="1" /> <el-input-number v-model="materialForm.maxFileSizeMb" :min="1" :max="100" />
</el-form-item> </el-form-item>
<el-form-item label="状态"> <el-form-item label="状态">
<el-select v-model="materialForm.status"> <el-radio-group v-model="materialForm.status">
<el-option label="启用" value="enable" /> <el-radio label="enable">启用</el-radio>
<el-option label="停用" value="disable" /> <el-radio label="disable">停用</el-radio>
</el-select> </el-radio-group>
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="materialVisible = false"> <el-button @click="materialVisible = false">取消</el-button>
取消 <el-button type="primary" @click="saveMaterial">保存</el-button>
</el-button>
<el-button type="primary" @click="saveMaterial">
保存
</el-button>
</template>
</el-dialog>
<el-dialog v-model="requirementVisible" :title="requirementKind === 'form' ? '填写字段要求' : '上传资料要求'" width="560px"
destroy-on-close>
<el-form :model="requirementForm" label-width="110px">
<el-form-item label="所属公司">
<el-select v-model="requirementForm.companyId" disabled>
<el-option v-for="company in companies" :key="company.id" :label="company.companyName"
:value="company.id" />
</el-select>
</el-form-item>
<el-form-item label="适用岗位">
<el-select v-model="requirementForm.positionId" clearable placeholder="不选表示全公司">
<el-option v-for="position in positions" :key="position.id" :label="position.positionName"
:value="position.id" />
</el-select>
</el-form-item>
<el-form-item v-if="requirementKind === 'form'" label="填写字段" required>
<el-select v-model="requirementForm.fieldId">
<el-option v-for="field in enabledFields" :key="field.id" :label="field.fieldName" :value="field.id" />
</el-select>
</el-form-item>
<el-form-item v-else label="资料类型" required>
<el-select v-model="requirementForm.materialTypeId">
<el-option v-for="material in enabledMaterials" :key="material.id" :label="material.materialName"
:value="material.id" />
</el-select>
</el-form-item>
<el-form-item label="是否必填/必传">
<el-switch v-model="requirementForm.required" />
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="requirementForm.sortCode" :min="0" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="requirementVisible = false">
取消
</el-button>
<el-button type="primary" @click="saveRequirement">
保存
</el-button>
</template> </template>
</el-dialog> </el-dialog>
</div> </div>
</template> </template>
<script setup lang="ts" name="onboardingEntryConfig"> <script setup lang="ts" name="onboardingEntryConfig">
import { onMounted, reactive, ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { import {
onboardingTaskApi, OnboardingMaterialType,
type OnboardingCompanyOption, onboardingTaskApi
type OnboardingFormField,
type OnboardingFormRequirement,
type OnboardingMaterialRequirement,
type OnboardingMaterialType,
type OnboardingPositionOption
} from "@/api/modules/biz/onboarding"; } from "@/api/modules/biz/onboarding";
import OnboardingTemplatePanel from "./components/OnboardingTemplatePanel.vue";
import VariableDefinitionPanel from "./components/VariableDefinitionPanel.vue";
const activeTab = ref("fields"); const activeTab = ref("variables");
const requirementTab = ref("form");
const fields = ref<OnboardingFormField[]>([]);
const materialTypes = ref<OnboardingMaterialType[]>([]); const materialTypes = ref<OnboardingMaterialType[]>([]);
const companies = ref<OnboardingCompanyOption[]>([]);
const positions = ref<OnboardingPositionOption[]>([]);
const formRequirements = ref<OnboardingFormRequirement[]>([]);
const materialRequirements = ref<OnboardingMaterialRequirement[]>([]);
const selectedCompanyId = ref<number>();
const fieldVisible = ref(false);
const materialVisible = ref(false); const materialVisible = ref(false);
const requirementVisible = ref(false);
const requirementKind = ref<"form" | "material">("form"); const materialForm = reactive<Partial<OnboardingMaterialType>>({
materialCode: "",
const fieldForm = reactive<Partial<OnboardingFormField>>({}); materialName: "",
const materialForm = reactive<Partial<OnboardingMaterialType>>({}); allowedExtensions: "png,jpg,jpeg,pdf",
const requirementForm = reactive<any>({}); maxFileCount: 3,
maxFileSizeMb: 10,
const enabledFields = computed(() =>
fields.value.filter(item => item.status === "enable")
);
const enabledMaterials = computed(() =>
materialTypes.value.filter(item => item.status === "enable")
);
async function loadBase() {
const [
fieldResult,
materialResult,
companyResult
] = await Promise.all([
onboardingTaskApi.listFormFields(),
onboardingTaskApi.listMaterialTypes(),
onboardingTaskApi.listCompanies()
]);
fields.value = fieldResult.data;
materialTypes.value = materialResult.data;
companies.value = companyResult.data;
}
async function loadRequirements() {
if (!selectedCompanyId.value) return;
const [
formResult,
materialResult,
positionResult
] = await Promise.all([
onboardingTaskApi.listFormRequirements(selectedCompanyId.value),
onboardingTaskApi.listMaterialRequirements(selectedCompanyId.value),
onboardingTaskApi.listPositions(selectedCompanyId.value)
]);
formRequirements.value = formResult.data;
materialRequirements.value = materialResult.data;
positions.value = positionResult.data;
}
function resetField() {
Object.assign(fieldForm, {
id: undefined,
fieldCode: "",
fieldName: "",
dataType: "string",
placeholder: "",
optionsJson: "",
validationRule: "",
status: "enable" status: "enable"
}); });
async function loadMaterials() {
const res = await onboardingTaskApi.listMaterialTypes();
materialTypes.value = res.data || [];
} }
function resetMaterial() { function openMaterialCreate() {
Object.assign(materialForm, { Object.assign(materialForm, {
id: undefined, id: undefined,
materialCode: "", materialCode: "",
materialName: "", materialName: "",
allowedExtensions: "", allowedExtensions: "png,jpg,jpeg,pdf",
maxFileCount: 1, maxFileCount: 3,
maxFileSizeMb: undefined, maxFileSizeMb: 10,
status: "enable" status: "enable"
}); });
}
function openFieldCreate() {
resetField();
fieldVisible.value = true;
}
function openFieldEdit(row: OnboardingFormField) {
Object.assign(fieldForm, row);
fieldVisible.value = true;
}
async function saveField() {
if (
!fieldForm.fieldCode ||
!fieldForm.fieldName ||
!fieldForm.dataType
) {
return ElMessage.warning(
"请填写字段编码、名称和数据类型"
);
}
await onboardingTaskApi.saveFormField(fieldForm);
fieldVisible.value = false;
await loadBase();
}
function openMaterialCreate() {
resetMaterial();
materialVisible.value = true; materialVisible.value = true;
} }
@@ -468,141 +142,25 @@ function openMaterialEdit(row: OnboardingMaterialType) {
} }
async function saveMaterial() { async function saveMaterial() {
if ( if (!materialForm.materialCode || !materialForm.materialName) {
!materialForm.materialCode || return ElMessage.warning("请填写资料编码和资料名称");
!materialForm.materialName
) {
return ElMessage.warning(
"请填写资料编码和名称"
);
} }
await onboardingTaskApi.saveMaterialType(materialForm); await onboardingTaskApi.saveMaterialType(materialForm);
ElMessage.success("资料类型保存成功");
materialVisible.value = false; materialVisible.value = false;
await loadMaterials();
await loadBase();
}
async function removeField(row: OnboardingFormField) {
await ElMessageBox.confirm(
`确认删除字段“${row.fieldName}”?`,
"删除确认"
);
await onboardingTaskApi.deleteFormFields([row.id]);
await loadBase();
} }
async function removeMaterial(row: OnboardingMaterialType) { async function removeMaterial(row: OnboardingMaterialType) {
await ElMessageBox.confirm( await ElMessageBox.confirm(`确认删除资料类型“${row.materialName}”吗?`, "删除确认", { type: "warning" });
`确认删除资料类型“${row.materialName}”?`, await onboardingTaskApi.deleteMaterialTypes([row.id!]);
"删除确认" ElMessage.success("删除成功");
); await loadMaterials();
await onboardingTaskApi.deleteMaterialTypes([row.id]);
await loadBase();
} }
function resetRequirement() { onMounted(() => {
Object.assign(requirementForm, { loadMaterials();
id: undefined,
companyId: selectedCompanyId.value,
positionId: undefined,
fieldId: undefined,
materialTypeId: undefined,
required: true,
sortCode: 0
}); });
}
function openRequirementCreate(
kind: "form" | "material"
) {
requirementKind.value = kind;
resetRequirement();
requirementVisible.value = true;
}
function openRequirementEdit(
kind: "form" | "material",
row: any
) {
requirementKind.value = kind;
Object.assign(requirementForm, row);
requirementVisible.value = true;
}
async function saveRequirement() {
if (
requirementKind.value === "form" &&
!requirementForm.fieldId
) {
return ElMessage.warning("请选择填写字段");
}
if (
requirementKind.value === "material" &&
!requirementForm.materialTypeId
) {
return ElMessage.warning("请选择资料类型");
}
if (requirementKind.value === "form") {
await onboardingTaskApi.saveFormRequirement(
requirementForm
);
} else {
await onboardingTaskApi.saveMaterialRequirement(
requirementForm
);
}
requirementVisible.value = false;
await loadRequirements();
}
async function removeRequirement(
kind: "form" | "material",
row: any
) {
await ElMessageBox.confirm(
"确认删除该要求?",
"删除确认"
);
if (kind === "form") {
await onboardingTaskApi.deleteFormRequirements([
row.id
]);
} else {
await onboardingTaskApi.deleteMaterialRequirements([
row.id
]);
}
await loadRequirements();
}
const fieldName = (id: number) =>
fields.value.find(item => item.id === id)?.fieldName || id;
const materialName = (id: number) =>
materialTypes.value.find(item => item.id === id)?.materialName || id;
const positionName = (id?: number) =>
!id
? "全公司"
: positions.value.find(item => item.id === id)?.positionName || id;
onMounted(loadBase);
</script> </script>
<style scoped> <style scoped>
@@ -632,21 +190,34 @@ onMounted(loadBase);
.config-tabs :deep(.el-tabs__content) { .config-tabs :deep(.el-tabs__content) {
flex: 1; flex: 1;
overflow: auto; height: 0;
overflow: hidden;
}
.config-tabs :deep(.el-tab-pane) {
height: 100%;
display: flex;
flex-direction: column;
} }
.tab-card-wrapper { .tab-card-wrapper {
height: 100%;
width: 100%;
background: #fff; background: #fff;
border-radius: 8px; border-radius: 8px;
padding: 16px; padding: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden;
}
.material-tab-wrapper {
display: flex;
flex-direction: column;
} }
.toolbar { .toolbar {
margin-bottom: 14px; margin-bottom: 12px;
}
.hint {
margin-bottom: 14px;
} }
</style> </style>
+748 -59
View File
@@ -106,13 +106,41 @@
> >
撤销 撤销
</el-button> </el-button>
<el-button
v-if="scope.row.taskStatus === 'revoked'"
link
type="danger"
icon="Delete"
@click="removeTask(scope.row)"
>
删除
</el-button>
</template> </template>
</ProTable> </ProTable>
</div> </div>
<!-- 发起任务弹窗 --> <!-- 发起任务弹窗 -->
<el-dialog v-model="createVisible" title="发起入职任务" width="520px" destroy-on-close> <el-dialog
<el-form :model="form" label-width="90px"> v-model="createVisible"
title="发起入职任务"
width="1050px"
top="4vh"
destroy-on-close
append-to-body
class="create-task-dialog"
>
<div class="create-task-dialog-body">
<el-form :model="form" label-position="top">
<!-- 1. 基础岗位选择板块 -->
<div class="form-card-section">
<div class="section-title">
<span class="title-text">入职基础信息</span>
<span class="title-tip">请选择新员工入职的公司主体、岗位及适用的入职模板</span>
</div>
<el-row :gutter="20">
<el-col :xs="24" :sm="8">
<el-form-item label="所属公司" required> <el-form-item label="所属公司" required>
<el-select <el-select
v-model="form.companyId" v-model="form.companyId"
@@ -124,7 +152,9 @@
<el-option v-for="item in companies" :key="item.id" :label="item.companyName" :value="item.id" /> <el-option v-for="item in companies" :key="item.id" :label="item.companyName" :value="item.id" />
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-col>
<el-col :xs="24" :sm="8">
<el-form-item label="入职岗位" required> <el-form-item label="入职岗位" required>
<el-select <el-select
v-model="form.positionId" v-model="form.positionId"
@@ -132,19 +162,106 @@
placeholder="请选择岗位" placeholder="请选择岗位"
class="full-width" class="full-width"
:disabled="!form.companyId" :disabled="!form.companyId"
@change="onPositionChange"
> >
<el-option v-for="item in positions" :key="item.id" :label="item.positionName" :value="item.id" /> <el-option v-for="item in positions" :key="item.id" :label="item.positionName" :value="item.id" />
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-col>
<el-form-item label="人事备注"> <el-col :xs="24" :sm="8">
<el-input v-model="form.remark" type="textarea" :rows="3" placeholder="填写该候选人入职的相关备注(选填)" /> <el-form-item label="入职模板" required>
<el-select
v-model="form.onboardingTemplateId"
filterable
placeholder="请选择入职模板"
class="full-width"
:disabled="!form.positionId"
@change="onOnboardingTemplateChange"
>
<el-option v-for="item in onboardingTemplates" :key="item.id" :label="item.templateName" :value="item.id" />
</el-select>
</el-form-item> </el-form-item>
</el-col>
</el-row>
</div>
<!-- 2. 合同人事参数卡片 -->
<div v-if="form.contractVariables.length" class="form-card-section">
<div class="section-title">
<span class="title-text">合同人事参数预设</span>
<span class="title-tip">以下参数将自动替换进 DOCX 合同中,请在向员工发送链接前核对填写</span>
</div>
<el-alert
title="提示:此处填写的内容将作为合同正式条款,如薪资、合同起止日期、试用期等,请谨慎填写。"
type="warning"
:closable="false"
show-icon
style="margin-bottom: 16px;"
/>
<el-row :gutter="20">
<el-col
v-for="item in form.contractVariables"
:key="`${item.contractTemplateVersionId}-${item.variableCode}`"
:xs="24"
:sm="12"
>
<el-form-item>
<template #label>
<div class="var-label-row">
<span v-if="item.required" class="required-star">*</span>
<span class="var-name">{{ item.variableName }}</span>
<el-tag size="small" type="info" effect="plain" class="contract-source-tag">
{{ item.contractName }}
</el-tag>
</div>
</template>
<el-date-picker
v-if="item.dataType === 'date'"
v-model="item.variableValue"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择日期"
class="var-input-field"
/>
<el-input-number
v-else-if="item.dataType === 'number' || item.dataType === 'decimal'"
v-model="item.variableValue"
controls-position="right"
class="var-input-field"
placeholder="输入金额或数值"
/>
<el-input
v-else
v-model="item.variableValue"
:placeholder="`请输入${item.variableName}`"
class="var-input-field"
clearable
/>
</el-form-item>
</el-col>
</el-row>
</div>
<!-- 3. 其他备注 -->
<div class="form-card-section">
<div class="section-title">
<span class="title-text">补充备注</span>
<span class="title-tip">填写该候选人入职的相关流转备注(仅人事可见,选填)</span>
</div>
<el-form-item label="">
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="请输入人事备注说明(选填)" />
</el-form-item>
</div>
</el-form> </el-form>
</div>
<template #footer> <template #footer>
<el-button @click="createVisible = false">取消</el-button> <el-button @click="createVisible = false">取消</el-button>
<el-button type="primary" :loading="creating" @click="createTask">立即创建</el-button> <el-button type="primary" :loading="creating" @click="createTask">立即发起入职</el-button>
</template> </template>
</el-dialog> </el-dialog>
@@ -188,13 +305,15 @@
<el-drawer <el-drawer
v-model="detailVisible" v-model="detailVisible"
:title="`入职任务详情 · ${detail?.task?.taskNo || ''}`" :title="`入职任务详情 · ${detail?.task?.taskNo || ''}`"
size="860px" size="1180px"
destroy-on-close destroy-on-close
@closed="clearMaterialPreviews"
> >
<div v-if="detail" class="drawer-content"> <div v-if="detail" class="drawer-content">
<!-- 头部状态栏 --> <!-- 头部状态栏 (100% 满宽卡片) -->
<div class="drawer-status-bar"> <div class="drawer-status-banner">
<div class="status-left"> <div class="banner-left">
<div class="banner-title-row">
<span class="task-no-large">{{ detail.task.taskNo }}</span> <span class="task-no-large">{{ detail.task.taskNo }}</span>
<el-tag :type="statusType(detail.task.taskStatus)" size="large" effect="dark"> <el-tag :type="statusType(detail.task.taskStatus)" size="large" effect="dark">
{{ statusText(detail.task.taskStatus) }} {{ statusText(detail.task.taskStatus) }}
@@ -203,8 +322,15 @@
第 {{ detail.task.submitCount }} 次提交 第 {{ detail.task.submitCount }} 次提交
</span> </span>
</div> </div>
<div class="status-right"> <div class="banner-meta-row">
<span class="created-time">发起于:{{ formatTime(detail.task.createTime) }}</span> <span>所属公司:<strong>{{ detail.task.companyName || '-' }}</strong></span>
<span>入职岗位:<strong>{{ detail.task.positionName || '-' }}</strong></span>
<span>入职模板:<strong>{{ detail.task.onboardingTemplateName || '-' }}</strong></span>
</div>
</div>
<div class="banner-right">
<div class="meta-item">发起时间:{{ formatTime(detail.task.createTime) }}</div>
<div v-if="detail.task.linkExpireAt" class="meta-item">链接有效期至:{{ formatTime(detail.task.linkExpireAt) }}</div>
</div> </div>
</div> </div>
@@ -221,51 +347,156 @@
<!-- Tab 切换 --> <!-- Tab 切换 -->
<el-tabs v-model="activeDetailTab" class="detail-tabs"> <el-tabs v-model="activeDetailTab" class="detail-tabs">
<!-- 1. 候选人档案与动态信息 --> <!-- 1. 候选人档案与动态信息 -->
<el-tab-pane label="候选人信息" name="profile"> <el-tab-pane label="候选人档案 (入职登记表)" name="profile">
<el-card shadow="never" class="mb-4 border-card"> <div v-if="!detail.employeeInfo || !detail.employeeInfo.employeeName" class="empty-profile-box">
<el-empty description="候选人尚未填写或提交《入职登记表》" :image-size="100" />
</div>
<div v-else class="profile-details-wrapper">
<!-- 1. 基本个人档案 -->
<el-card shadow="never" class="mb-3 border-card">
<template #header> <template #header>
<div class="card-header-flex"> <div class="card-header-flex">
<span class="font-bold">基本个人档案</span> <span class="font-bold card-title-text"><el-icon class="mr-1"><UserFilled /></el-icon> 基本个人信息</span>
<el-tag size="small" type="info">固定信息</el-tag> <el-tag size="small" type="primary">档案标准项</el-tag>
</div> </div>
</template> </template>
<el-descriptions v-if="detail.employeeInfo" :column="2" border> <el-descriptions :column="3" border size="default">
<el-descriptions-item label="姓名"> <el-descriptions-item label="真实姓名">
<span class="font-bold text-primary">{{ detail.employeeInfo.employeeName }}</span> <strong class="text-primary font-bold">{{ detail.employeeInfo.employeeName }}</strong>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="性别">{{ detail.employeeInfo.gender || '-' }}</el-descriptions-item> <el-descriptions-item label="性别">{{ detail.employeeInfo.gender || '-' }}</el-descriptions-item>
<el-descriptions-item label="手机号码">{{ detail.employeeInfo.phone }}</el-descriptions-item> <el-descriptions-item label="民族">{{ detail.employeeInfo.nation || '-' }}</el-descriptions-item>
<el-descriptions-item label="身份证号码">{{ detail.employeeInfo.idCardNumber }}</el-descriptions-item> <el-descriptions-item label="出生年月日">{{ detail.employeeInfo.birthday || '-' }}</el-descriptions-item>
<el-descriptions-item label="毕业院校">{{ detail.employeeInfo.college || '-' }}</el-descriptions-item> <el-descriptions-item label="户籍所在地" :span="2">{{ detail.employeeInfo.registeredResidence || '-' }}</el-descriptions-item>
<el-descriptions-item label="学历 / 专业"> <el-descriptions-item label="身高 / 体重">
{{ detail.employeeInfo.education || '-' }} / {{ detail.employeeInfo.major || '-' }} {{ detail.employeeInfo.height ? detail.employeeInfo.height + ' cm' : '-' }} /
{{ detail.employeeInfo.weight ? detail.employeeInfo.weight + ' kg' : '-' }}
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="现居地址" :span="2"> <el-descriptions-item label="健康状况">{{ detail.employeeInfo.healthCondition || '-' }}</el-descriptions-item>
{{ detail.employeeInfo.contactAddress || '-' }} <el-descriptions-item label="婚育情况">{{ detail.employeeInfo.maritalStatus || '-' }}</el-descriptions-item>
<el-descriptions-item label="身份证号码" :span="3">
<code class="code-font font-bold">{{ detail.employeeInfo.idCardNumber || '-' }}</code>
</el-descriptions-item> </el-descriptions-item>
</el-descriptions> </el-descriptions>
<el-empty v-else description="候选人尚未提交基础档案" :image-size="80" />
</el-card> </el-card>
<!-- 动态填写字段 --> <!-- 2. 学历与职业资质 -->
<el-card shadow="never" class="border-card"> <el-card shadow="never" class="mb-3 border-card">
<template #header> <template #header>
<div class="card-header-flex"> <div class="card-header-flex">
<span class="font-bold">岗位补充登记信息</span> <span class="font-bold card-title-text"><el-icon class="mr-1"><School /></el-icon> 学历与职业资格</span>
<el-tag size="small" type="info">动态字段</el-tag>
</div> </div>
</template> </template>
<el-descriptions v-if="detail.formValues && detail.formValues.length" :column="2" border> <el-descriptions :column="3" border size="default">
<el-descriptions-item label="最高学历">{{ detail.employeeInfo.education || '-' }}</el-descriptions-item>
<el-descriptions-item label="所学专业">{{ detail.employeeInfo.major || '-' }}</el-descriptions-item>
<el-descriptions-item label="毕业院校">{{ detail.employeeInfo.graduatedSchool || detail.employeeInfo.college || '-' }}</el-descriptions-item>
<el-descriptions-item label="职业资格/职称">{{ detail.employeeInfo.vocationalQualification || '-' }}</el-descriptions-item>
<el-descriptions-item label="资格等级">{{ detail.employeeInfo.qualificationLevel || '-' }}</el-descriptions-item>
<el-descriptions-item label="特长技能">{{ detail.employeeInfo.technicalSpecialty || '-' }}</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 3. 联系方式与银行卡 -->
<el-card shadow="never" class="mb-3 border-card">
<template #header>
<div class="card-header-flex">
<span class="font-bold card-title-text"><el-icon class="mr-1"><Phone /></el-icon> 联系方式与工资银行卡</span>
</div>
</template>
<el-descriptions :column="3" border size="default">
<el-descriptions-item label="联系电话">{{ detail.employeeInfo.phone || '-' }}</el-descriptions-item>
<el-descriptions-item label="电子邮箱" :span="2">{{ detail.employeeInfo.email || '-' }}</el-descriptions-item>
<el-descriptions-item label="现居住详细地址" :span="2">{{ detail.employeeInfo.currentAddress || detail.employeeInfo.contactAddress || '-' }}</el-descriptions-item>
<el-descriptions-item label="房东联系方式">{{ detail.employeeInfo.landlordContact || '-' }}</el-descriptions-item>
<el-descriptions-item label="工资银行卡号" :span="2">
<code class="code-font font-bold">{{ detail.employeeInfo.bankCardNumber || '-' }}</code>
</el-descriptions-item>
<el-descriptions-item label="开户银行全称">{{ detail.employeeInfo.bankName || '-' }}</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 4. 紧急联系人 -->
<el-card shadow="never" class="mb-3 border-card">
<template #header>
<div class="card-header-flex">
<span class="font-bold card-title-text"><el-icon class="mr-1"><FirstAidKit /></el-icon> 紧急联系人</span>
</div>
</template>
<el-descriptions :column="3" border size="default">
<el-descriptions-item label="紧急联系人姓名">{{ detail.employeeInfo.emergencyContact || '-' }}</el-descriptions-item>
<el-descriptions-item label="联系电话">{{ detail.employeeInfo.emergencyPhone || '-' }}</el-descriptions-item>
<el-descriptions-item label="联系地址">{{ detail.employeeInfo.emergencyAddress || '-' }}</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 5. 教育学习经历 -->
<el-card shadow="never" class="mb-3 border-card">
<template #header>
<div class="card-header-flex">
<span class="font-bold card-title-text"><el-icon class="mr-1"><Reading /></el-icon> 教育学习经历</span>
</div>
</template>
<el-table :data="detailEducationList" border stripe empty-text="未填写教育学习经历">
<el-table-column prop="timeRange" label="起止时间" min-width="160" />
<el-table-column prop="location" label="学校 / 机构" min-width="200" />
<el-table-column prop="content" label="所学专业 / 学习内容" min-width="200" />
<el-table-column prop="result" label="学习结果 / 获得学位" min-width="180" />
</el-table>
</el-card>
<!-- 6. 主要工作经历 -->
<el-card shadow="never" class="mb-3 border-card">
<template #header>
<div class="card-header-flex">
<span class="font-bold card-title-text"><el-icon class="mr-1"><Briefcase /></el-icon> 主要工作经历</span>
</div>
</template>
<el-table :data="detailWorkList" border stripe empty-text="未填写工作经历(应届生无记录)">
<el-table-column prop="timeRange" label="起止时间" min-width="160" />
<el-table-column prop="companyName" label="工作单位" min-width="220" />
<el-table-column prop="position" label="担任职务" width="140" />
<el-table-column prop="salary" label="薪资待遇" width="120" />
<el-table-column prop="leaveReason" label="离职原因" min-width="180" />
</el-table>
</el-card>
<!-- 7. 家庭成员 -->
<el-card shadow="never" class="mb-3 border-card">
<template #header>
<div class="card-header-flex">
<span class="font-bold card-title-text"><el-icon class="mr-1"><HomeFilled /></el-icon> 家庭成员</span>
</div>
</template>
<el-table :data="detailFamilyList" border stripe empty-text="未填写家庭成员">
<el-table-column prop="name" label="姓名" width="120" />
<el-table-column prop="relationship" label="与本人关系" width="120" />
<el-table-column prop="age" label="年龄" width="90" align="center" />
<el-table-column prop="company" label="工作单位及职务" min-width="240" />
<el-table-column prop="phone" label="联系电话" width="150" />
</el-table>
</el-card>
<!-- 8. 合同专有自定义补充项 (如果有) -->
<el-card v-if="detailCustomVars.length" shadow="never" class="mb-3 border-card">
<template #header>
<div class="card-header-flex">
<span class="font-bold card-title-text">合同专属补充信息</span>
<el-tag size="small" type="warning">合同专有项</el-tag>
</div>
</template>
<el-descriptions :column="2" border size="default">
<el-descriptions-item <el-descriptions-item
v-for="item in detail.formValues" v-for="item in detailCustomVars"
:key="item.id" :key="item.key"
:label="item.fieldName || item.fieldCode" :label="item.key"
> >
{{ item.fieldValue || '-' }} {{ item.value || '-' }}
</el-descriptions-item> </el-descriptions-item>
</el-descriptions> </el-descriptions>
<el-empty v-else description="暂无岗位补充字段或未填写" :image-size="80" />
</el-card> </el-card>
</div>
</el-tab-pane> </el-tab-pane>
<!-- 2. 证件与材料复核画廊 --> <!-- 2. 证件与材料复核画廊 -->
@@ -283,19 +514,16 @@
> >
<div class="material-img-wrapper"> <div class="material-img-wrapper">
<el-image <el-image
v-if="isImageMaterial(item) && materialPreviewUrls[item.fileId]"
class="material-thumb" class="material-thumb"
:src="fileDownloadUrl(item.fileId)" :src="materialPreviewUrls[item.fileId]"
:preview-src-list="[fileDownloadUrl(item.fileId)]"
fit="cover" fit="cover"
preview-teleported @click="previewImage(item.fileId)"
> />
<template #error> <div v-else class="material-thumb-placeholder">
<div class="material-thumb-placeholder">
<el-icon :size="32"><Document /></el-icon> <el-icon :size="32"><Document /></el-icon>
<span>文档文件</span> <span>{{ materialPreviewText(item) }}</span>
</div> </div>
</template>
</el-image>
<div class="material-overlay-status"> <div class="material-overlay-status">
<el-tag <el-tag
:type="item.reviewStatus === 'passed' ? 'success' : item.reviewStatus === 'need_supplement' ? 'danger' : 'warning'" :type="item.reviewStatus === 'passed' ? 'success' : item.reviewStatus === 'need_supplement' ? 'danger' : 'warning'"
@@ -313,7 +541,18 @@
</div> </div>
<div class="material-actions-flex"> <div class="material-actions-flex">
<el-button link type="primary" size="small" icon="Download" @click="download(item.fileId)"> <el-button
v-if="isImageMaterial(item)"
link
type="primary"
size="small"
icon="View"
:disabled="!materialPreviewUrls[item.fileId]"
@click="previewImage(item.fileId)"
>
图片预览
</el-button>
<el-button link type="primary" size="small" icon="Download" @click="download(item.fileId, item.fileName)">
原件下载 原件下载
</el-button> </el-button>
@@ -451,12 +690,27 @@
> >
归档该任务 归档该任务
</el-button> </el-button>
<el-button
v-if="detail.task.taskStatus === 'revoked'"
type="danger"
icon="Delete"
@click="removeTask(detail.task)"
>
删除该任务
</el-button>
<el-button @click="detailVisible = false">关闭</el-button> <el-button @click="detailVisible = false">关闭</el-button>
</div> </div>
</div> </div>
</template> </template>
</el-drawer> </el-drawer>
<el-image-viewer
v-if="imageViewerVisible"
:url-list="imageViewerUrls"
hide-on-click-modal
@close="closeImageViewer"
/>
<!-- 合同参数弹窗 --> <!-- 合同参数弹窗 -->
<el-dialog <el-dialog
v-model="contractVariablesVisible" v-model="contractVariablesVisible"
@@ -524,7 +778,7 @@
</template> </template>
<script setup lang="ts" name="onboardingTask"> <script setup lang="ts" name="onboardingTask">
import { ref, reactive, onMounted } from "vue"; import { ref, reactive, computed, onMounted, onBeforeUnmount } from "vue";
import { ElMessage, ElMessageBox } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import { import {
DocumentAdd, DocumentAdd,
@@ -548,6 +802,8 @@ import {
onboardingTaskApi, onboardingTaskApi,
type OnboardingCompanyOption, type OnboardingCompanyOption,
type OnboardingPositionOption, type OnboardingPositionOption,
type OnboardingTemplate,
type OnboardingContractTemplate,
type OnboardingTask, type OnboardingTask,
type OnboardingTaskContractVariableGroup type OnboardingTaskContractVariableGroup
} from "@/api/modules/biz/onboarding"; } from "@/api/modules/biz/onboarding";
@@ -557,6 +813,8 @@ const proTable = ref<ProTableInstance>();
const companies = ref<OnboardingCompanyOption[]>([]); const companies = ref<OnboardingCompanyOption[]>([]);
const positions = ref<OnboardingPositionOption[]>([]); const positions = ref<OnboardingPositionOption[]>([]);
const onboardingTemplates = ref<OnboardingTemplate[]>([]);
const contractTemplates = ref<OnboardingContractTemplate[]>([]);
const createVisible = ref(false); const createVisible = ref(false);
const creating = ref(false); const creating = ref(false);
@@ -566,6 +824,53 @@ const createdTaskInfo = ref<any>(null);
const detailVisible = ref(false); const detailVisible = ref(false);
const activeDetailTab = ref("profile"); const activeDetailTab = ref("profile");
const detail = ref<any>(); const detail = ref<any>();
const materialPreviewUrls = reactive<Record<number, string>>({});
const materialPreviewLoading = reactive<Record<number, boolean>>({});
const materialPreviewFailed = reactive<Record<number, boolean>>({});
const imageViewerVisible = ref(false);
const imageViewerUrls = ref<string[]>([]);
const imageMimeTypes: Record<string, string> = {
bmp: "image/bmp",
gif: "image/gif",
jpeg: "image/jpeg",
jpg: "image/jpeg",
png: "image/png",
webp: "image/webp"
};
const detailEducationList = computed(() => {
try {
return JSON.parse(detail.value?.employeeInfo?.educationHistoryJson || "[]");
} catch {
return [];
}
});
const detailWorkList = computed(() => {
try {
return JSON.parse(detail.value?.employeeInfo?.workHistoryJson || "[]");
} catch {
return [];
}
});
const detailFamilyList = computed(() => {
try {
return JSON.parse(detail.value?.employeeInfo?.familyMembersJson || "[]");
} catch {
return [];
}
});
const detailCustomVars = computed(() => {
try {
const obj = JSON.parse(detail.value?.employeeInfo?.customVariablesJson || "{}");
return Object.entries(obj).map(([k, v]) => ({ key: k, value: String(v) }));
} catch {
return [];
}
});
const contractVariablesVisible = ref(false); const contractVariablesVisible = ref(false);
const contractVariableTask = ref<OnboardingTask>(); const contractVariableTask = ref<OnboardingTask>();
@@ -582,6 +887,16 @@ const statusCounts = reactive({
const form = reactive({ const form = reactive({
companyId: undefined as number | undefined, companyId: undefined as number | undefined,
positionId: undefined as number | undefined, positionId: undefined as number | undefined,
onboardingTemplateId: undefined as number | undefined,
contractVariables: [] as Array<{
contractTemplateVersionId: number;
variableCode: string;
variableName: string;
variableValue?: string;
dataType: string;
required: boolean;
contractName: string;
}>,
remark: "" remark: ""
}); });
@@ -705,9 +1020,96 @@ const formatTime = (timeStr?: string) => {
return timeStr.replace("T", " ").substring(0, 19); return timeStr.replace("T", " ").substring(0, 19);
}; };
const fileDownloadUrl = (fileId: number) => { function materialFileSuffix(item: any) {
return `/api/sys/dev/file/download?id=${fileId}`; const suffix = String(item?.fileSuffix || "")
}; .trim()
.replace(/^\./, "")
.toLowerCase();
if (suffix) return suffix;
const fileName = String(item?.fileName || "");
const dotIndex = fileName.lastIndexOf(".");
return dotIndex >= 0 ? fileName.substring(dotIndex + 1).toLowerCase() : "";
}
function isImageMaterial(item: any) {
return Boolean(imageMimeTypes[materialFileSuffix(item)]);
}
function materialPreviewText(item: any) {
if (!isImageMaterial(item)) return "非图片文件";
if (materialPreviewLoading[item.fileId]) return "图片加载中";
if (materialPreviewFailed[item.fileId]) return "图片预览加载失败";
return "图片待加载";
}
function closeImageViewer() {
imageViewerVisible.value = false;
imageViewerUrls.value = [];
}
function clearMaterialPreviews() {
Object.values(materialPreviewUrls).forEach(url => URL.revokeObjectURL(url));
Object.keys(materialPreviewUrls).forEach(key => delete materialPreviewUrls[Number(key)]);
Object.keys(materialPreviewLoading).forEach(key => delete materialPreviewLoading[Number(key)]);
Object.keys(materialPreviewFailed).forEach(key => delete materialPreviewFailed[Number(key)]);
closeImageViewer();
}
function previewImage(fileId: number) {
const previewUrl = materialPreviewUrls[fileId];
if (!previewUrl) {
ElMessage.warning("图片正在加载或暂时无法预览");
return;
}
imageViewerUrls.value = [previewUrl];
imageViewerVisible.value = true;
}
async function loadMaterialPreviews(materials: any[]) {
clearMaterialPreviews();
const imageMaterials = (materials || []).filter(isImageMaterial);
await Promise.all(imageMaterials.map(async item => {
materialPreviewLoading[item.fileId] = true;
materialPreviewFailed[item.fileId] = false;
try {
const response: any = await onboardingTaskApi.downloadFile(item.fileId);
const sourceBlob = response?.data;
if (!(sourceBlob instanceof Blob)) {
throw new Error("服务器未返回有效的图片文件");
}
const mimeType = imageMimeTypes[materialFileSuffix(item)];
const imageBlob = sourceBlob.type.startsWith("image/")
? sourceBlob
: new Blob([sourceBlob], { type: mimeType });
materialPreviewUrls[item.fileId] = URL.createObjectURL(imageBlob);
} catch {
materialPreviewFailed[item.fileId] = true;
} finally {
materialPreviewLoading[item.fileId] = false;
}
}));
}
function resolveDownloadFileName(response: any, fallbackFileName?: string) {
const disposition = String(response?.headers?.["content-disposition"] || "");
const encodedName = disposition.match(/filename\*=UTF-8\'\'([^;]+)/i)?.[1]
|| disposition.match(/filename="?([^";]+)"?/i)?.[1];
if (encodedName) {
try {
return decodeURIComponent(encodedName.replace(/\+/g, "%20"));
} catch {
return encodedName;
}
}
return fallbackFileName || "入职材料";
}
// 列表查询并统计 // 列表查询并统计
async function getTableData(params: any) { async function getTableData(params: any) {
@@ -724,25 +1126,88 @@ async function getTableData(params: any) {
} }
async function loadCompanies() { async function loadCompanies() {
const { data } = await onboardingTaskApi.listCompanies(); try {
companies.value = data; const [companyResult, contractResult] = await Promise.all([
onboardingTaskApi.listCompanies().catch(() => ({ data: [] })),
onboardingTaskApi.templatePage({ pageNum: 1, pageSize: 100, status: "enable" }).catch(() => ({ data: { list: [] } }))
]);
companies.value = companyResult.data || [];
contractTemplates.value = contractResult.data?.list || [];
} catch (e) {
console.error("加载公司与合同数据失败", e);
}
} }
async function onCompanyChange(companyId: number) { async function onCompanyChange(companyId: number) {
form.positionId = undefined; form.positionId = undefined;
form.onboardingTemplateId = undefined;
form.contractVariables = [];
onboardingTemplates.value = [];
const { data } = await onboardingTaskApi.listPositions(companyId); const { data } = await onboardingTaskApi.listPositions(companyId);
positions.value = data; positions.value = data || [];
}
async function onPositionChange(positionId: number) {
form.onboardingTemplateId = undefined;
form.contractVariables = [];
onboardingTemplates.value = [];
if (!form.companyId || !positionId) return;
const { data } = await onboardingTaskApi.listEnabledOnboardingTemplates(form.companyId, positionId);
onboardingTemplates.value = data || [];
if (onboardingTemplates.value.length === 1) {
form.onboardingTemplateId = onboardingTemplates.value[0].id;
await onOnboardingTemplateChange(form.onboardingTemplateId!);
}
}
async function onOnboardingTemplateChange(templateId: number) {
form.contractVariables = [];
if (!templateId) return;
const { data: template } = await onboardingTaskApi.onboardingTemplateDetail({ id: templateId });
const variableResults = await Promise.all(
template.contracts.map(contract => onboardingTaskApi.variablePage({
templateVersionId: contract.contractTemplateVersionId,
pageNum: 1,
pageSize: 100
}))
);
form.contractVariables = template.contracts.flatMap((contract, index) => {
const contractName = contractTemplates.value.find(item => item.id === contract.contractTemplateId)?.templateName || "合同";
return (variableResults[index].data?.list || [])
.filter(variable => variable.hrEditable && ["contract", "company"].includes(variable.variableType))
.map(variable => ({
contractTemplateVersionId: contract.contractTemplateVersionId,
variableCode: variable.variableCode,
variableName: variable.variableName,
variableValue: variable.defaultValue,
dataType: variable.dataType,
required: variable.required,
contractName
}));
});
} }
function openCreate() { function openCreate() {
Object.assign(form, { companyId: undefined, positionId: undefined, remark: "" }); Object.assign(form, {
companyId: undefined,
positionId: undefined,
onboardingTemplateId: undefined,
contractVariables: [],
remark: ""
});
positions.value = []; positions.value = [];
onboardingTemplates.value = [];
createVisible.value = true; createVisible.value = true;
} }
async function createTask() { async function createTask() {
if (!form.companyId || !form.positionId) { if (!form.companyId || !form.positionId || !form.onboardingTemplateId) {
return ElMessage.warning("请选择所属公司和入职岗位"); return ElMessage.warning("请选择所属公司、入职岗位和入职模板");
}
const missingVariable = form.contractVariables.find(item => item.required && !String(item.variableValue || "").trim());
if (missingVariable) {
return ElMessage.warning(`请填写合同人事参数“${missingVariable.variableName}”`);
} }
creating.value = true; creating.value = true;
@@ -783,10 +1248,11 @@ function copyInviteMessage() {
} }
async function showDetail(row: OnboardingTask) { async function showDetail(row: OnboardingTask) {
const { data } = await onboardingTaskApi.detail({ id: row.id }); const { data }: any = await onboardingTaskApi.detail({ id: row.id });
detail.value = data; detail.value = data;
activeDetailTab.value = "profile"; activeDetailTab.value = "profile";
detailVisible.value = true; detailVisible.value = true;
void loadMaterialPreviews(data?.materials || []);
} }
async function openContractVariables(row: OnboardingTask) { async function openContractVariables(row: OnboardingTask) {
@@ -849,10 +1315,29 @@ async function generate(taskContractId: number) {
} }
} }
function download(fileId: number) { async function download(fileId: number, fallbackFileName?: string) {
window.open(fileDownloadUrl(fileId), "_blank"); try {
const response: any = await onboardingTaskApi.downloadFile(fileId);
const blob = response?.data;
if (!(blob instanceof Blob)) {
throw new Error("服务器未返回有效的文件");
} }
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = objectUrl;
link.download = resolveDownloadFileName(response, fallbackFileName);
document.body.appendChild(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
} catch (error: any) {
ElMessage.error(error?.msg || error?.message || "文件下载失败");
}
}
onBeforeUnmount(clearMaterialPreviews);
async function reviewMaterial(taskMaterialId: number, reviewStatus: string) { async function reviewMaterial(taskMaterialId: number, reviewStatus: string) {
let reviewRemark = ""; let reviewRemark = "";
if (reviewStatus === "need_supplement") { if (reviewStatus === "need_supplement") {
@@ -891,6 +1376,18 @@ async function revoke(row: OnboardingTask) {
proTable.value?.refresh(); proTable.value?.refresh();
} }
async function removeTask(row: OnboardingTask) {
await ElMessageBox.confirm(
`确认永久删除已撤销的入职任务【${row.taskNo}】吗?此操作将清理该任务全部数据,且不可恢复。`,
"删除任务确认",
{ type: "error", confirmButtonText: "确定删除", cancelButtonText: "取消" }
);
await onboardingTaskApi.deleteTask(row.id);
ElMessage.success("入职任务已删除");
detailVisible.value = false;
proTable.value?.refresh();
}
onMounted(loadCompanies); onMounted(loadCompanies);
</script> </script>
@@ -1106,6 +1603,52 @@ onMounted(loadCompanies);
gap: 8px; gap: 8px;
} }
.material-img-wrapper {
position: relative;
width: 100%;
height: 150px;
overflow: hidden;
background: #e2e8f0;
border-radius: 6px;
}
.material-thumb {
width: 100%;
height: 100%;
cursor: zoom-in;
}
.material-thumb-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
gap: 8px;
color: #64748b;
}
.material-overlay-status {
position: absolute;
top: 8px;
right: 8px;
}
.material-info-box {
display: flex;
flex-direction: column;
gap: 8px;
}
.material-actions-flex,
.review-btn-group {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.border-pass { .border-pass {
border-color: #10b981; border-color: #10b981;
background: #f0fdf4; background: #f0fdf4;
@@ -1196,4 +1739,150 @@ onMounted(loadCompanies);
display: flex; display: flex;
gap: 10px; gap: 10px;
} }
/* 发起任务大弹窗样式 */
.create-task-dialog-body {
max-height: 74vh;
overflow-y: auto;
padding-right: 6px;
}
.form-card-section {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 16px 18px;
margin-bottom: 16px;
}
.section-title {
margin-bottom: 14px;
}
.title-text {
font-size: 15px;
font-weight: 700;
color: #0f172a;
margin-right: 10px;
}
.title-tip {
font-size: 12px;
color: #64748b;
}
.var-label-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 600;
color: #1e293b;
}
.contract-source-tag {
font-weight: normal;
border-radius: 4px;
}
.required-star {
color: #f56c6c;
font-weight: bold;
font-size: 14px;
line-height: 1;
}
.var-input-field {
width: 100% !important;
}
:deep(.var-input-field.el-date-editor) {
width: 100% !important;
}
:deep(.var-input-field.el-input-number) {
width: 100% !important;
}
:deep(.var-input-field.el-input-number .el-input__inner) {
text-align: left;
}
/* 详情抽屉顶部横幅与档案卡片样式 */
.drawer-status-banner {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 16px 20px;
margin-bottom: 20px;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.banner-left {
display: flex;
flex-direction: column;
gap: 8px;
}
.banner-title-row {
display: flex;
align-items: center;
gap: 12px;
}
.task-no-large {
font-size: 18px;
font-weight: 800;
color: #0f172a;
}
.submit-count-badge {
font-size: 12px;
color: #2563eb;
background: #eff6ff;
border: 1px solid #bfdbfe;
padding: 2px 8px;
border-radius: 4px;
font-weight: 600;
}
.banner-meta-row {
display: flex;
gap: 20px;
font-size: 13px;
color: #64748b;
flex-wrap: wrap;
}
.banner-right {
display: flex;
flex-direction: column;
gap: 4px;
align-items: flex-end;
font-size: 13px;
color: #64748b;
}
.card-title-text {
font-size: 14px;
color: #1e293b;
display: inline-flex;
align-items: center;
}
.mr-1 {
margin-right: 4px;
}
.code-font {
font-family: Consolas, Monaco, monospace;
background: #eff6ff;
padding: 2px 6px;
border-radius: 4px;
color: #2563eb;
}
</style> </style>
File diff suppressed because it is too large Load Diff