Compare commits

..
12 Commits
Author SHA1 Message Date
fuenmao f183bfe508 查询方式优化 2026-11-09 17:08:34 +08:00
fuenmao b79034311b 新增公司盖章与骑缝章配置 2026-10-15 17:42:13 +08:00
fuenmao a2a163425c 取消电话必填限制 2026-10-14 16:26:37 +08:00
fuenmao 54ac8369b5 调整默认登录信息 2026-09-20 14:12:49 +08:00
fuenmao 30ac8b51b5 优化审核提示 2026-09-18 21:14:21 +08:00
fuenmao 6be0d4f54a 优化操作 2026-09-18 16:19:29 +08:00
fuenmao ea2c823b75 相关bug调整 2026-09-18 16:08:27 +08:00
fuenmao c812071d8a 修改相关问题 2026-09-18 10:53:56 +08:00
fuenmao 2f0d04b982 修复相关bug 2026-09-17 16:03:40 +08:00
fuenmao 7cffe57e4d 新增必填项验证 2026-09-17 14:33:55 +08:00
fuenmao 04c621961c 修改相关bug 2026-09-17 11:37:32 +08:00
fuenmao a0a181af57 入职样式优化 2026-09-15 15:38:19 +08:00
20 changed files with 2541 additions and 718 deletions
+1
View File
@@ -9,6 +9,7 @@ lerna-debug.log*
node_modules
dist
dist-*
dist-ssr
stats.html
*.local
+5 -1
View File
@@ -5,9 +5,13 @@
</template>
<script setup lang="ts">
import { reactive } from "vue";
import { reactive, onMounted } from "vue";
import { ElConfigProvider } from "element-plus";
import zhCn from "element-plus/es/locale/lang/zh-cn";
const buttonConfig = reactive({ autoInsertSpace: false });
onMounted(() => {
document.documentElement.classList.add("is-public-app");
});
</script>
+17
View File
@@ -115,6 +115,8 @@ export interface OnboardingElectronicSignDocument {
originalDocxFileId?: number;
signedPdfFileId?: number;
employeeSignedPreviewed: boolean;
companySealEnabled: boolean;
acrossPageSealEnabled: boolean;
}
/** 员工已签、企业未盖章文件的契约锁只读预览。 */
@@ -245,6 +247,8 @@ export interface OnboardingContractVersion {
effectiveDate?: string;
expiryDate?: string;
versionStatus: string;
companySealEnabled?: boolean | null;
acrossPageSealEnabled?: boolean | null;
remark?: string;
}
@@ -756,6 +760,7 @@ export const onboardingTaskApi = {
versionPage(params: {
templateId: number;
versionStatus?: string;
pageNum: number;
pageSize: number;
}) {
@@ -781,6 +786,18 @@ export const onboardingTaskApi = {
);
},
setVersionSeals(params: {
id: number;
companySealEnabled?: boolean;
acrossPageSealEnabled?: boolean;
}) {
return moduleRequest("/biz/onboarding/contract-template/").post(
"version/set-seals",
params,
{ loading: false }
);
},
deleteVersion(id: number) {
return moduleRequest("/biz/onboarding/contract-template/").post(
"version/delete",
+2 -1
View File
@@ -24,7 +24,8 @@ export const onboardingPublicApi = {
},
saveEmployeeInfo(params: Record<string, unknown>) {
return publicPost<never>("save-employee-info", params);
// “下一步”需要将业务校验结果作为阻断弹窗展示,由页面统一提示一次。
return publicPost<never>("save-employee-info", params, { notifyError: false });
},
confirmContract(params: { token: string; taskContractId: number }) {
+3
View File
@@ -70,6 +70,9 @@ function reportError(error: PublicApiError, notifyError: boolean) {
}
async function readErrorMessage(response: Response) {
if (response.status === 413) {
return "单个文件不能超过20MB,请压缩后重新上传";
}
try {
const payload = await response.clone().json();
return payload?.msg || payload?.message || `请求失败(${response.status})`;
+152 -71
View File
@@ -4,56 +4,62 @@
* @Date: 2023-12-15 15:40:39
!-->
<template>
<form-container v-model="visible" title="角色选择" form-size="90%" v-bind="$attrs">
<div class="-mt-15px min-h-350px">
<el-row :gutter="12" justify="space-between">
<el-col :span="4">
<el-scrollbar max-height="650px">
<TreeFilter
label="name"
class="filterWidth"
title="组织列表"
top-name="全局"
:default-expand-all="false"
:request-api="orgTreeApi"
@change="changeOrgTreeFilter"
/></el-scrollbar>
</el-col>
<el-col :span="10">
<ProTable ref="userTable" :columns="columns" :tool-button="false" :init-param="initParam" :request-api="roleSelectorApi">
<form-container v-model="visible" title="角色选择" :form-size="formSize" v-bind="$attrs" @close="onClose">
<div class="role-selector-content">
<!-- 组织树 -->
<div class="selector-tree-col">
<TreeFilter label="name" class="filterWidth" title="组织列表" top-name="全局" :default-expand-all="false"
:request-api="orgTreeApi" @change="changeOrgTreeFilter" />
</div>
<!-- 待选角色 -->
<div class="selector-table-col">
<ProTable ref="userTable" :columns="columns" :tool-button="false" :init-param="initParam"
:request-api="roleSelectorApi" :search-col="{ xs: 1, sm: 2, md: 2, lg: 2, xl: 2 }" max-height="300px">
<!-- 表格 header 按钮 -->
<template #tableHeader="scope">
<el-button type="primary" @click="addRecords(userTable!.tableData)">添加当前</el-button>
<el-button type="primary" plain :disabled="!scope.isSelected" @click="addRecords(scope.selectedList)">添加选中</el-button>
<el-button type="primary" plain :disabled="!scope.isSelected"
@click="addRecords(scope.selectedList)">添加选中</el-button>
</template>
<!-- 操作 -->
<template #operation="scope">
<el-button type="primary" link :icon="Plus" plain @click="addRecords([scope.row])">添加</el-button>
</template>
</ProTable>
</el-col>
<el-col :span="10">
<ProTable ref="chooseTable" :columns="columns" :tool-button="true" :data="chooseDataTmp" @search="searchRecords" @reset="resetRecords">
</div>
<!-- 已选角色 -->
<div class="selector-table-col">
<div class="card choose-header-bar">
<div class="choose-header-left">
<span class="choose-title">已选角色清单</span>
<span v-if="maxCount" class="max-count-tip">(最多选择 {{ maxCount }} 个)</span>
</div>
<el-tag type="primary" size="small" effect="plain">已选择 {{ chooseData.length }} 个角色</el-tag>
</div>
<ProTable ref="chooseTable" :columns="chooseColumns" :tool-button="false" :pagination="false"
:data="chooseDataTmp" max-height="300px">
<!-- 表格 header 按钮 -->
<template #tableHeader="scope">
<el-button type="danger" @click="delRecords(chooseTable!.tableData)">删除当前</el-button>
<el-button type="danger" plain :disabled="!scope.isSelected" @click="delRecords(scope.selectedList)">删除选中</el-button>
</template>
<template #toolButton>
<span>已选择:{{ chooseData.length }}人</span>
<span v-if="maxCount">,最多选择:{{ maxCount }}人</span>
<el-button type="danger" plain :disabled="!scope.isSelected"
@click="delRecords(scope.selectedList)">删除选中</el-button>
</template>
<!-- 操作 -->
<template #operation="scope">
<el-button type="danger" link :icon="Delete" plain @click="delRecords([scope.row])">删除</el-button>
</template>
</ProTable>
</el-col>
</el-row>
</div>
</div>
<!-- 底部按钮区域 -->
<template #footer>
<div class="role-selector-footer">
<el-button @click="onClose"> 取消 </el-button>
<el-button type="primary" @click="handleOk"> 确定 </el-button>
</div>
</template>
</form-container>
</template>
@@ -67,32 +73,41 @@ import { Plus, Delete } from "@element-plus/icons-vue";
const emit = defineEmits({ successful: null }); // 自定义事件
const visible = ref(false); //是否显示
const visible = ref(false); // 是否显示
// 定义组件props
// 定义组件 props
const props = withDefaults(defineProps<RoleSelectProps>(), {
permission: false,
multiple: false
multiple: false,
formSize: "1300px"
});
// 如果表格需要初始化请求参数,直接定义传给 ProTable(之后每次请求都会自动带上该参数,此参数更改之后也会一直带上,改变此参数会自动刷新表格数据)
// 如果表格需要初始化请求参数,直接定义传给 ProTable
const initParam = reactive<RoleSelectTableInitParams>({});
// 获取 userTable 元素,调用其获取刷新数据方法(还能获取到当前查询参数,方便导出携带参数)
// 获取 userTable 元素
const userTable = ref<ProTableInstance>();
// 获取 chooseTable 元素,调用其获取刷新数据方法(还能获取到当前查询参数,方便导出携带参数)
// 获取 chooseTable 元素
const chooseTable = ref<ProTableInstance>();
// 表格配置项
// 待选角色表格配置项(包含搜索项)
const columns: ColumnProps<SysRole.SysRoleInfo>[] = [
{ type: "selection", fixed: "left", width: 50 },
{ prop: "operation", label: "操作", width: 80, fixed: "left" },
{ prop: "name", label: "角色名", search: { el: "input", span: 2 } }
{ prop: "name", label: "角色名", search: { el: "input" } }
];
// 已选角色表格配置项(不包含搜索,更轻量)
const chooseColumns: ColumnProps<SysRole.SysRoleInfo>[] = [
{ type: "selection", fixed: "left", width: 50 },
{ prop: "operation", label: "操作", width: 80, fixed: "left" },
{ prop: "name", label: "角色名" }
];
/** 显示选择器 */
function showSelector(data: SysRole.SysRoleInfo[] = []) {
visible.value = true;
chooseDataTmp.value = data;
chooseData.value = data;
chooseDataTmp.value = data ? [...data] : [];
chooseData.value = data ? [...data] : [];
}
/** 关闭选择器 */
@@ -110,74 +125,140 @@ function handleOk() {
/** 部门切换 */
function changeOrgTreeFilter(val: number | string) {
userTable.value!.pageable.pageNum = 1;
if (val != "") {
// 如果传入的val不为空
if (userTable.value?.pageable) {
userTable.value.pageable.pageNum = 1;
}
if (val !== "") {
initParam.orgId = val;
} else {
initParam.orgId = null;
}
}
const chooseData = ref<SysRole.SysRoleInfo[]>([]); //选择的数据
const chooseDataTmp = ref<SysRole.SysRoleInfo[]>([]); //临时选择的数据
const chooseData = ref<SysRole.SysRoleInfo[]>([]); // 选择的数据
const chooseDataTmp = ref<SysRole.SysRoleInfo[]>([]); // 临时选择的数据
/** 添加记录 */
function addRecords(records: any[]) {
//如果不是多选,判断是否已经添加了
// 如果不是多选,判断是否已经添加了
if (!props.multiple) {
if (chooseData.value.length > 0 || records.length > 1) {
ElMessage.warning("只可选择一条");
ElMessage.warning("只可选择一个角色");
return;
}
chooseData.value = records;
chooseDataTmp.value = chooseData.value;
} else {
//如果是多选,先判断已添加列表是否有重复的id,有则过滤掉,没有则直接添加
records = records.filter(item => !chooseData.value.find(it => it.id == item.id));
// 如果是多选,先判断已添加列表是否有重复的id,有则过滤掉,没有则直接添加
records = records.filter(item => !chooseData.value.find(it => it.id === item.id));
if (props.maxCount && props.maxCount < records.length + chooseData.value.length) {
ElMessage.warning("最多选择" + props.maxCount + "条");
ElMessage.warning(`最多选择 ${props.maxCount} 个角色`);
return;
}
chooseData.value = chooseData.value.concat(records); //添加到已选中列表
chooseData.value = chooseData.value.concat(records); // 添加到已选中列表
chooseDataTmp.value = chooseData.value;
}
chooseTable.value?.refresh(); //刷新表格
chooseTable.value?.refresh(); // 刷新表格
}
/** 删除记录 */
function delRecords(records: any[]) {
chooseData.value = chooseData.value.filter(item => !records.includes(item)); //过滤掉已选中的
chooseData.value = chooseData.value.filter(item => !records.includes(item)); // 过滤掉已选中的
chooseDataTmp.value = chooseData.value;
chooseTable.value?.refresh(); //刷新表格
chooseTable.value?.refresh(); // 刷新表格
}
/** 搜索记录 */
function searchRecords() {
if (chooseTable.value?.searchParam?.name) {
//搜索name符合的记录
chooseDataTmp.value = chooseDataTmp.value.filter(item => item.name.includes(chooseTable.value?.searchParam.name)); //过滤掉已选中的
chooseTable.value?.refresh(); //刷新表格
}
}
/** 重置记录 */
function resetRecords() {
chooseDataTmp.value = chooseData.value;
chooseTable.value?.refresh(); //刷新表格
}
// 暴露方法
defineExpose({ showSelector });
</script>
<style lang="scss" scoped>
.filterWidth {
width: 100%;
.role-selector-content {
display: flex;
gap: 12px;
align-items: stretch;
min-height: 460px;
}
:deep(.el-tabs--border-card > .el-tabs__content) {
padding: 5px;
.selector-tree-col {
width: 220px;
flex-shrink: 0;
height: 460px;
}
.selector-table-col {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.choose-header-bar {
display: flex;
align-items: center;
justify-content: space-between;
height: 64px;
padding: 0 16px;
margin-bottom: 10px;
box-sizing: border-box;
.choose-header-left {
display: flex;
align-items: center;
}
.choose-title {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.max-count-tip {
margin-left: 6px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
.role-selector-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding-top: 14px;
border-top: 1px solid var(--el-border-color-lighter);
}
:deep(.filter) {
width: 100% !important;
height: 100% !important;
margin-right: 0 !important;
padding: 14px !important;
}
:deep(.card) {
padding: 14px;
}
:deep(.table-search) {
padding: 14px 14px 0 !important;
margin-bottom: 10px !important;
}
:deep(.table-main) {
height: 90%;
flex: 1;
display: flex;
flex-direction: column;
}
:deep(.el-pagination) {
margin-top: 12px;
}
:deep(.table-header) {
.header-button-lf,
.header-button-ri {
margin-bottom: 10px;
}
}
</style>
@@ -27,6 +27,8 @@ export interface RoleSelectProps {
multiple?: boolean;
/** 最大角色数 */
maxCount?: number;
/** 弹窗宽度尺寸 */
formSize?: string;
}
/** 角色选择器表格初始化参数 */
+59
View File
@@ -0,0 +1,59 @@
import { ref } from "vue";
import {
onboardingTaskApi,
type OnboardingDepartmentOption,
type OnboardingPositionOption
} from "@/api/modules/biz/onboarding";
type ScopeSelection = { companyId?: number; departmentId?: number; positionId?: number };
/** 公司、部门、岗位只按当前选择加载;过期响应不能覆盖新选择。 */
export function useOnboardingScopeOptions(
getSelection: () => ScopeSelection | undefined,
syncOptions?: () => void
) {
const departments = ref<OnboardingDepartmentOption[]>([]);
const positions = ref<OnboardingPositionOption[]>([]);
let companyRequest = 0;
let departmentRequest = 0;
function reset() {
companyRequest++;
departmentRequest++;
departments.value = [];
positions.value = [];
const selection = getSelection();
if (selection) {
selection.departmentId = undefined;
selection.positionId = undefined;
}
syncOptions?.();
}
async function onCompanyChange(companyId?: number) {
reset();
if (!companyId) return;
const request = companyRequest;
const { data } = await onboardingTaskApi.listDepartments(companyId).catch(() => ({ data: [] }));
if (request !== companyRequest || getSelection()?.companyId !== companyId) return;
departments.value = data || [];
syncOptions?.();
}
async function onDepartmentChange(departmentId?: number) {
const request = ++departmentRequest;
positions.value = [];
const selection = getSelection();
if (selection) selection.positionId = undefined;
syncOptions?.();
const companyId = selection?.companyId;
if (!companyId || !departmentId) return;
const { data } = await onboardingTaskApi.listPositionsByDepartment(departmentId).catch(() => ({ data: [] }));
const current = getSelection();
if (request !== departmentRequest || current?.companyId !== companyId || current?.departmentId !== departmentId) return;
positions.value = data || [];
syncOptions?.();
}
return { departments, positions, onCompanyChange, onDepartmentChange, reset };
}
+2
View File
@@ -18,6 +18,8 @@ import "element-plus/theme-chalk/dark/css-vars.css";
import "@/styles/element-dark.scss";
// custom element css
import "@/styles/element.scss";
// custom mobile feedback (Message / MessageBox / Dialog on mobile screens)
import "@/styles/mobile-feedback.scss";
// svg icons
import "virtual:svg-icons-register";
// element plus
+5
View File
@@ -27,9 +27,14 @@ import errorHandler from "@/utils/errorHandler";
import "@/styles/reset.scss";
import "@/styles/common.scss";
import "element-plus/dist/index.css";
import "@/styles/mobile-feedback.scss";
import "virtual:uno.css";
import "default-passive-events";
if (typeof document !== "undefined") {
document.documentElement.classList.add("is-public-app");
}
const app = createApp(PublicApp);
const publicIcons = {
Briefcase,
+403
View File
@@ -0,0 +1,403 @@
/**
* 移动端专属提示与弹窗交互体系
* 1. 在屏幕宽度 <= 768px 时对全局生效(手机端体验极致优化)
* 2. 在员工公开入职端 (html.is-public-app) 全面生效(无论手机真机或 PC 浏览器预览入职均享有一致的精致体验)
* 3. 严格隔离 PC 端后台管理系统,保证 PC 管理端在桌面大屏下 100% 保持标准桌面交互规范。
*/
@mixin mobile-feedback-rules {
/* ==========================================================================
1. ElMessage 移动端悬浮胶囊 Toast
========================================================================== */
.el-message {
min-width: unset !important;
width: max-content !important;
max-width: calc(100vw - 32px) !important;
padding: 10px 18px !important;
border-radius: 9999px !important;
box-shadow: 0 10px 25px -4px rgba(0, 0, 0, 0.12), 0 4px 10px -2px rgba(0, 0, 0, 0.05) !important;
backdrop-filter: blur(14px) !important;
-webkit-backdrop-filter: blur(14px) !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
box-sizing: border-box !important;
left: 50% !important;
transform: translateX(-50%) !important;
/* 适配刘海屏及微信导航栏安全区 */
margin-top: env(safe-area-inset-top, 14px) !important;
transition: top 0.3s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.25s ease, transform 0.25s ease !important;
pointer-events: auto !important;
.el-message__icon {
margin-right: 8px !important;
font-size: 18px !important;
flex-shrink: 0 !important;
}
.el-message__content {
font-size: 14px !important;
line-height: 1.45 !important;
font-weight: 500 !important;
word-break: break-word !important;
text-align: center !important;
padding: 0 !important;
}
.el-message__closeBtn {
margin-left: 10px !important;
font-size: 14px !important;
flex-shrink: 0 !important;
}
/* 品牌成功绿 (Emerald) */
&.el-message--success {
background: rgba(240, 253, 244, 0.96) !important;
border: 1px solid #86efac !important;
.el-message__icon {
color: #059669 !important;
}
.el-message__content {
color: #064e3b !important;
font-weight: 600 !important;
}
.el-message__closeBtn {
color: #059669 !important;
}
}
/* 品牌暖琥珀黄 (Amber) */
&.el-message--warning {
background: rgba(254, 252, 232, 0.96) !important;
border: 1px solid #fde047 !important;
.el-message__icon {
color: #d97706 !important;
}
.el-message__content {
color: #78350f !important;
font-weight: 600 !important;
}
.el-message__closeBtn {
color: #d97706 !important;
}
}
/* 柔和玫瑰红 (Rose) */
&.el-message--error {
background: rgba(255, 241, 242, 0.96) !important;
border: 1px solid #fca5a5 !important;
.el-message__icon {
color: #e11d48 !important;
}
.el-message__content {
color: #881337 !important;
font-weight: 600 !important;
}
.el-message__closeBtn {
color: #e11d48 !important;
}
}
/* 晨雾优雅蓝灰 (Slate) */
&.el-message--info {
background: rgba(248, 250, 252, 0.96) !important;
border: 1px solid #cbd5e1 !important;
.el-message__icon {
color: #059669 !important;
}
.el-message__content {
color: #1e293b !important;
font-weight: 600 !important;
}
.el-message__closeBtn {
color: #64748b !important;
}
}
}
/* ==========================================================================
2. ElMessageBox 移动端原生卡片弹框 (Confirm / Alert)
========================================================================== */
.el-overlay.is-message-box {
background-color: rgba(15, 23, 42, 0.52) !important;
backdrop-filter: blur(5px) !important;
-webkit-backdrop-filter: blur(5px) !important;
.el-overlay-message-box {
display: flex !important;
align-items: center !important;
justify-content: center !important;
padding: 16px !important;
box-sizing: border-box !important;
/* 清除 PC 端 inline-block + vertical-align 伪类居中 */
&::after {
display: none !important;
}
}
}
.el-message-box {
display: flex !important;
flex-direction: column !important;
width: min(calc(100vw - 36px), 336px) !important;
max-width: 336px !important;
padding: 24px 20px 20px !important;
border-radius: 20px !important;
border: 1px solid rgba(255, 255, 255, 0.9) !important;
background: #ffffff !important;
box-shadow: 0 20px 45px -10px rgba(6, 78, 59, 0.18), 0 10px 20px -5px rgba(0, 0, 0, 0.08) !important;
text-align: center !important;
overflow: hidden !important;
box-sizing: border-box !important;
animation: mobileBoxPop 0.26s cubic-bezier(0.34, 1.56, 0.64, 1) !important;
/* 弹框头部 */
.el-message-box__header {
padding: 0 0 10px !important;
position: relative !important;
border-bottom: none !important;
.el-message-box__title {
justify-content: center !important;
text-align: center !important;
span {
font-size: 18px !important;
font-weight: 700 !important;
color: #064e3b !important;
letter-spacing: 0.3px !important;
}
}
/* 隐藏 PC 端右上角极小叉号,避免手机端误触 */
.el-message-box__headerbtn {
display: none !important;
}
}
/* 弹框内容区 */
.el-message-box__content {
padding: 6px 4px 20px !important;
text-align: center !important;
}
.el-message-box__container {
display: flex !important;
flex-direction: column !important;
align-items: center !important;
justify-content: center !important;
gap: 12px !important;
}
/* 拟物状态大徽标 */
.el-message-box__status {
position: static !important;
transform: none !important;
margin: 0 auto 4px !important;
font-size: 30px !important;
width: 52px !important;
height: 52px !important;
border-radius: 50% !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
&.el-message-box-icon--success {
color: #059669 !important;
background: #ecfdf5 !important;
border: 2px solid #a7f3d0 !important;
}
&.el-message-box-icon--warning {
color: #d97706 !important;
background: #fffbeb !important;
border: 2px solid #fde68a !important;
}
&.el-message-box-icon--error {
color: #e11d48 !important;
background: #fff1f2 !important;
border: 2px solid #fecdd3 !important;
}
&.el-message-box-icon--info {
color: #059669 !important;
background: #f0fdf7 !important;
border: 2px solid #d1fae5 !important;
}
}
.el-message-box__message {
padding: 0 !important;
text-align: center !important;
p {
margin: 0 !important;
font-size: 14px !important;
line-height: 1.65 !important;
color: #475569 !important;
word-break: break-word !important;
}
}
/* 按钮操作区 (44pt 移动端人机工效大按钮) */
.el-message-box__btns {
display: flex !important;
flex-direction: row !important;
gap: 10px !important;
padding: 0 !important;
justify-content: center !important;
.el-button {
flex: 1 1 0 !important;
min-width: 0 !important;
margin: 0 !important;
height: 44px !important;
min-height: 44px !important;
border-radius: 12px !important;
font-size: 15px !important;
font-weight: 600 !important;
box-sizing: border-box !important;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1) !important;
cursor: pointer !important;
/* 点击微触觉缩放反馈 */
&:active {
transform: scale(0.97) !important;
}
}
/* 次要/取消按钮 ("再检查一下"、"取消") */
.el-button:not(.el-button--primary) {
background: #f1f5f9 !important;
border: 1px solid #e2e8f0 !important;
color: #475569 !important;
&:active {
background: #e2e8f0 !important;
color: #1e293b !important;
}
}
/* 主要/确认按钮 ("确认提交"、"我知道了") */
.el-button--primary {
background: linear-gradient(135deg, #059669 0%, #047857 100%) !important;
border: none !important;
color: #ffffff !important;
box-shadow: 0 4px 12px rgba(5, 150, 105, 0.3) !important;
&:active {
background: linear-gradient(135deg, #047857 0%, #065f46 100%) !important;
box-shadow: 0 2px 6px rgba(5, 150, 105, 0.2) !important;
}
}
}
}
/* ==========================================================================
3. ElAlert 移动端微卡片优化
========================================================================== */
.el-alert {
border-radius: 12px !important;
padding: 10px 14px !important;
box-sizing: border-box !important;
.el-alert__title {
font-size: 13px !important;
line-height: 1.5 !important;
}
.el-alert__description {
font-size: 12px !important;
line-height: 1.55 !important;
margin-top: 4px !important;
}
&.el-alert--info {
background-color: #f0fdf4 !important;
border: 1px solid #bbf7d0 !important;
color: #166534 !important;
.el-alert__icon {
color: #059669 !important;
}
}
&.el-alert--success {
background-color: #ecfdf5 !important;
border: 1px solid #a7f3d0 !important;
color: #065f46 !important;
.el-alert__icon {
color: #059669 !important;
}
}
&.el-alert--warning {
background-color: #fffbeb !important;
border: 1px solid #fde68a !important;
color: #92400e !important;
.el-alert__icon {
color: #d97706 !important;
}
}
&.el-alert--error {
background-color: #fff1f2 !important;
border: 1px solid #fecdd3 !important;
color: #9f1239 !important;
.el-alert__icon {
color: #e11d48 !important;
}
}
}
/* ==========================================================================
4. ElDialog 移动端通用轻量圆角抽屉/卡片
========================================================================== */
.el-dialog {
width: min(calc(100vw - 32px), 520px) !important;
max-width: calc(100vw - 32px) !important;
margin: 20px auto !important;
border-radius: 18px !important;
overflow: hidden !important;
box-shadow: 0 20px 40px -10px rgba(0, 0, 0, 0.15) !important;
}
}
/* 屏幕宽度 <= 768px(手机、垂直平板)生效 */
@media screen and (max-width: 768px) {
@include mobile-feedback-rules;
}
/* 员工入职前端(无论手机真机或 PC 浏览器预览均享有该高级样式) */
html.is-public-app {
@include mobile-feedback-rules;
}
/* 移动端弹窗弹出平滑缩放进入动画 */
@keyframes mobileBoxPop {
0% {
opacity: 0;
transform: scale(0.92);
}
100% {
opacity: 1;
transform: scale(1);
}
}
+152 -106
View File
@@ -5,6 +5,7 @@
title="签署材料模板列表"
:columns="templateColumns"
:request-api="onboardingTaskApi.templatePage"
@reset="resetSearchScope"
>
<template #tableHeader>
<el-button v-auth="'onboardingContractAdd'" type="primary" icon="Plus" @click="openTemplateCreate">
@@ -174,7 +175,7 @@
<el-drawer
v-model="drawerVisible"
:title="`签署材料模板管理 · ${selectedTemplate?.templateName || ''}`"
size="1150px"
size="min(1500px, 96vw)"
destroy-on-close
>
<div v-if="selectedTemplate" class="drawer-content-box">
@@ -211,7 +212,7 @@
上传新版本 DOCX
</el-button>
<span class="pane-tip-text">
支持多版本迭代。启用某个版本后,该版本的 DOCX 将用于生成新入职员工的签署材料。
支持多版本迭代。启用版本后用于生成新入职员工的签署材料;盖章开关的修改仅影响后续新建任务。
</span>
</div>
@@ -230,8 +231,37 @@
</template>
</el-table-column>
<el-table-column prop="effectiveDate" label="生效日期" width="130" align="center" />
<el-table-column prop="expiryDate" label="失效日期" width="130" align="center" />
<el-table-column label="公司盖章" width="100" align="center">
<template #default="scope">
<el-switch
:model-value="scope.row.companySealEnabled === true"
:loading="sealUpdating[scope.row.id] === 'companySealEnabled'"
:disabled="!canEditVersionSeals || !!sealUpdating[scope.row.id]"
:title="versionSealDisabledReason"
aria-label="公司盖章"
@change="value => changeVersionSeal(scope.row, 'companySealEnabled', value)"
/>
</template>
</el-table-column>
<el-table-column label="骑缝章" width="100" align="center">
<template #default="scope">
<el-switch
:model-value="scope.row.acrossPageSealEnabled === true"
:loading="sealUpdating[scope.row.id] === 'acrossPageSealEnabled'"
:disabled="!canEditVersionSeals || !!sealUpdating[scope.row.id]"
:title="versionSealDisabledReason"
aria-label="骑缝章"
@change="value => changeVersionSeal(scope.row, 'acrossPageSealEnabled', value)"
/>
</template>
</el-table-column>
<el-table-column label="生效时间" width="175" align="center">
<template #default="scope">{{ formatVersionTime(scope.row.effectiveDate) }}</template>
</el-table-column>
<el-table-column label="失效时间" width="175" align="center">
<template #default="scope">{{ formatVersionTime(scope.row.expiryDate) }}</template>
</el-table-column>
<el-table-column prop="remark" label="版本说明" min-width="150" />
<el-table-column label="操作" width="340" fixed="right" align="center">
@@ -311,6 +341,7 @@
v-model="versionVisible"
title="上传新版本 DOCX"
width="600px"
top="5vh"
destroy-on-close
append-to-body
>
@@ -344,14 +375,41 @@
</div>
</el-form-item>
<el-form-item label="生效日期">
<el-date-picker v-model="versionForm.effectiveDate" value-format="YYYY-MM-DD" class="w-full" />
<el-form-item label="生效时间">
<el-date-picker
v-model="versionForm.effectiveDate"
type="datetime"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="请选择生效时间"
class="w-full"
/>
</el-form-item>
<el-form-item label="失效日期">
<el-date-picker v-model="versionForm.expiryDate" value-format="YYYY-MM-DD" class="w-full" />
<el-form-item label="失效时间">
<el-date-picker
v-model="versionForm.expiryDate"
type="datetime"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="请选择失效时间"
class="w-full"
/>
</el-form-item>
<el-form-item label="公司盖章">
<el-switch v-model="versionForm.companySealEnabled" aria-label="公司盖章" />
</el-form-item>
<el-form-item label="骑缝章">
<el-switch v-model="versionForm.acrossPageSealEnabled" aria-label="骑缝章" />
</el-form-item>
<el-alert
title="所有材料均需员工签字。公司盖章和骑缝章可在版本列表调整,修改仅影响后续新建任务,已有任务和签约保持原配置。"
type="info"
:closable="false"
style="margin-bottom: 16px"
/>
<el-form-item label="版本备注">
<el-input v-model="versionForm.remark" type="textarea" :rows="2" placeholder="本次版本修订说明" />
</el-form-item>
@@ -476,7 +534,7 @@
</el-form-item>
<el-form-item label="变量类型" required>
<el-select v-model="variableForm.variableType" class="w-full">
<el-select v-model="variableForm.variableType" disabled class="w-full">
<el-option label="员工填写(从新员工基本信息/动态字段读取)" value="employee" />
<el-option label="公司信息(从公司档案自动读取)" value="company" />
<el-option label="人事预设填写(人事发起时录入)" value="contract" />
@@ -485,7 +543,7 @@
</el-form-item>
<el-form-item label="数据类型" required>
<el-select v-model="variableForm.dataType" class="w-full">
<el-select v-model="variableForm.dataType" disabled class="w-full">
<el-option label="文本" value="string" />
<el-option label="日期" value="date" />
<el-option label="数字" value="number" />
@@ -494,7 +552,7 @@
</el-form-item>
<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" disabled placeholder="如:EmployeeName、IdCardNumber、Phone" />
</el-form-item>
<el-form-item label="默认值">
@@ -527,8 +585,10 @@
</template>
<script setup lang="ts" name="onboardingContract">
import { useOnboardingScopeOptions } from "@/hooks/useOnboardingScopeOptions";
import { computed, onMounted, reactive, ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import dayjs from "dayjs";
import { Check } from "@element-plus/icons-vue";
import {
onboardingTaskApi,
@@ -544,7 +604,7 @@ import { isMessageBoxConfirmed } from "@/utils/messageBox";
import { useOnboardingPermission } from "@/utils/onboardingPermission";
import { copyToClipboard } from "@/utils";
const { canManage, disabledReason } = useOnboardingPermission("onboardingContractManageAll");
const { canManage, disabledReason, hasAction } = useOnboardingPermission("onboardingContractManageAll");
const templateTable = ref<ProTableInstance>();
const selectedTemplate = ref<OnboardingContractTemplate>();
@@ -557,10 +617,19 @@ const companies = ref<OnboardingCompanyOption[]>([]);
const departments = ref<OnboardingDepartmentOption[]>([]);
const positions = ref<OnboardingPositionOption[]>([]);
const searchCompanies = ref<OnboardingCompanyOption[]>([]);
const searchDepartments = ref<OnboardingDepartmentOption[]>([]);
const searchPositions = ref<OnboardingPositionOption[]>([]);
const allSearchDepartments = ref<OnboardingDepartmentOption[]>([]);
const allSearchPositions = ref<OnboardingPositionOption[]>([]);
const {
departments: searchDepartments,
positions: searchPositions,
onCompanyChange: onSearchCompanyChange,
onDepartmentChange: onSearchDepartmentChange,
reset: resetSearchScope
} = useOnboardingScopeOptions(
() => templateTable.value?.searchParam,
() => {
templateTable.value?.enumMap.set("departmentId", departmentSearchOptions.value);
templateTable.value?.enumMap.set("positionId", positionSearchOptions.value);
}
);
const companySearchOptions = computed(() =>
searchCompanies.value.map(c => ({
@@ -583,59 +652,17 @@ const positionSearchOptions = computed(() =>
}))
);
async function onSearchCompanyChange(companyId?: number) {
if (companyId) {
const [depRes, posRes] = await Promise.all([
onboardingTaskApi.listDepartments(companyId).catch(() => ({ data: [] })),
onboardingTaskApi.listPositions(companyId).catch(() => ({ data: [] }))
]);
searchDepartments.value = depRes.data || [];
searchPositions.value = posRes.data || [];
} else {
searchDepartments.value = allSearchDepartments.value;
searchPositions.value = allSearchPositions.value;
}
templateTable.value?.enumMap.set("departmentId", departmentSearchOptions.value);
templateTable.value?.enumMap.set("positionId", positionSearchOptions.value);
if (templateTable.value?.searchParam) {
const currentDept = templateTable.value.searchParam.departmentId;
if (currentDept && !searchDepartments.value.some(d => d.id === currentDept)) {
templateTable.value.searchParam.departmentId = undefined;
}
const currentPos = templateTable.value.searchParam.positionId;
if (currentPos && !searchPositions.value.some(p => p.id === currentPos)) {
templateTable.value.searchParam.positionId = undefined;
}
}
}
async function onSearchDepartmentChange(departmentId?: number) {
if (departmentId) {
const posRes = await onboardingTaskApi.listPositionsByDepartment(departmentId).catch(() => ({ data: [] }));
searchPositions.value = posRes.data || [];
} else {
const selectedCompanyId = templateTable.value?.searchParam?.companyId;
if (selectedCompanyId) {
const posRes = await onboardingTaskApi.listPositions(selectedCompanyId).catch(() => ({ data: [] }));
searchPositions.value = posRes.data || [];
} else {
searchPositions.value = allSearchPositions.value;
}
}
templateTable.value?.enumMap.set("positionId", positionSearchOptions.value);
if (templateTable.value?.searchParam) {
const currentPos = templateTable.value.searchParam.positionId;
if (currentPos && !searchPositions.value.some(p => p.id === currentPos)) {
templateTable.value.searchParam.positionId = undefined;
}
}
}
const versions = ref<OnboardingContractVersion[]>([]);
type VersionSealField = "companySealEnabled" | "acrossPageSealEnabled";
const sealUpdating = reactive<Record<number, VersionSealField | undefined>>({});
const canEditVersionSeals = computed(() =>
canManage(selectedTemplate.value) && hasAction("onboardingContractEdit")
);
const versionSealDisabledReason = computed(() =>
!hasAction("onboardingContractEdit")
? "没有编辑签署材料模板的权限"
: disabledReason(selectedTemplate.value)
);
const versionVisible = ref(false);
const variablesVisible = ref(false);
const variables = ref<OnboardingTemplateVariable[]>([]);
@@ -671,11 +698,18 @@ const emptyTemplate = (): Partial<OnboardingContractTemplate> => ({
description: ""
});
const emptyVersion = (): Partial<OnboardingContractVersion> => ({
type OnboardingVersionForm = Partial<OnboardingContractVersion> & {
companySealEnabled: boolean;
acrossPageSealEnabled: boolean;
};
const emptyVersion = (): OnboardingVersionForm => ({
versionNo: "",
sourceDocxFileId: undefined,
effectiveDate: undefined,
expiryDate: undefined,
companySealEnabled: true,
acrossPageSealEnabled: true,
remark: ""
});
@@ -693,7 +727,7 @@ const emptyVariable = (): Partial<OnboardingTemplateVariable> => ({
});
const templateForm = reactive<Partial<OnboardingContractTemplate>>(emptyTemplate());
const versionForm = reactive<Partial<OnboardingContractVersion>>(emptyVersion());
const versionForm = reactive<OnboardingVersionForm>(emptyVersion());
const variableForm = reactive<Partial<OnboardingTemplateVariable>>(emptyVariable());
const templateColumns: ColumnProps<OnboardingContractTemplate>[] = [
@@ -722,7 +756,12 @@ const templateColumns: ColumnProps<OnboardingContractTemplate>[] = [
props: {
filterable: true,
clearable: true,
placeholder: "请选择所属部门",
get placeholder() {
return templateTable.value?.searchParam?.companyId ? "请选择所属部门" : "请先选择所属公司";
},
get disabled() {
return !templateTable.value?.searchParam?.companyId;
},
onChange: (val?: number) => onSearchDepartmentChange(val)
}
}
@@ -734,7 +773,16 @@ const templateColumns: ColumnProps<OnboardingContractTemplate>[] = [
enum: positionSearchOptions,
search: {
el: "select",
props: { filterable: true, clearable: true, placeholder: "请选择适用岗位" }
props: {
filterable: true,
clearable: true,
get placeholder() {
return templateTable.value?.searchParam?.departmentId ? "请选择适用岗位" : "请先选择所属部门";
},
get disabled() {
return !templateTable.value?.searchParam?.departmentId;
}
}
}
},
{ prop: "templateCode", label: "模板编码", minWidth: 160, search: { el: "input" } },
@@ -892,10 +940,21 @@ async function uploadDocx(options: any) {
}
}
function formatVersionTime(value?: string) {
return value ? dayjs(value).format("YYYY-MM-DD HH:mm:ss") : "--";
}
async function saveVersion() {
if (!selectedTemplate.value || !versionForm.versionNo || !versionForm.sourceDocxFileId) {
return ElMessage.warning("请填写版本号并上传 DOCX 文件");
}
if (
versionForm.effectiveDate
&& versionForm.expiryDate
&& !dayjs(versionForm.expiryDate).isAfter(dayjs(versionForm.effectiveDate))
) {
return ElMessage.warning("失效时间必须晚于生效时间");
}
await onboardingTaskApi.addVersion({ ...versionForm, templateId: selectedTemplate.value.id });
versionVisible.value = false;
await loadVersions();
@@ -908,6 +967,27 @@ async function changeVersionStatus(row: OnboardingContractVersion, status: strin
ElMessage.success(`版本状态已更新为:${versionText(status)}`);
}
async function changeVersionSeal(
row: OnboardingContractVersion,
field: VersionSealField,
enabled: string | number | boolean
) {
if (typeof enabled !== "boolean" || !canEditVersionSeals.value || sealUpdating[row.id]) return;
sealUpdating[row.id] = field;
try {
// 只提交当前开关;另一个开关和历史任务快照均保持不变。
await onboardingTaskApi.setVersionSeals({ id: row.id, [field]: enabled });
const currentRow = versions.value.find(item => item.id === row.id);
if (currentRow) currentRow[field] = enabled;
ElMessage.success(`${field === "companySealEnabled" ? "公司盖章" : "骑缝章"}已${enabled ? "开启" : "关闭"},仅对后续新建任务生效`);
} catch {
// 请求层统一显示错误;保存失败时保留原开关状态。
} finally {
delete sealUpdating[row.id];
}
}
async function removeVersion(row: OnboardingContractVersion) {
const confirmed = await isMessageBoxConfirmed(
ElMessageBox.confirm(`确认删除签署材料版本“${row.versionNo}”?此操作不可撤销。`, "删除确认", { type: "warning" })
@@ -1031,43 +1111,9 @@ async function saveVariable() {
}
async function loadSearchOptions() {
try {
await loadCompanies();
searchCompanies.value = companies.value;
const deptList: OnboardingDepartmentOption[] = [];
const posList: OnboardingPositionOption[] = [];
if (companies.value && companies.value.length > 0) {
const promises = companies.value.map(async comp => {
const [depRes, posRes] = await Promise.all([
onboardingTaskApi.listDepartments(comp.id).catch(() => ({ data: [] })),
onboardingTaskApi.listPositions(comp.id).catch(() => ({ data: [] }))
]);
if (depRes.data) deptList.push(...depRes.data);
if (posRes.data) posList.push(...posRes.data);
});
await Promise.all(promises);
}
const uniqueDeptMap = new Map<number, OnboardingDepartmentOption>();
deptList.forEach(d => uniqueDeptMap.set(d.id, d));
const finalDepts = Array.from(uniqueDeptMap.values());
allSearchDepartments.value = finalDepts;
searchDepartments.value = finalDepts;
const uniquePosMap = new Map<number, OnboardingPositionOption>();
posList.forEach(p => uniquePosMap.set(p.id, p));
const finalPositions = Array.from(uniquePosMap.values());
allSearchPositions.value = finalPositions;
searchPositions.value = finalPositions;
templateTable.value?.enumMap.set("companyId", companySearchOptions.value);
templateTable.value?.enumMap.set("departmentId", departmentSearchOptions.value);
templateTable.value?.enumMap.set("positionId", positionSearchOptions.value);
} catch (e) {
console.error("加载签署材料搜索选项失败", e);
}
}
onMounted(loadSearchOptions);
+399 -119
View File
@@ -1,6 +1,6 @@
<template>
<div class="table-box">
<ProTable ref="proTable" title="员工档案" :columns="columns" :request-api="getTableData">
<ProTable ref="proTable" title="员工档案" :columns="columns" :request-api="getTableData" @reset="resetSearchScope">
<template #tableHeader>
<div class="employee-table-toolbar">
<el-button v-auth="'onboardingEmployeeExport'" type="primary" plain icon="Download" :loading="exporting" @click="exportEmployees">
@@ -162,7 +162,7 @@
<el-descriptions-item label="所属岗位">{{
detail.employee.positionName || employeeInfo.onboardingPosition || "-"
}}</el-descriptions-item>
<el-descriptions-item label="员工编号">{{ detail.employee.employeeNo || employeeInfo.employeeNo || "-" }}</el-descriptions-item>
<el-descriptions-item label="员工编号">{{ resolvedEmployeeNo }}</el-descriptions-item>
<el-descriptions-item label="入职日期">{{ formatDate(detail.employee.entryDate) }}</el-descriptions-item>
<el-descriptions-item label="员工司龄">{{ formatSeniority(detail.employee.entryDate) }}</el-descriptions-item>
<el-descriptions-item label="用工类型">{{ employmentTypeText(detail.employee.employmentType) }}</el-descriptions-item>
@@ -243,19 +243,54 @@
:key="record.id"
:timestamp="formatTime(record.createTime)"
placement="top"
type="primary"
:type="changeTimelineType(record.changeType)"
>
<div class="change-record-card">
<!-- 顶部信息:操作类型标签 + 生效日期 + 操作人 -->
<div class="change-record-header">
<strong>{{ changeTypeText(record.changeType) }}</strong>
<span>{{ record.createUser || "系统" }}</span>
<div class="change-record-title-wrap">
<el-tag :type="changeTypeTagType(record.changeType)" size="default" effect="light" class="change-type-tag">
{{ changeTypeText(record.changeType) }}
</el-tag>
<span v-if="record.effectiveDate" class="change-effective-badge">
<el-icon class="mr-1"><Calendar /></el-icon>生效于 {{ formatDate(record.effectiveDate) }}
</span>
</div>
<div v-if="record.effectiveDate" class="change-record-line">
生效日期:{{ formatDate(record.effectiveDate) }}
<div class="change-operator">
<el-icon class="operator-icon"><User /></el-icon>
<span class="operator-name">{{ record.createUser || "系统" }}</span>
</div>
<div v-if="record.remark" class="change-record-line">
备注:{{ record.remark }}
</div>
<!-- 变更明细:轻量 Diff 流向列表 -->
<div v-if="hasChangeSnapshot(record)" class="change-diff-flow">
<div
v-for="diff in changeDiffRows(record)"
:key="diff.key"
class="change-diff-item"
>
<span class="diff-field-label">{{ diff.label }}</span>
<div class="diff-values">
<span class="diff-val-before">{{ diff.beforeText }}</span>
<el-icon class="diff-arrow"><Right /></el-icon>
<span class="diff-val-after">{{ diff.afterText }}</span>
</div>
</div>
<div v-if="!changeDiffRows(record).length" class="change-diff-none">
前后字段值无实质差异
</div>
</div>
<div v-else class="change-record-empty">
该记录未保存修改前后快照
</div>
<!-- 真实备注(过滤掉无意义系统默认同义反复文案) -->
<div v-if="shouldShowRemark(record)" class="change-remark-box">
<el-icon class="remark-icon"><ChatDotRound /></el-icon>
<span class="remark-text">{{ record.remark }}</span>
</div>
<!-- 附件列表 -->
<div v-if="record.attachments?.length" class="change-attachments">
<el-button
v-for="file in record.attachments"
@@ -456,6 +491,7 @@
filterable
allow-create
clearable
:value-on-clear="''"
default-first-option
placeholder="请选择或输入合同性质"
class="full-width"
@@ -518,6 +554,7 @@
v-model:file-list="regularizationFiles"
multiple
:limit="10"
:on-exceed="handleRegularizationExceed"
:http-request="uploadRegularizationFile"
:before-upload="beforeRegularizationUpload"
:on-remove="removeRegularizationFile"
@@ -539,14 +576,18 @@
</template>
<script setup lang="ts" name="onboardingEmployee">
import { useOnboardingScopeOptions } from "@/hooks/useOnboardingScopeOptions";
import { computed, onBeforeUnmount, onMounted, reactive, ref } from "vue";
import {
Briefcase,
Calendar,
ChatDotRound,
Clock,
Connection,
Document,
Download,
FirstAidKit,
Right,
User,
UserFilled,
WarningFilled
@@ -556,9 +597,7 @@ import type { UploadFile, UploadRequestOptions, UploadUserFile } from "element-p
import {
onboardingTaskApi,
type OnboardingEmployee,
type OnboardingCompanyOption,
type OnboardingDepartmentOption,
type OnboardingPositionOption
type OnboardingCompanyOption
} from "@/api/modules/biz/onboarding";
import type { ColumnProps, ProTableInstance } from "@/components/ProTable/interface";
import { getMessageBoxPromptResult, isMessageBoxConfirmed } from "@/utils/messageBox";
@@ -724,6 +763,34 @@ const regularizationForm = reactive({
remark: ""
});
const employeeInfo = computed<Record<string, any>>(() => detail.value?.employeeInfo || {});
const resolvedEmployeeNo = computed(() => {
if (detail.value?.employee?.employeeNo && String(detail.value.employee.employeeNo).trim()) {
return String(detail.value.employee.employeeNo).trim();
}
const snapshots = detail.value?.contractSnapshots || [];
for (const item of snapshots) {
if (item?.employeeNo && String(item.employeeNo).trim()) {
return String(item.employeeNo).trim();
}
if (item?.variablesJson) {
try {
const vars = typeof item.variablesJson === "string" ? JSON.parse(item.variablesJson) : item.variablesJson;
const target = vars?.["hr.EmployeeNo"] || vars?.["hr.employeeno"];
const val = typeof target === "object" ? target?.variableValue : target;
if (val && String(val).trim()) {
return String(val).trim();
}
} catch {
// 忽略变量快照反序列化异常
}
}
}
if (employeeInfo.value?.employeeNo && String(employeeInfo.value.employeeNo).trim()) {
return String(employeeInfo.value.employeeNo).trim();
}
return "-";
});
const materialPreviewUrls = reactive<Record<number, string>>({});
const materialPreviewLoading = reactive<Record<number, boolean>>({});
const materialPreviewFailed = reactive<Record<number, boolean>>({});
@@ -738,10 +805,19 @@ const imageMimeTypes: Record<string, string> = {
};
const searchCompanies = ref<OnboardingCompanyOption[]>([]);
const searchDepartments = ref<OnboardingDepartmentOption[]>([]);
const searchPositions = ref<OnboardingPositionOption[]>([]);
const allSearchDepartments = ref<OnboardingDepartmentOption[]>([]);
const allSearchPositions = ref<OnboardingPositionOption[]>([]);
const {
departments: searchDepartments,
positions: searchPositions,
onCompanyChange: onSearchCompanyChange,
onDepartmentChange: onSearchDepartmentChange,
reset: resetSearchScope
} = useOnboardingScopeOptions(
() => proTable.value?.searchParam,
() => {
proTable.value?.enumMap.set("departmentId", departmentSearchOptions.value);
proTable.value?.enumMap.set("positionId", positionSearchOptions.value);
}
);
const companySearchOptions = computed(() =>
searchCompanies.value.map(c => ({
@@ -764,58 +840,6 @@ const positionSearchOptions = computed(() =>
}))
);
async function onSearchCompanyChange(companyId?: number) {
if (companyId) {
const [depRes, posRes] = await Promise.all([
onboardingTaskApi.listDepartments(companyId).catch(() => ({ data: [] })),
onboardingTaskApi.listPositions(companyId).catch(() => ({ data: [] }))
]);
searchDepartments.value = depRes.data || [];
searchPositions.value = posRes.data || [];
} else {
searchDepartments.value = allSearchDepartments.value;
searchPositions.value = allSearchPositions.value;
}
proTable.value?.enumMap.set("departmentId", departmentSearchOptions.value);
proTable.value?.enumMap.set("positionId", positionSearchOptions.value);
if (proTable.value?.searchParam) {
const currentDept = proTable.value.searchParam.departmentId;
if (currentDept && !searchDepartments.value.some(d => d.id === currentDept)) {
proTable.value.searchParam.departmentId = undefined;
}
const currentPos = proTable.value.searchParam.positionId;
if (currentPos && !searchPositions.value.some(p => p.id === currentPos)) {
proTable.value.searchParam.positionId = undefined;
}
}
}
async function onSearchDepartmentChange(departmentId?: number) {
if (departmentId) {
const posRes = await onboardingTaskApi.listPositionsByDepartment(departmentId).catch(() => ({ data: [] }));
searchPositions.value = posRes.data || [];
} else {
const selectedCompanyId = proTable.value?.searchParam?.companyId;
if (selectedCompanyId) {
const posRes = await onboardingTaskApi.listPositions(selectedCompanyId).catch(() => ({ data: [] }));
searchPositions.value = posRes.data || [];
} else {
searchPositions.value = allSearchPositions.value;
}
}
proTable.value?.enumMap.set("positionId", positionSearchOptions.value);
if (proTable.value?.searchParam) {
const currentPos = proTable.value.searchParam.positionId;
if (currentPos && !searchPositions.value.some(p => p.id === currentPos)) {
proTable.value.searchParam.positionId = undefined;
}
}
}
function parseJsonList(value?: string) {
if (!value) return [];
@@ -911,10 +935,10 @@ function safeDownloadName(value: string) {
}
function getEmployeeDownloadPrefix(): string {
const empNo = resolvedEmployeeNo.value !== "-" ? resolvedEmployeeNo.value : "";
const taskNo = String(
detail.value?.task?.taskNo
|| detail.value?.employee?.employeeNo
|| detail.value?.employeeInfo?.employeeNo
|| empNo
|| ""
).trim();
const employeeName = String(
@@ -1015,7 +1039,12 @@ const columns: ColumnProps<OnboardingEmployee>[] = [
props: {
filterable: true,
clearable: true,
placeholder: "请选择所属部门",
get placeholder() {
return proTable.value?.searchParam?.companyId ? "请选择所属部门" : "请先选择所属公司";
},
get disabled() {
return !proTable.value?.searchParam?.companyId;
},
onChange: (val?: number) => onSearchDepartmentChange(val)
}
}
@@ -1027,7 +1056,16 @@ const columns: ColumnProps<OnboardingEmployee>[] = [
enum: positionSearchOptions,
search: {
el: "select",
props: { filterable: true, clearable: true, placeholder: "请选择入职岗位" }
props: {
filterable: true,
clearable: true,
get placeholder() {
return proTable.value?.searchParam?.departmentId ? "请选择入职岗位" : "请先选择所属部门";
},
get disabled() {
return !proTable.value?.searchParam?.departmentId;
}
}
}
},
{
@@ -1160,6 +1198,128 @@ function changeTypeText(value?: string) {
return map[value || ""] || value || "人事变更";
}
function changeTypeTagType(value?: string): "primary" | "success" | "warning" | "danger" | "info" {
switch (value) {
case "regularization":
return "success";
case "regularization_revoke":
return "warning";
case "hr_info_update":
return "primary";
default:
return "info";
}
}
function changeTimelineType(value?: string): "primary" | "success" | "warning" | "info" {
switch (value) {
case "regularization":
return "success";
case "regularization_revoke":
return "warning";
default:
return "primary";
}
}
function shouldShowRemark(record: any) {
if (!record?.remark || !String(record.remark).trim()) return false;
const remark = String(record.remark).trim();
const defaultRemarks = [
"编辑员工人事信息",
"办理员工转正",
"撤销员工转正",
"编辑人事信息",
"办理转正",
"撤销转正"
];
return !defaultRemarks.includes(remark) && remark !== changeTypeText(record?.changeType);
}
type EmployeeChangeFieldKey =
| "employmentType"
| "regularizationStatus"
| "regularizationDate"
| "contractNature"
| "socialInsuranceStatus"
| "socialInsuranceCompany";
type EmployeeChangeDiffRow = {
key: EmployeeChangeFieldKey;
label: string;
beforeText: string;
afterText: string;
};
const employeeChangeFields: Array<{ key: EmployeeChangeFieldKey; label: string }> = [
{ key: "employmentType", label: "用工类型" },
{ key: "regularizationStatus", label: "转正状态" },
{ key: "regularizationDate", label: "转正日期" },
{ key: "contractNature", label: "合同性质" },
{ key: "socialInsuranceStatus", label: "社保状态" },
{ key: "socialInsuranceCompany", label: "合同社保单位" }
];
function parseChangeSnapshot(value: unknown): Record<string, unknown> | null {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
if (typeof value !== "string" || !value.trim()) return null;
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? parsed as Record<string, unknown>
: null;
} catch {
return null;
}
}
function getSnapshot(record: any, field: "beforeJson" | "afterJson") {
const pascalField = field === "beforeJson" ? "BeforeJson" : "AfterJson";
return parseChangeSnapshot(record?.[field] ?? record?.[pascalField]);
}
function getSnapshotValue(snapshot: Record<string, unknown>, key: string) {
const matchedKey = Object.keys(snapshot).find(item => item.toLowerCase() === key.toLowerCase());
return matchedKey ? snapshot[matchedKey] : undefined;
}
function normalizeChangeValue(value: unknown) {
if (value === null || value === undefined) return "";
return typeof value === "string" ? value.trim() : JSON.stringify(value);
}
function formatChangeValue(key: EmployeeChangeFieldKey, value: unknown) {
if (value === null || value === undefined || value === "") return "—";
const text = String(value);
if (key === "employmentType") return employmentTypeText(text);
if (key === "regularizationStatus") return regularizationText(text);
if (key === "regularizationDate") return formatDate(text);
if (key === "socialInsuranceStatus") return socialInsuranceText(text);
return text;
}
function hasChangeSnapshot(record: any) {
return Boolean(getSnapshot(record, "beforeJson") || getSnapshot(record, "afterJson"));
}
function changeDiffRows(record: any): EmployeeChangeDiffRow[] {
const before = getSnapshot(record, "beforeJson") || {};
const after = getSnapshot(record, "afterJson") || {};
return employeeChangeFields.flatMap(field => {
const beforeValue = getSnapshotValue(before, field.key);
const afterValue = getSnapshotValue(after, field.key);
if (normalizeChangeValue(beforeValue) === normalizeChangeValue(afterValue)) return [];
return [{
key: field.key,
label: field.label,
beforeText: formatChangeValue(field.key, beforeValue),
afterText: formatChangeValue(field.key, afterValue)
}];
});
}
function canRegularize(employee?: OnboardingEmployee) {
return Boolean(
employee
@@ -1301,9 +1461,9 @@ async function saveHrInfo() {
await onboardingTaskApi.updateEmployeeHrInfo({
id: hrForm.id,
employmentType: hrForm.employmentType,
contractNature: hrForm.contractNature.trim() || undefined,
contractNature: String(hrForm.contractNature ?? "").trim() || undefined,
socialInsuranceStatus: hrForm.socialInsuranceStatus,
socialInsuranceCompany: hrForm.socialInsuranceCompany.trim() || undefined
socialInsuranceCompany: String(hrForm.socialInsuranceCompany ?? "").trim() || undefined
});
hrEditVisible.value = false;
await refreshEmployee(hrForm.id);
@@ -1337,7 +1497,15 @@ function openRegularization(row: OnboardingEmployee) {
regularizationVisible.value = true;
}
function handleRegularizationExceed() {
ElMessage.warning("文件总数不超过 10");
}
function beforeRegularizationUpload(file: File) {
if (regularizationFiles.value.length >= 10) {
ElMessage.warning("文件总数不超过 10");
return false;
}
const extension = "." + (file.name.split(".").pop() || "").toLowerCase();
const allowed = [
".pdf", ".doc", ".docx", ".xls", ".xlsx",
@@ -1376,6 +1544,9 @@ async function submitRegularization() {
if (!regularizationForm.regularizationDate) {
return ElMessage.warning("请选择转正日期");
}
if (regularizationFiles.value.length > 10) {
return ElMessage.warning("文件总数不超过 10");
}
if (regularizationFiles.value.some(file => file.status === "uploading")) {
return ElMessage.warning("材料仍在上传,请稍候");
}
@@ -1383,6 +1554,9 @@ async function submitRegularization() {
const fileIds = regularizationFiles.value
.map(file => Number((file.response as any)?.fileId))
.filter(fileId => Number.isFinite(fileId) && fileId > 0);
if (fileIds.length > 10) {
return ElMessage.warning("文件总数不超过 10");
}
if (
regularizationFiles.value.some(file => file.status === "fail")
|| fileIds.length !== regularizationFiles.value.length
@@ -1509,43 +1683,9 @@ async function download(fileId: number, fallbackFileName?: string) {
}
async function loadSearchOptions() {
try {
const { data } = await onboardingTaskApi.listCompanies().catch(() => ({ data: [] }));
const { data } = await onboardingTaskApi.listCompanies();
searchCompanies.value = data || [];
const deptList: OnboardingDepartmentOption[] = [];
const posList: OnboardingPositionOption[] = [];
if (data && data.length > 0) {
const promises = data.map(async (comp: any) => {
const [depRes, posRes] = await Promise.all([
onboardingTaskApi.listDepartments(comp.id).catch(() => ({ data: [] })),
onboardingTaskApi.listPositions(comp.id).catch(() => ({ data: [] }))
]);
if (depRes.data) deptList.push(...depRes.data);
if (posRes.data) posList.push(...posRes.data);
});
await Promise.all(promises);
}
const uniqueDeptMap = new Map<number, OnboardingDepartmentOption>();
deptList.forEach(d => uniqueDeptMap.set(d.id, d));
const finalDepts = Array.from(uniqueDeptMap.values());
allSearchDepartments.value = finalDepts;
searchDepartments.value = finalDepts;
const uniquePosMap = new Map<number, OnboardingPositionOption>();
posList.forEach(p => uniquePosMap.set(p.id, p));
const finalPositions = Array.from(uniquePosMap.values());
allSearchPositions.value = finalPositions;
searchPositions.value = finalPositions;
proTable.value?.enumMap.set("companyId", companySearchOptions.value);
proTable.value?.enumMap.set("departmentId", departmentSearchOptions.value);
proTable.value?.enumMap.set("positionId", positionSearchOptions.value);
} catch (e) {
console.error("加载员工档案搜索选项失败", e);
}
}
onMounted(loadSearchOptions);
@@ -1623,35 +1763,175 @@ onBeforeUnmount(clearMaterialPreviews);
}
.change-record-card {
padding: 12px 14px;
padding: 14px 16px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
border-radius: 10px;
background: #ffffff;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04);
transition: all 0.2s ease;
}
.change-record-card:hover {
border-color: #cbd5e1;
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.06);
}
.change-record-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: #1e293b;
flex-wrap: wrap;
gap: 10px;
padding-bottom: 10px;
border-bottom: 1px dashed #f1f5f9;
}
.change-record-header span,
.change-record-line {
.change-record-title-wrap {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.change-type-tag {
font-weight: 600;
border-radius: 6px;
}
.change-effective-badge {
display: inline-flex;
align-items: center;
font-size: 12px;
color: #64748b;
background: #f8fafc;
padding: 2px 8px;
border-radius: 4px;
}
.change-operator {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 13px;
color: #475569;
background: #f1f5f9;
padding: 3px 10px;
border-radius: 20px;
}
.operator-icon {
font-size: 13px;
color: #64748b;
}
.operator-name {
font-weight: 500;
}
/* 变更 Diff 标签流 */
.change-diff-flow {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 12px;
}
.change-diff-item {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
font-size: 13px;
padding: 8px 12px;
background: #f8fafc;
border-radius: 6px;
border: 1px solid #f1f5f9;
}
.diff-field-label {
min-width: 90px;
font-weight: 600;
color: #475569;
flex-shrink: 0;
}
.diff-values {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.diff-val-before {
color: #64748b;
background: #ffffff;
padding: 2px 8px;
border-radius: 4px;
border: 1px solid #e2e8f0;
font-size: 12px;
}
.diff-arrow {
color: #94a3b8;
font-size: 13px;
}
.change-record-line {
margin-top: 6px;
.diff-val-after {
color: #2563eb;
background: #eff6ff;
padding: 2px 10px;
border-radius: 4px;
border: 1px solid #bfdbfe;
font-weight: 600;
font-size: 12px;
}
.change-diff-none {
color: #94a3b8;
font-size: 13px;
padding: 6px 0;
}
.change-record-empty {
margin-top: 10px;
padding: 8px 10px;
border-radius: 6px;
background: #f8fafc;
color: #94a3b8;
font-size: 13px;
}
/* 真实备注气泡框 */
.change-remark-box {
margin-top: 10px;
padding: 8px 12px;
border-radius: 6px;
background: #f8fafc;
border-left: 3px solid #cbd5e1;
display: flex;
align-items: flex-start;
gap: 6px;
font-size: 13px;
color: #475569;
}
.remark-icon {
margin-top: 2px;
color: #94a3b8;
flex-shrink: 0;
}
.remark-text {
line-height: 1.5;
}
.change-attachments {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
margin-top: 10px;
padding-top: 8px;
border-top: 1px dashed #f1f5f9;
}
.section-card {
@@ -16,7 +16,6 @@
placeholder="全部公司"
class="company-filter"
@change="onQueryCompanyChange"
@clear="onQueryCompanyChange"
>
<el-option v-for="item in companies" :key="item.id" :label="item.companyName" :value="item.id" />
</el-select>
@@ -24,10 +23,10 @@
v-model="query.departmentId"
clearable
filterable
placeholder="全部部门"
:placeholder="query.companyId ? '全部部门' : '请先选择所属公司'"
:disabled="!query.companyId"
class="department-filter"
@change="onQueryDepartmentChange"
@clear="onQueryDepartmentChange"
>
<el-option v-for="item in queryDepartments" :key="item.id" :label="item.departmentName" :value="item.id" />
</el-select>
@@ -35,10 +34,10 @@
v-model="query.positionId"
clearable
filterable
placeholder="全部岗位"
:placeholder="query.departmentId ? '全部岗位' : '请先选择所属部门'"
:disabled="!query.departmentId"
class="position-filter"
@change="loadTemplates"
@clear="loadTemplates"
>
<el-option v-for="item in queryPositions" :key="item.id" :label="item.positionName" :value="item.id" />
</el-select>
@@ -48,7 +47,6 @@
placeholder="模板状态"
class="status-filter"
@change="loadTemplates"
@clear="loadTemplates"
>
<el-option label="全部状态" value="" />
<el-option label="启用" value="enable" />
@@ -257,7 +255,17 @@
</div>
<el-table :data="enabledMaterialTypes" border stripe height="240px" class="mt-2">
<el-table-column label="选择要求" width="85" align="center">
<el-table-column label="选择要求" width="95" align="center">
<template #header>
<el-checkbox
:model-value="isAllMaterialsSelected"
:indeterminate="isMaterialsIndeterminate"
:disabled="!enabledMaterialTypes.length"
@change="handleSelectAllMaterials"
>
全选
</el-checkbox>
</template>
<template #default="scope">
<el-checkbox :model-value="hasMaterial(scope.row.id)"
@change="value => toggleMaterial(scope.row.id, Boolean(value))" />
@@ -294,15 +302,16 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { useOnboardingScopeOptions } from "@/hooks/useOnboardingScopeOptions";
import { ElMessage, ElMessageBox } from "element-plus";
import dayjs from "dayjs";
import {
OnboardingCompany,
OnboardingCompanyOption,
OnboardingContractTemplate,
OnboardingContractVersion,
OnboardingDepartmentOption,
OnboardingMaterialType,
OnboardingPosition,
OnboardingPositionOption,
OnboardingTemplate,
onboardingTaskApi
} from "@/api/modules/biz/onboarding";
@@ -317,9 +326,8 @@ const visible = ref(false);
const rows = ref<OnboardingTemplate[]>([]);
const total = ref(0);
const companies = ref<OnboardingCompany[]>([]);
const queryDepartments = ref<OnboardingDepartmentOption[]>([]);
const queryPositions = ref<OnboardingPositionOption[]>([]);
const props = defineProps<{ loadMaterials: () => Promise<OnboardingMaterialType[]> }>();
const companies = ref<OnboardingCompanyOption[]>([]);
const departments = ref<OnboardingDepartmentOption[]>([]);
const positions = ref<OnboardingPosition[]>([]);
const contractTemplates = ref<OnboardingContractTemplate[]>([]);
@@ -353,96 +361,54 @@ const form = reactive<OnboardingTemplate>({
materials: []
});
async function loadAllQueryOptions() {
if (!companies.value || companies.value.length === 0) return;
const deptList: OnboardingDepartmentOption[] = [];
const posList: OnboardingPositionOption[] = [];
const {
departments: queryDepartments,
positions: queryPositions,
onCompanyChange: changeQueryCompany,
onDepartmentChange: changeQueryDepartment,
reset: resetQueryScope
} = useOnboardingScopeOptions(() => query);
const promises = companies.value.map(async comp => {
const [depRes, posRes] = await Promise.all([
onboardingTaskApi.listDepartments(comp.id).catch(() => ({ data: [] })),
onboardingTaskApi.listPositions(comp.id).catch(() => ({ data: [] }))
]);
if (depRes.data) deptList.push(...depRes.data);
if (posRes.data) posList.push(...posRes.data);
});
await Promise.all(promises);
const uniqueDeptMap = new Map<number, OnboardingDepartmentOption>();
deptList.forEach(d => uniqueDeptMap.set(d.id, d));
queryDepartments.value = Array.from(uniqueDeptMap.values());
const uniquePosMap = new Map<number, OnboardingPositionOption>();
posList.forEach(p => uniquePosMap.set(p.id, p));
queryPositions.value = Array.from(uniquePosMap.values());
let companiesLoaded = false;
let companyRequest: Promise<void> | undefined;
async function loadCompanies() {
if (companiesLoaded) return;
if (!companyRequest) {
companyRequest = onboardingTaskApi.listCompanies().then(({ data }) => {
companies.value = data || [];
companiesLoaded = true;
}).finally(() => { companyRequest = undefined; });
}
await companyRequest;
}
async function loadBaseData() {
const [companyRes, materialRes] = await Promise.all([
onboardingTaskApi.companyPage({ pageNum: 1, pageSize: 100, status: "enable" }).catch(() => ({ data: { list: [] } })),
onboardingTaskApi.listMaterialTypes().catch(() => ({ data: [] }))
]);
companies.value = companyRes.data?.list || [];
materialTypes.value = materialRes.data || [];
await loadAllQueryOptions();
const [, materials] = await Promise.all([loadCompanies(), props.loadMaterials()]);
materialTypes.value = materials;
}
let templateRequest = 0;
async function loadTemplates() {
const request = ++templateRequest;
loading.value = true;
try {
const res = await onboardingTaskApi.onboardingTemplatePage(query);
const res = await onboardingTaskApi.onboardingTemplatePage({ ...query });
if (request !== templateRequest) return;
rows.value = res.data?.list || [];
total.value = res.data?.total || 0;
} finally {
loading.value = false;
if (request === templateRequest) loading.value = false;
}
}
async function onQueryCompanyChange(companyId?: number) {
query.departmentId = undefined;
query.positionId = undefined;
if (!companyId) {
await loadAllQueryOptions();
} else {
const [depRes, posRes] = await Promise.all([
onboardingTaskApi.listDepartments(companyId).catch(() => ({ data: [] })),
onboardingTaskApi.listPositions(companyId).catch(() => ({ data: [] }))
]);
queryDepartments.value = depRes.data || [];
queryPositions.value = (posRes.data || []).map(p => ({
id: p.id,
positionName: p.positionName,
companyId: companyId,
departmentId: p.departmentId
} as any));
}
loadTemplates();
query.pageNum = 1;
await Promise.all([changeQueryCompany(companyId), loadTemplates()]);
}
async function onQueryDepartmentChange(departmentId?: number) {
query.positionId = undefined;
if (!departmentId) {
if (query.companyId) {
const posRes = await onboardingTaskApi.listPositions(query.companyId).catch(() => ({ data: [] }));
queryPositions.value = (posRes.data || []).map(p => ({
id: p.id,
positionName: p.positionName,
companyId: query.companyId!,
departmentId: p.departmentId
} as any));
} else {
await loadAllQueryOptions();
}
} else {
const posRes = await onboardingTaskApi.listPositionsByDepartment(departmentId).catch(() => ({ data: [] }));
queryPositions.value = (posRes.data || []).map(p => ({
id: p.id,
positionName: p.positionName,
companyId: p.companyId,
departmentId: departmentId
} as any));
}
loadTemplates();
query.pageNum = 1;
await Promise.all([changeQueryDepartment(departmentId), loadTemplates()]);
}
function resetQuery() {
@@ -452,7 +418,7 @@ function resetQuery() {
query.positionId = undefined;
query.status = "enable";
query.pageNum = 1;
loadAllQueryOptions();
resetQueryScope();
loadTemplates();
}
@@ -508,10 +474,23 @@ async function onContractTemplateChange(row: any) {
await loadContractVersions(row.contractTemplateId);
}
function isVersionCurrentlyAvailable(version: OnboardingContractVersion) {
const now = dayjs();
const effectiveAt = version.effectiveDate ? dayjs(version.effectiveDate) : undefined;
const expiresAt = version.expiryDate ? dayjs(version.expiryDate) : undefined;
return version.versionStatus === "enabled"
&& (!effectiveAt || (effectiveAt.isValid() && !effectiveAt.isAfter(now)))
&& (!expiresAt || (expiresAt.isValid() && expiresAt.isAfter(now)));
}
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");
const res = await onboardingTaskApi.versionPage({
templateId,
versionStatus: "enabled",
pageNum: 1,
pageSize: 100
});
versionOptions[templateId] = (res.data?.list || []).filter(isVersionCurrentlyAvailable);
}
function addContract() {
@@ -551,6 +530,35 @@ function toggleMaterial(materialTypeId: number, checked: boolean) {
}
}
const isAllMaterialsSelected = computed(() => {
if (!enabledMaterialTypes.value.length) return false;
return enabledMaterialTypes.value.every(item => hasMaterial(item.id));
});
const isMaterialsIndeterminate = computed(() => {
if (!enabledMaterialTypes.value.length) return false;
const selectedCount = enabledMaterialTypes.value.filter(item => hasMaterial(item.id)).length;
return selectedCount > 0 && selectedCount < enabledMaterialTypes.value.length;
});
function handleSelectAllMaterials(checked: boolean | string | number) {
const isChecked = Boolean(checked);
if (isChecked) {
enabledMaterialTypes.value.forEach(item => {
if (!hasMaterial(item.id)) {
form.materials.push({
materialTypeId: item.id,
required: true,
sortCode: (form.materials.length + 1) * 10
});
}
});
} else {
const enabledIds = new Set(enabledMaterialTypes.value.map(item => item.id));
form.materials = form.materials.filter(item => !enabledIds.has(item.materialTypeId));
}
}
async function openCreate() {
await loadBaseData();
Object.assign(form, {
@@ -619,12 +627,25 @@ async function openEdit(row: OnboardingTemplate) {
const positionRes = await onboardingTaskApi.listPositionsByDepartment(form.departmentId);
positions.value = positionRes.data || [];
}
const configuredContractCount = form.contracts.length;
await loadAvailableContractTemplates();
const removedTemplateCount = configuredContractCount - form.contracts.length;
let clearedVersionCount = 0;
for (const contract of form.contracts) {
if (contract.contractTemplateId) {
await loadContractVersions(contract.contractTemplateId);
const availableVersions = versionOptions[contract.contractTemplateId] || [];
if (!availableVersions.some(item => item.id === contract.contractTemplateVersionId)) {
contract.contractTemplateVersionId = undefined as any;
clearedVersionCount++;
}
}
}
if (removedTemplateCount > 0 || clearedVersionCount > 0) {
ElMessage.warning(
"该入职模板原来关联了已停用、未生效或已失效的签署材料,已从当前选择中移除或清空,请重新选择后保存"
);
}
visible.value = true;
}
@@ -645,6 +666,14 @@ async function save() {
if (!form.contracts.length || form.contracts.some(item => !item.contractTemplateId || !item.contractTemplateVersionId)) {
return ElMessage.warning("请完整选择至少一份签署材料模板及版本");
}
const containsUnavailableVersion = form.contracts.some(contract => {
const versions = versionOptions[contract.contractTemplateId] || [];
const selected = versions.find(item => item.id === contract.contractTemplateVersionId);
return !selected || !isVersionCurrentlyAvailable(selected);
});
if (containsUnavailableVersion) {
return ElMessage.warning("所选签署材料版本已停用、尚未生效或已经失效,请重新选择");
}
saving.value = true;
try {
@@ -670,7 +699,7 @@ async function removeTemplate(row: OnboardingTemplate) {
}
onMounted(async () => {
await Promise.all([loadBaseData(), loadTemplates()]);
await Promise.all([loadCompanies(), loadTemplates()]);
});
</script>
+16 -10
View File
@@ -1,13 +1,13 @@
<template>
<div class="main-box config-container">
<el-tabs v-model="activeTab" class="config-tabs">
<el-tab-pane label="合同变量字典" name="variables">
<el-tab-pane lazy label="合同变量字典" name="variables">
<div class="tab-card-wrapper">
<VariableDefinitionPanel />
</div>
</el-tab-pane>
<el-tab-pane label="上传资料类型" name="materials">
<el-tab-pane lazy label="上传资料类型" name="materials">
<div class="tab-card-wrapper material-tab-wrapper">
<div class="toolbar">
<el-button v-auth="'onboardingEntryConfigAdd'" type="primary" icon="Plus" @click="openMaterialCreate">
@@ -64,9 +64,9 @@
</div>
</el-tab-pane>
<el-tab-pane label="入职模板" name="templates">
<el-tab-pane lazy label="入职模板" name="templates">
<div class="tab-card-wrapper">
<OnboardingTemplatePanel />
<OnboardingTemplatePanel :load-materials="loadMaterials" />
</div>
</el-tab-pane>
</el-tabs>
@@ -92,7 +92,7 @@
</el-form-item>
<el-form-item label="单文件大小">
<el-input-number v-model="materialForm.maxFileSizeMb" :min="1" :max="100" />
<el-input-number v-model="materialForm.maxFileSizeMb" :min="1" :max="20" />
<span class="unit-suffix">MB</span>
</el-form-item>
@@ -113,7 +113,7 @@
</template>
<script setup lang="ts" name="onboardingEntryConfig">
import { onMounted, reactive, ref } from "vue";
import { reactive, ref, watch } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import {
OnboardingMaterialType,
@@ -140,9 +140,15 @@ const materialForm = reactive<Partial<OnboardingMaterialType>>({
status: "enable"
});
let materialRequest: Promise<OnboardingMaterialType[]> | undefined;
async function loadMaterials() {
const res = await onboardingTaskApi.listMaterialTypes();
materialTypes.value = res.data || [];
if (!materialRequest) {
materialRequest = onboardingTaskApi.listMaterialTypes().then(({ data }) => {
materialTypes.value = data || [];
return materialTypes.value;
}).finally(() => { materialRequest = undefined; });
}
return materialRequest;
}
function openMaterialCreate() {
@@ -184,8 +190,8 @@ async function removeMaterial(row: OnboardingMaterialType) {
await loadMaterials();
}
onMounted(() => {
loadMaterials();
watch(activeTab, tab => {
if (tab === "materials") void loadMaterials();
});
</script>
+102 -34
View File
@@ -2,7 +2,7 @@
<div class="main-box setup-container">
<el-tabs v-model="activeTab" class="setup-tabs">
<!-- 1. 入职公司管理 -->
<el-tab-pane label="入职公司" name="company">
<el-tab-pane lazy label="入职公司" name="company">
<div class="table-wrapper">
<ProTable
ref="companyTable"
@@ -62,7 +62,7 @@
</el-tab-pane>
<!-- 2. 入职部门管理 -->
<el-tab-pane label="入职部门" name="department">
<el-tab-pane lazy label="入职部门" name="department">
<div class="table-wrapper">
<ProTable
ref="departmentTable"
@@ -78,9 +78,17 @@
</template>
<template #companyId="scope">
<el-tag effect="plain" type="primary">
<el-tooltip
v-if="scope.row.companyName || getCompanyName(scope.row.companyId)"
:content="scope.row.companyName || getCompanyName(scope.row.companyId)"
placement="top"
:show-after="300"
>
<el-tag effect="plain" type="primary" class="truncate-tag">
{{ scope.row.companyName || getCompanyName(scope.row.companyId) }}
</el-tag>
</el-tooltip>
<span v-else class="text-gray-400">-</span>
</template>
<template #status="scope">
@@ -119,7 +127,7 @@
</el-tab-pane>
<!-- 3. 入职岗位管理 -->
<el-tab-pane label="入职岗位" name="position">
<el-tab-pane lazy label="入职岗位" name="position">
<div class="table-wrapper">
<ProTable
ref="positionTable"
@@ -135,15 +143,30 @@
</template>
<template #companyId="scope">
<el-tag effect="plain" type="primary">
<el-tooltip
v-if="scope.row.companyName || getCompanyName(scope.row.companyId)"
:content="scope.row.companyName || getCompanyName(scope.row.companyId)"
placement="top"
:show-after="300"
>
<el-tag effect="plain" type="primary" class="truncate-tag">
{{ scope.row.companyName || getCompanyName(scope.row.companyId) }}
</el-tag>
</el-tooltip>
<span v-else class="text-gray-400">-</span>
</template>
<template #departmentName="scope">
<el-tag v-if="scope.row.departmentName" effect="light" type="warning">
<el-tooltip
v-if="scope.row.departmentName"
:content="scope.row.departmentName"
placement="top"
:show-after="300"
>
<el-tag effect="light" type="warning" class="truncate-tag">
{{ scope.row.departmentName }}
</el-tag>
</el-tooltip>
<span v-else class="text-gray-400">-</span>
</template>
@@ -204,18 +227,18 @@
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="公司编码" prop="companyCode">
<el-input v-model.trim="companyForm.companyCode" placeholder="如:TECH_CORP" clearable />
<el-input v-model.trim="companyForm.companyCode" placeholder="如:TECH_CORP" maxlength="50" show-word-limit clearable />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="公司简称/名称" prop="companyName">
<el-input v-model.trim="companyForm.companyName" placeholder="如:总公司 / 科技分部" clearable />
<el-input v-model.trim="companyForm.companyName" placeholder="如:总公司 / 科技分部" maxlength="50" show-word-limit clearable />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="签署材料甲方全称" prop="legalName">
<el-input v-model.trim="companyForm.legalName" placeholder="必须与契约锁中的企业名称完全一致" clearable />
<el-input v-model.trim="companyForm.legalName" placeholder="必须与契约锁中的企业名称完全一致" maxlength="100" show-word-limit clearable />
</el-form-item>
<el-row :gutter="20">
@@ -225,14 +248,15 @@
v-model.trim="companyForm.unifiedSocialCreditCode"
placeholder="18位统一社会信用代码"
maxlength="18"
show-word-limit
clearable
@input="val => companyForm.unifiedSocialCreditCode = (val || '').toUpperCase()"
@input="val => companyForm.unifiedSocialCreditCode = (val || '').replace(/[^0-9A-Za-z]/g, '').toUpperCase()"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="法定代表人" prop="legalRepresentative">
<el-input v-model.trim="companyForm.legalRepresentative" placeholder="法定代表人姓名" clearable />
<el-input v-model.trim="companyForm.legalRepresentative" placeholder="法定代表人姓名" maxlength="20" show-word-limit clearable />
</el-form-item>
</el-col>
</el-row>
@@ -240,7 +264,7 @@
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="联系电话" prop="contactPhone">
<el-input v-model.trim="companyForm.contactPhone" placeholder="企业联系电话(手机或固话)" clearable />
<el-input v-model.trim="companyForm.contactPhone" placeholder="企业联系电话(选填,手机或固话)" maxlength="20" show-word-limit clearable />
</el-form-item>
</el-col>
<el-col :span="12">
@@ -254,11 +278,11 @@
</el-row>
<el-form-item label="注册地址" prop="registeredAddress">
<el-input v-model.trim="companyForm.registeredAddress" placeholder="企业法定注册地址" clearable />
<el-input v-model.trim="companyForm.registeredAddress" placeholder="企业法定注册地址" maxlength="150" show-word-limit clearable />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="companyForm.remark" type="textarea" :rows="2" placeholder="备注信息(选填)" />
<el-input v-model="companyForm.remark" type="textarea" :rows="3" placeholder="备注信息(选填)" maxlength="300" show-word-limit />
</el-form-item>
<el-divider content-position="left">契约锁电子签配置(由技术人员添加)</el-divider>
@@ -272,12 +296,26 @@
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="默认业务分类 Id" prop="qiyuesuoCategoryId">
<el-input v-model.trim="companyForm.qiyuesuoCategoryId" placeholder="契约锁当前公司的分类 Id" clearable />
<el-input
v-model.trim="companyForm.qiyuesuoCategoryId"
placeholder="契约锁当前公司的分类 Id"
maxlength="19"
show-word-limit
clearable
@input="val => companyForm.qiyuesuoCategoryId = (val || '').replace(/\D/g, '')"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="企业印章 Id" prop="qiyuesuoSealId">
<el-input v-model.trim="companyForm.qiyuesuoSealId" placeholder="契约锁当前公司的印章 Id" clearable />
<el-input
v-model.trim="companyForm.qiyuesuoSealId"
placeholder="契约锁当前公司的印章 Id"
maxlength="19"
show-word-limit
clearable
@input="val => companyForm.qiyuesuoSealId = (val || '').replace(/\D/g, '')"
/>
</el-form-item>
</el-col>
</el-row>
@@ -299,12 +337,12 @@
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="部门编码" required>
<el-input v-model="departmentForm.departmentCode" placeholder="如:DEPT_TECH" />
<el-input v-model.trim="departmentForm.departmentCode" placeholder="如:DEPT_TECH" maxlength="50" show-word-limit clearable />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="部门名称" required>
<el-input v-model="departmentForm.departmentName" placeholder="如:技术研发部" />
<el-input v-model.trim="departmentForm.departmentName" placeholder="如:技术研发部" maxlength="50" show-word-limit clearable />
</el-form-item>
</el-col>
</el-row>
@@ -326,7 +364,7 @@
</el-row>
<el-form-item label="备注">
<el-input v-model="departmentForm.remark" type="textarea" :rows="2" placeholder="备注信息" />
<el-input v-model="departmentForm.remark" type="textarea" :rows="2" placeholder="备注信息" maxlength="200" show-word-limit />
</el-form-item>
</el-form>
@@ -370,12 +408,12 @@
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="岗位编码" required>
<el-input v-model="positionForm.positionCode" placeholder="如:DEV_FRONTEND" />
<el-input v-model.trim="positionForm.positionCode" placeholder="如:DEV_FRONTEND" maxlength="50" show-word-limit clearable />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="岗位名称" required>
<el-input v-model="positionForm.positionName" placeholder="如:高级前端开发工程师" />
<el-input v-model.trim="positionForm.positionName" placeholder="如:高级前端开发工程师" maxlength="50" show-word-limit clearable />
</el-form-item>
</el-col>
</el-row>
@@ -401,7 +439,7 @@
</el-form-item>
<el-form-item label="备注">
<el-input v-model="positionForm.remark" type="textarea" :rows="2" placeholder="备注信息" />
<el-input v-model="positionForm.remark" type="textarea" :rows="2" placeholder="备注信息" maxlength="200" show-word-limit />
</el-form-item>
</el-form>
@@ -414,7 +452,7 @@
</template>
<script setup lang="ts" name="onboardingSetup">
import { ref, reactive, computed, onMounted, nextTick } from "vue";
import { ref, reactive, computed, watch, nextTick } from "vue";
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
import { Plus, Edit, Delete } from "@element-plus/icons-vue";
import {
@@ -505,7 +543,7 @@ const companyRules = reactive<FormRules>({
],
companyName: [
{ required: true, message: "请输入公司简称/名称", trigger: "blur" },
{ min: 2, max: 100, message: "公司名称长度需在 2 到 100 个字符之间", trigger: "blur" }
{ min: 2, max: 50, message: "公司名称长度需在 2 到 50 个字符之间", trigger: "blur" }
],
legalName: [
{ required: true, message: "请输入签署材料甲方全称", trigger: "blur" },
@@ -528,7 +566,6 @@ const companyRules = reactive<FormRules>({
}
],
contactPhone: [
{ required: true, message: "请输入企业联系电话", trigger: "blur" },
{
pattern: /^(1[3-9]\d{9}|(0\d{2,3}-?)?\d{7,8}|400(-?\d{3,4}){2})$/,
message: "请输入有效的手机号或固话号码",
@@ -540,21 +577,26 @@ const companyRules = reactive<FormRules>({
],
registeredAddress: [
{ required: true, message: "请输入企业法定注册地址", trigger: "blur" },
{ min: 4, max: 200, message: "注册地址请至少输入4个字符", trigger: "blur" }
{ min: 4, max: 150, message: "注册地址需在 4 到 150 个字符之间", trigger: "blur" }
],
remark: [
{ max: 300, message: "备注不能超过 300 个字符", trigger: "blur" }
],
qiyuesuoCategoryId: [
{
pattern: /^\d*$/,
message: "业务分类 Id 必须为纯数字",
trigger: "blur"
}
},
{ max: 19, message: "业务分类 Id 不能超过 19 位数字", trigger: "blur" }
],
qiyuesuoSealId: [
{
pattern: /^\d*$/,
message: "企业印章 Id 必须为纯数字",
trigger: "blur"
}
},
{ max: 19, message: "企业印章 Id 不能超过 19 位数字", trigger: "blur" }
]
});
const departmentForm = reactive<Partial<OnboardingDepartment>>(emptyDepartment());
@@ -646,7 +688,8 @@ const departmentColumns: ColumnProps<OnboardingDepartment>[] = [
prop: "departmentName",
label: "部门名称",
minWidth: 180,
search: { el: "input" }
search: { el: "input" },
showOverflowTooltip: true
},
{
prop: "sortCode",
@@ -693,13 +736,15 @@ const positionColumns: ColumnProps<OnboardingPosition>[] = [
prop: "positionCode",
label: "岗位编码",
width: 150,
search: { el: "input" }
search: { el: "input" },
showOverflowTooltip: true
},
{
prop: "positionName",
label: "岗位名称",
minWidth: 180,
search: { el: "input" }
search: { el: "input" },
showOverflowTooltip: true
},
{
prop: "defaultContractYears",
@@ -734,9 +779,14 @@ const positionColumns: ColumnProps<OnboardingPosition>[] = [
}
];
let companyRequest: Promise<void> | undefined;
async function loadCompanies() {
const { data } = await onboardingTaskApi.listCompanies();
if (!companyRequest) {
companyRequest = onboardingTaskApi.listCompanies().then(({ data }) => {
companies.value = data || [];
}).finally(() => { companyRequest = undefined; });
}
await companyRequest;
}
async function loadDepartmentsForCompany(companyId?: number) {
@@ -831,7 +881,10 @@ async function save() {
return ElMessage.warning("契约锁默认业务分类 Id 和企业印章 Id 必须同时填写(或均不填)");
}
if (categoryId && (!/^\d+$/.test(categoryId) || !/^\d+$/.test(sealId))) {
return ElMessage.warning("契约锁默认业务分类 Id 和企业印章 Id 只能填写数字");
return ElMessage.warning("契约锁默认业务分类 Id 和企业印章 Id 只能填写纯数字");
}
if (categoryId && (categoryId.length > 19 || sealId.length > 19)) {
return ElMessage.warning("契约锁默认业务分类 Id 和企业印章 Id 不能超过 19 位数字");
}
companyForm.qiyuesuoCategoryId = categoryId;
companyForm.qiyuesuoSealId = sealId;
@@ -890,7 +943,9 @@ async function removePosition(row: OnboardingPosition) {
positionTable.value?.refresh();
}
onMounted(loadCompanies);
watch(activeTab, tab => {
if (tab !== "company" && !companies.value.length) void loadCompanies();
});
</script>
<style scoped>
@@ -971,4 +1026,17 @@ onMounted(loadCompanies);
padding-top: 3px;
line-height: 1.25;
}
.truncate-tag {
max-width: 140px;
display: inline-flex;
align-items: center;
vertical-align: middle;
}
.truncate-tag :deep(.el-tag__content) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
+354 -149
View File
@@ -7,6 +7,7 @@
title="入职任务列表"
:columns="columns"
:request-api="getTableData"
@reset="resetSearchScope"
>
<template #tableHeader>
<div class="task-table-toolbar">
@@ -346,6 +347,14 @@
</div>
</div>
<el-alert
v-if="contractConfigurationError"
:title="contractConfigurationError"
type="error"
show-icon
:closable="false"
class="mb-3"
/>
<!-- 3. 合同逐份预览与确认 -->
<div v-if="contractPreviews.length" class="form-card-section">
<div class="section-title contract-preview-section-title">
@@ -541,38 +550,75 @@
</el-dialog>
<!-- 任务创建成功与分享弹窗 -->
<el-dialog v-model="shareVisible" title="入职任务已创建" width="560px" destroy-on-close>
<el-result icon="success" title="任务创建成功!" sub-title="请先确认签署材料人事参数,再将专属链接发送给候选人">
<el-dialog
v-model="shareVisible"
title="入职任务已创建"
width="600px"
destroy-on-close
class="task-share-dialog"
>
<el-result
icon="success"
title="任务创建成功!"
sub-title="请先确认签署材料人事参数,再将专属链接发送给候选人"
class="task-share-result"
>
<template #extra>
<div class="share-box">
<div class="share-item">
<span class="share-label">任务编号:</span>
<span class="share-value font-bold">{{ createdTaskInfo?.taskNo }}</span>
<div class="share-card">
<div class="share-info-header">
<div class="share-meta-item">
<span class="share-meta-label">任务编号</span>
<span class="share-meta-val font-mono">{{ createdTaskInfo?.taskNo }}</span>
</div>
<div class="share-item">
<span class="share-label">专属链接:</span>
<el-input :model-value="createdTaskInfo?.linkUrl" readonly size="small">
<div v-if="createdTaskInfo?.positionName" class="share-meta-item text-right">
<span class="share-meta-label">入职岗位</span>
<span class="share-meta-val">{{ createdTaskInfo.positionName }}</span>
</div>
</div>
<div class="share-link-section">
<div class="share-section-title">专属填报链接</div>
<el-input
:model-value="createdTaskInfo?.linkUrl"
readonly
class="share-link-input"
>
<template #append>
<el-button icon="CopyDocument" @click="copyText(createdTaskInfo?.linkUrl, '专属链接已复制')">
复制
<el-button
type="primary"
icon="CopyDocument"
@click="copyText(createdTaskInfo?.linkUrl, '专属链接已复制')"
>
复制链接
</el-button>
</template>
</el-input>
</div>
<div class="share-item">
<span class="share-label">有效期至:</span>
<span class="share-value text-secondary">{{ createdTaskInfo?.expiresAt }}</span>
<div class="share-expiry-row">
<el-icon class="expiry-icon"><Clock /></el-icon>
<span>链接有效期至:</span>
<span class="expiry-time">{{ createdTaskInfo?.expiresAt }}</span>
</div>
<div class="share-actions">
<el-button type="primary" plain icon="ChatDotRound" @click="copyInviteMessage">
复制短信/微信邀请文案
<div class="share-action-section">
<el-button
type="primary"
plain
icon="ChatDotRound"
class="invite-copy-btn"
@click="copyInviteMessage"
>
复制短信 / 微信邀请文案
</el-button>
</div>
</div>
</template>
</el-result>
<template #footer>
<div class="dialog-footer-center">
<el-button type="primary" @click="shareVisible = false">完成</el-button>
</div>
</template>
</el-dialog>
@@ -769,7 +815,7 @@
<el-descriptions-item
v-for="item in detailCustomVars"
:key="item.key"
:label="item.key"
:label="item.label"
>
{{ item.value || '-' }}
</el-descriptions-item>
@@ -779,7 +825,15 @@
</el-tab-pane>
<!-- 2. 证件与材料复核画廊 -->
<el-tab-pane label="证件与材料复核" name="materials">
<el-tab-pane name="materials">
<template #label>
<span class="tab-label-with-badge">
<span>证件与材料复核</span>
<span v-if="unreviewedMaterialCount > 0" class="tab-badge-pill">
{{ unreviewedMaterialCount > 99 ? '99+' : unreviewedMaterialCount }}
</span>
</span>
</template>
<el-empty v-if="!detail.materials || !detail.materials.length" description="候选人尚未上传任何材料" />
<div v-else class="materials-gallery">
<div
@@ -843,21 +897,10 @@
plain
:disabled="item.reviewStatus === 'passed' || !canManage(detail.task)"
:title="disabledReason(detail.task)"
@click="reviewMaterial(item.id, 'passed')"
@click="reviewMaterial(item.id)"
>
合格
</el-button>
<el-button
v-auth="'onboardingTaskMaterialReview'"
size="small"
type="danger"
plain
:disabled="item.reviewStatus === 'need_supplement' || !canManage(detail.task)"
:title="disabledReason(detail.task)"
@click="reviewMaterial(item.id, 'need_supplement')"
>
需补充
</el-button>
</div>
</div>
</div>
@@ -1268,6 +1311,11 @@
<div class="font-bold">
{{ documentItem.documentSort }}. {{ documentItem.documentName }}
</div>
<div class="document-state-text">
<el-tag size="small" effect="plain">员工签字</el-tag>
<el-tag v-if="documentItem.companySealEnabled" size="small" type="success" effect="plain">公司盖章</el-tag>
<el-tag v-if="documentItem.acrossPageSealEnabled" size="small" type="warning" effect="plain">骑缝章</el-tag>
</div>
<div class="text-secondary document-state-text">
{{ documentItem.providerDocumentId ? '已上传契约锁' : '发起时自动生成并上传' }}
</div>
@@ -1474,6 +1522,7 @@
</template>
<script setup lang="ts" name="onboardingTask">
import { useOnboardingScopeOptions } from "@/hooks/useOnboardingScopeOptions";
import { ref, reactive, computed, watch, onMounted, onBeforeUnmount } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import {
@@ -1519,10 +1568,19 @@ const companies = ref<OnboardingCompanyOption[]>([]);
const departments = ref<OnboardingDepartmentOption[]>([]);
const positions = ref<OnboardingPositionOption[]>([]);
const searchCompanies = ref<OnboardingCompanyOption[]>([]);
const searchDepartments = ref<OnboardingDepartmentOption[]>([]);
const searchPositions = ref<OnboardingPositionOption[]>([]);
const allSearchDepartments = ref<OnboardingDepartmentOption[]>([]);
const allSearchPositions = ref<OnboardingPositionOption[]>([]);
const {
departments: searchDepartments,
positions: searchPositions,
onCompanyChange: onSearchCompanyChange,
onDepartmentChange: onSearchDepartmentChange,
reset: resetSearchScope
} = useOnboardingScopeOptions(
() => proTable.value?.searchParam,
() => {
proTable.value?.enumMap.set("departmentId", departmentSearchOptions.value);
proTable.value?.enumMap.set("positionId", positionSearchOptions.value);
}
);
const companySearchOptions = computed(() =>
searchCompanies.value.map(c => ({
@@ -1545,58 +1603,6 @@ const positionSearchOptions = computed(() =>
}))
);
async function onSearchCompanyChange(companyId?: number) {
if (companyId) {
const [depRes, posRes] = await Promise.all([
onboardingTaskApi.listDepartments(companyId).catch(() => ({ data: [] })),
onboardingTaskApi.listPositions(companyId).catch(() => ({ data: [] }))
]);
searchDepartments.value = depRes.data || [];
searchPositions.value = posRes.data || [];
} else {
searchDepartments.value = allSearchDepartments.value;
searchPositions.value = allSearchPositions.value;
}
proTable.value?.enumMap.set("departmentId", departmentSearchOptions.value);
proTable.value?.enumMap.set("positionId", positionSearchOptions.value);
if (proTable.value?.searchParam) {
const currentDept = proTable.value.searchParam.departmentId;
if (currentDept && !searchDepartments.value.some(d => d.id === currentDept)) {
proTable.value.searchParam.departmentId = undefined;
}
const currentPos = proTable.value.searchParam.positionId;
if (currentPos && !searchPositions.value.some(p => p.id === currentPos)) {
proTable.value.searchParam.positionId = undefined;
}
}
}
async function onSearchDepartmentChange(departmentId?: number) {
if (departmentId) {
const posRes = await onboardingTaskApi.listPositionsByDepartment(departmentId).catch(() => ({ data: [] }));
searchPositions.value = posRes.data || [];
} else {
const selectedCompanyId = proTable.value?.searchParam?.companyId;
if (selectedCompanyId) {
const posRes = await onboardingTaskApi.listPositions(selectedCompanyId).catch(() => ({ data: [] }));
searchPositions.value = posRes.data || [];
} else {
searchPositions.value = allSearchPositions.value;
}
}
proTable.value?.enumMap.set("positionId", positionSearchOptions.value);
if (proTable.value?.searchParam) {
const currentPos = proTable.value.searchParam.positionId;
if (currentPos && !searchPositions.value.some(p => p.id === currentPos)) {
proTable.value.searchParam.positionId = undefined;
}
}
}
const onboardingTemplates = ref<OnboardingTemplate[]>([]);
const contractTemplates = ref<OnboardingContractTemplate[]>([]);
@@ -1616,6 +1622,7 @@ interface ContractPreviewState {
}
const contractPreviews = ref<ContractPreviewState[]>([]);
const contractConfigurationError = ref("");
const confirmedContractCount = computed(
() => contractPreviews.value.filter(item => item.confirmed).length
);
@@ -1685,10 +1692,36 @@ const detailFamilyList = computed(() => {
}
});
const unreviewedMaterialCount = computed(() => {
const materials = detail.value?.materials;
if (!Array.isArray(materials) || materials.length === 0) return 0;
return materials.filter(item => item.reviewStatus !== "passed").length;
});
const registrationEmployeeFieldCodes = new Set([
"employeename", "name", "username", "gender", "nation", "birthday", "registeredresidence",
"height", "weight", "healthcondition", "maritalstatus", "education", "major", "graduatedschool",
"college", "vocationalqualification", "qualificationlevel", "technicalspecialty", "phone",
"mobile", "email", "idcardnumber", "idcard", "currentaddress", "contactaddress", "landlordcontact",
"bankcardnumber", "bankcard", "bankname", "bankaccountname", "emergencycontact", "emergencyphone",
"emergencyaddress", "onboardingdepartment", "onboardingdate", "onboardingposition", "otherremarks"
]);
const detailContractVariableNames = ref<Record<string, string>>({});
function isRegistrationEmployeeVariable(variableCode: string) {
const fieldCode = (String(variableCode || "").split(".")[1] || variableCode || "").toLowerCase();
return registrationEmployeeFieldCodes.has(fieldCode);
}
const detailCustomVars = computed(() => {
try {
const obj = JSON.parse(detail.value?.employeeInfo?.customVariablesJson || "{}");
return Object.entries(obj).map(([k, v]) => ({ key: k, value: String(v) }));
return Object.entries(obj)
.filter(([key]) => !isRegistrationEmployeeVariable(key))
.map(([key, value]) => ({
key,
label: detailContractVariableNames.value[key] || key,
value: value == null ? "" : String(value)
}));
} catch {
return [];
}
@@ -1878,7 +1911,12 @@ const columns: ColumnProps<OnboardingTask>[] = [
props: {
filterable: true,
clearable: true,
placeholder: "请选择所属部门",
get placeholder() {
return proTable.value?.searchParam?.companyId ? "请选择所属部门" : "请先选择所属公司";
},
get disabled() {
return !proTable.value?.searchParam?.companyId;
},
onChange: (val?: number) => onSearchDepartmentChange(val)
}
}
@@ -1890,7 +1928,16 @@ const columns: ColumnProps<OnboardingTask>[] = [
enum: positionSearchOptions,
search: {
el: "select",
props: { filterable: true, clearable: true, placeholder: "请选择入职岗位" }
props: {
filterable: true,
clearable: true,
get placeholder() {
return proTable.value?.searchParam?.departmentId ? "请选择入职岗位" : "请先选择所属部门";
},
get disabled() {
return !proTable.value?.searchParam?.departmentId;
}
}
}
},
{
@@ -2193,48 +2240,10 @@ async function getTableData(params: any) {
}
async function loadCompanies() {
try {
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 || [];
searchCompanies.value = companyResult.data || [];
contractTemplates.value = contractResult.data?.list || [];
const deptList: OnboardingDepartmentOption[] = [];
const posList: OnboardingPositionOption[] = [];
if (companies.value && companies.value.length > 0) {
const promises = companies.value.map(async comp => {
const [depRes, posRes] = await Promise.all([
onboardingTaskApi.listDepartments(comp.id).catch(() => ({ data: [] })),
onboardingTaskApi.listPositions(comp.id).catch(() => ({ data: [] }))
]);
if (depRes.data) deptList.push(...depRes.data);
if (posRes.data) posList.push(...posRes.data);
});
await Promise.all(promises);
}
const uniqueDeptMap = new Map<number, OnboardingDepartmentOption>();
deptList.forEach(d => uniqueDeptMap.set(d.id, d));
const finalDepts = Array.from(uniqueDeptMap.values());
allSearchDepartments.value = finalDepts;
searchDepartments.value = finalDepts;
const uniquePosMap = new Map<number, OnboardingPositionOption>();
posList.forEach(p => uniquePosMap.set(p.id, p));
const finalPositions = Array.from(uniquePosMap.values());
allSearchPositions.value = finalPositions;
searchPositions.value = finalPositions;
const { data } = await onboardingTaskApi.listCompanies();
companies.value = data || [];
searchCompanies.value = data || [];
proTable.value?.enumMap.set("companyId", companySearchOptions.value);
proTable.value?.enumMap.set("departmentId", departmentSearchOptions.value);
proTable.value?.enumMap.set("positionId", positionSearchOptions.value);
} catch (e) {
console.error("加载公司与搜索选项失败", e);
}
}
async function onCompanyChange(companyId: number) {
@@ -2290,6 +2299,42 @@ async function onOnboardingTemplateChange(templateId: number) {
const sortResult = (left.sortCode ?? 100) - (right.sortCode ?? 100);
return sortResult || left.contractTemplateVersionId - right.contractTemplateVersionId;
});
const matchesCurrentScope = (item: OnboardingContractTemplate, contractTemplateId: number) =>
item.id === contractTemplateId
&& item.status === "enable"
&& item.companyId === form.companyId
&& item.departmentId === form.departmentId;
const unresolvedTemplateIds = contracts
.map(contract => contract.contractTemplateId)
.filter(contractTemplateId =>
!contractTemplates.value.some(item => matchesCurrentScope(item, contractTemplateId))
)
.filter((contractTemplateId, index, values) => values.indexOf(contractTemplateId) === index);
if (unresolvedTemplateIds.length) {
const detailResults = await Promise.all(
unresolvedTemplateIds.map(id =>
onboardingTaskApi.templateDetail({ id }).catch(() => ({ data: undefined }))
)
);
detailResults.forEach(result => {
const item = result.data;
if (
item
&& matchesCurrentScope(item, item.id)
&& !contractTemplates.value.some(existing => existing.id === item.id)
) {
contractTemplates.value.push(item);
}
});
}
const unavailableContract = contracts.find(contract =>
!contractTemplates.value.some(item => matchesCurrentScope(item, contract.contractTemplateId))
);
if (unavailableContract) {
contractConfigurationError.value = "原签署材料已失效,请重新配置入职模板";
ElMessage.error(contractConfigurationError.value);
return;
}
const variableResults = await Promise.all(
contracts.map(contract => onboardingTaskApi.variablePage({
templateVersionId: contract.contractTemplateVersionId,
@@ -2317,12 +2362,12 @@ async function onOnboardingTemplateChange(templateId: number) {
}));
});
contractPreviews.value = contracts.map((contract, index) => ({
contractPreviews.value = contracts.map(contract => ({
contractTemplateId: contract.contractTemplateId,
contractTemplateVersionId: contract.contractTemplateVersionId,
contractName:
contractTemplates.value.find(item => item.id === contract.contractTemplateId)
?.templateName || `合同${index + 1}`,
?.templateName || "原签署材料已失效,请重新配置入职模板",
required: contract.required,
sortCode: contract.sortCode ?? 100,
downloaded: false,
@@ -2438,6 +2483,7 @@ function buildTaskCreatePayload() {
}
function resetContractPreviewState(clearContracts = false) {
contractConfigurationError.value = "";
if (clearContracts) {
contractPreviews.value = [];
return;
@@ -2604,7 +2650,16 @@ async function copyOrShareTaskLink(row: OnboardingTask) {
async function showDetail(row: OnboardingTask, preserveTab: boolean = false) {
const currentTab = activeDetailTab.value;
const { data }: any = await onboardingTaskApi.detail({ id: row.id });
const [{ data }, variableResponse]: any[] = await Promise.all([
onboardingTaskApi.detail({ id: row.id }),
onboardingTaskApi.taskContractVariables(row.id).catch(() => ({ data: [] }))
]);
detailContractVariableNames.value = Object.fromEntries(
(variableResponse.data || [])
.flatMap((group: OnboardingTaskContractVariableGroup) => group.variables || [])
.filter((variable: any) => variable.variableType === "employee" && !isRegistrationEmployeeVariable(variable.variableCode))
.map((variable: any) => [variable.variableCode, variable.variableName || variable.variableCode])
);
detail.value = data;
if (!preserveTab) {
activeDetailTab.value = "profile";
@@ -2757,21 +2812,9 @@ async function download(fileId: number, fallbackFileName?: string) {
onBeforeUnmount(clearMaterialPreviews);
async function reviewMaterial(taskMaterialId: number, reviewStatus: string) {
let reviewRemark = "";
if (reviewStatus === "need_supplement") {
const result = await getMessageBoxPromptResult(
ElMessageBox.prompt("请输入该材料退回补充说明:", "材料不合格退回", {
inputPattern: /\S+/,
inputErrorMessage: "请填写说明"
})
);
if (!result) return;
reviewRemark = result.value;
}
await onboardingTaskApi.reviewMaterial({ taskMaterialId, reviewStatus, reviewRemark });
ElMessage.success(reviewStatus === "passed" ? "该材料已标记为合格" : "已标记需补充");
async function reviewMaterial(taskMaterialId: number) {
await onboardingTaskApi.reviewMaterial({ taskMaterialId, reviewStatus: "passed", reviewRemark: "" });
ElMessage.success("该材料已标记为合格");
if (detail.value?.task) {
await showDetail(detail.value.task, true);
@@ -3662,6 +3705,27 @@ onMounted(loadCompanies);
flex-direction: column;
}
.tab-label-with-badge {
display: inline-flex;
align-items: center;
}
.tab-badge-pill {
display: inline-flex;
align-items: center;
justify-content: center;
height: 18px;
min-width: 18px;
padding: 0 5px;
margin-left: 6px;
font-size: 12px;
font-weight: 600;
line-height: 1;
color: #ffffff;
background-color: var(--el-color-danger, #f56c6c);
border-radius: 9px;
}
:deep(.detail-tabs > .el-tabs__header) {
flex-shrink: 0;
margin-bottom: 16px;
@@ -4130,4 +4194,145 @@ onMounted(loadCompanies);
justify-content: flex-start;
}
}
/* 任务创建分享弹窗样式优化 */
.task-share-result {
padding: 12px 0 4px 0;
}
.task-share-result :deep(.el-result__icon svg) {
width: 56px;
height: 56px;
}
.task-share-result :deep(.el-result__title) {
margin-top: 10px;
font-size: 18px;
font-weight: 600;
color: #1e293b;
}
.task-share-result :deep(.el-result__subtitle) {
margin-top: 6px;
font-size: 13px;
color: #64748b;
line-height: 1.5;
}
.task-share-result :deep(.el-result__extra) {
margin-top: 20px;
width: 100%;
}
.share-card {
width: 100%;
max-width: 500px;
margin: 0 auto;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 18px 22px;
box-sizing: border-box;
text-align: left;
}
.share-info-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding-bottom: 12px;
margin-bottom: 16px;
border-bottom: 1px dashed #cbd5e1;
}
.share-meta-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.share-meta-label {
font-size: 12px;
color: #94a3b8;
font-weight: 500;
}
.share-meta-val {
font-size: 14px;
color: #1e293b;
font-weight: 600;
}
.share-link-section {
margin-bottom: 14px;
}
.share-section-title {
font-size: 12px;
font-weight: 500;
color: #64748b;
margin-bottom: 8px;
}
.share-link-input :deep(.el-input__inner) {
font-size: 13px;
color: #334155;
background-color: #ffffff;
}
.share-link-input :deep(.el-input-group__append) {
background-color: #eff6ff;
color: #2563eb;
font-weight: 500;
border-color: #dbeafe;
cursor: pointer;
transition: all 0.2s;
padding: 0 16px;
}
.share-link-input :deep(.el-input-group__append:hover) {
background-color: #dbeafe;
}
.share-expiry-row {
display: flex;
align-items: center;
font-size: 12px;
color: #64748b;
margin-bottom: 16px;
padding: 8px 12px;
background: #ffffff;
border: 1px solid #f1f5f9;
border-radius: 6px;
}
.expiry-icon {
margin-right: 6px;
color: #94a3b8;
font-size: 14px;
}
.expiry-time {
color: #e11d48;
font-weight: 600;
margin-left: 2px;
}
.share-action-section {
width: 100%;
}
.invite-copy-btn {
width: 100%;
height: 40px;
font-size: 13px;
font-weight: 500;
border-radius: 8px;
}
.dialog-footer-center {
display: flex;
justify-content: center;
width: 100%;
}
</style>
@@ -2,7 +2,8 @@
<template>
<el-form ref="loginFormRef" :model="loginForm" :rules="loginRules" size="large">
<s-form-item v-if="tenantOption == TenantEnum.CHOSE" prop="orgId" class="-ml-12px">
<s-select v-model="loginForm.tenantId" :options="tenantOptions" label="name" value="id" placeholder="请选择租户"></s-select>
<s-select v-model="loginForm.tenantId" :options="tenantOptions" label="name" value="id"
placeholder="请选择租户"></s-select>
</s-form-item>
<el-form-item prop="account">
<el-input v-model="loginForm.account" placeholder="请输入用户名">
@@ -14,7 +15,8 @@
</el-input>
</el-form-item>
<el-form-item prop="password">
<el-input v-model="loginForm.password" type="password" placeholder="请输入密码" show-password autocomplete="new-password">
<el-input v-model="loginForm.password" type="password" placeholder="请输入密码" show-password
autocomplete="new-password">
<template #prefix>
<el-icon class="el-input__icon">
<lock />
@@ -38,7 +40,8 @@
</el-form>
<div class="login-btn">
<el-button :icon="CircleClose" round size="large" @click="resetForm(loginFormRef)"> 重置 </el-button>
<el-button :icon="UserFilled" round size="large" type="primary" :loading="auth.loginLoading" @click="handleSubmit(loginFormRef)">
<el-button :icon="UserFilled" round size="large" type="primary" :loading="auth.loginLoading"
@click="handleSubmit(loginFormRef)">
登录
</el-button>
</div>
@@ -90,8 +93,8 @@ watch(
const loginFormRef = ref<FormInstance>(); // 表单实例
// 表单数据
const loginForm = reactive<Login.LoginForm>({
account: "superAdmin", //用户名
password: "123456", // 密码
account: "", //用户名
password: "", // 密码
validCode: "", // 验证码
validCodeReqNo: "", // 验证码请求号
tenantId: config.tenantIdGet
+391 -61
View File
@@ -1,5 +1,19 @@
<template>
<main class="fill-page" v-loading="loading">
<main class="fill-page" :class="{ 'is-error-state': Boolean(error) }" v-loading="loading">
<!-- 异常状态展示(居中悬浮卡片,对齐 public-status 风格) -->
<div v-if="error" class="public-error-container">
<section class="error-status-card">
<div class="error-status-mark">!</div>
<h1 class="error-status-title">{{ errorTitle }}</h1>
<p class="error-status-desc">{{ errorDescription }}</p>
<div class="error-status-actions">
<el-button type="primary" class="error-retry-btn" @click="load()">重新加载</el-button>
</div>
</section>
</div>
<!-- 正常入职内容 -->
<template v-else>
<!-- 顶部品牌横幅 -->
<header class="brand-header">
<div class="brand-content">
@@ -16,13 +30,7 @@
<!-- 容器卡片 -->
<div class="main-container">
<el-result v-if="error" icon="error" title="访问受限" :sub-title="error">
<template #extra>
<el-button type="primary" @click="load()">重新加载</el-button>
</template>
</el-result>
<template v-else-if="task">
<template v-if="task">
<!-- 任务状态提示条 -->
<div class="status-banner" :class="task.taskStatus">
<div class="status-banner-main">
@@ -68,33 +76,32 @@
<!-- 步骤导航条:PC端展示完整步骤,移动端展示紧凑自适应导航 -->
<div class="steps-wrapper desktop-only">
<el-steps :active="currentStep" finish-status="success" align-center>
<el-step title="入职登记表" icon="User" />
<el-step title="材料补充项" icon="Document" />
<el-step title="证件材料" icon="FolderChecked" />
<el-step title="材料确认" icon="Tickets" />
<el-step v-for="step in visibleSteps" :key="step.key" :title="step.title" :icon="step.icon" />
</el-steps>
</div>
<div class="mobile-steps-wrapper mobile-only">
<div class="mobile-steps-track">
<div v-for="(stepName, index) in ['1. 登记表', '2. 补充项', '3. 证件材料', '4. 材料确认']" :key="index"
<div v-for="(step, index) in visibleSteps" :key="step.key"
class="mobile-step-item" :class="{
'is-active': currentStep === index,
'is-finished': currentStep > index,
'is-pending': currentStep < index
}">
<div class="step-indicator-bar" />
<span class="step-label">{{ stepName }}</span>
<span class="step-label">{{ (index + 1) + '. ' + step.shortTitle }}</span>
</div>
</div>
<div class="mobile-current-indicator">
当前进行:<span class="highlight-step">第 {{ currentStep + 1 }}/4 步 · {{ ['入职登记表填写', '签署材料补充项', '证件材料上传',
'签署材料查阅与确认'][currentStep] }}</span>
当前进行:<span class="highlight-step">第 {{ currentStep + 1 }}/{{ visibleSteps.length }} 步 · {{ currentStepDefinition.description }}</span>
</div>
</div>
<!-- 步骤 1: 入职登记表 -->
<div v-show="currentStep === 0" class="step-content">
<div v-show="currentStepKey === 'registration'" class="step-content">
<div class="registration-header">
<h2 class="reg-title">入 职 登 记 表</h2>
<p class="reg-subtitle">请如实填写个人基础档案、教育经历及家庭相关信息,标注 * 为必填项,信息将用于建立人事档案,请务必保证资料真实准确。</p>
@@ -301,19 +308,19 @@
</div>
<!-- 5. 教育学习经历动态列表 -->
<div class="card-section">
<div id="education-history-section" class="card-section">
<div class="card-section-header">
<div class="card-section-title">
<div class="title-icon-box"><el-icon>
<Reading />
</el-icon></div>
<span>教育学习经历</span>
<span><span class="required-asterisk">*</span>教育学习经历</span>
</div>
<el-button v-if="canEdit" size="small" type="primary" plain icon="Plus" @click="addEducation">
<el-button v-if="canEdit" size="small" class="section-action-btn" icon="Plus" @click="addEducation">
添加经历
</el-button>
</div>
<div v-if="!educationHistory.length" class="empty-hint">暂未添加学习经历,可点击右上角添加</div>
<div v-if="!educationHistory.length" class="empty-hint">暂未添加学习经历,可点击右上角添加(至少填写一条最高学历)</div>
<div v-for="(item, idx) in educationHistory" :id="`education-history-${idx}`" :key="idx"
class="dynamic-row-box">
<div class="row-badge-header">
@@ -347,19 +354,19 @@
</div>
<!-- 6. 工作经历动态列表 -->
<div class="card-section">
<div id="work-history-section" class="card-section">
<div class="card-section-header">
<div class="card-section-title">
<div class="title-icon-box"><el-icon>
<Briefcase />
</el-icon></div>
<span>主要工作经历</span>
<span><span class="required-asterisk">*</span>主要工作经历</span>
</div>
<el-button v-if="canEdit" size="small" type="primary" plain icon="Plus" @click="addWork">
<el-button v-if="canEdit" size="small" class="section-action-btn" icon="Plus" @click="addWork">
添加经历
</el-button>
</div>
<div v-if="!workHistory.length" class="empty-hint">暂未添加工作经历(应届生可留空)</div>
<div v-if="!workHistory.length" class="empty-hint">暂未添加工作经历,可点击右上角添加(应届生可填兼职、在校或实习经历)</div>
<div v-for="(item, idx) in workHistory" :id="`work-history-${idx}`" :key="idx" class="dynamic-row-box">
<div class="row-badge-header">
<span class="row-badge">工作经历 #{{ idx + 1 }}</span>
@@ -374,12 +381,12 @@
</el-col>
<el-col :xs="24" :sm="6">
<el-form-item label="工作单位名称">
<el-input v-model="item.companyName" placeholder="公司全称" :disabled="!canEdit" />
<el-input v-model="item.companyName" placeholder="工作单位/实习单位全称" :disabled="!canEdit" />
</el-form-item>
</el-col>
<el-col :xs="12" :sm="4">
<el-form-item label="担任职务">
<el-input v-model="item.position" placeholder="职位名称" :disabled="!canEdit" />
<el-input v-model="item.position" placeholder="担任职务/在校岗位" :disabled="!canEdit" />
</el-form-item>
</el-col>
<el-col :xs="12" :sm="4">
@@ -389,7 +396,7 @@
</el-col>
<el-col :xs="24" :sm="5">
<el-form-item label="离职原因">
<el-input v-model="item.leaveReason" placeholder="简述离职原因" :disabled="!canEdit" />
<el-input v-model="item.leaveReason" placeholder="简述离职/实习结束原因" :disabled="!canEdit" />
</el-form-item>
</el-col>
</el-row>
@@ -397,19 +404,19 @@
</div>
<!-- 7. 家庭成员动态列表 -->
<div class="card-section">
<div id="family-members-section" class="card-section">
<div class="card-section-header">
<div class="card-section-title">
<div class="title-icon-box"><el-icon>
<HomeFilled />
</el-icon></div>
<span>家庭成员</span>
<span><span class="required-asterisk">*</span>家庭成员</span>
</div>
<el-button v-if="canEdit" size="small" type="primary" plain icon="Plus" @click="addFamily">
<el-button v-if="canEdit" size="small" class="section-action-btn" icon="Plus" @click="addFamily">
添加成员
</el-button>
</div>
<div v-if="!familyMembers.length" class="empty-hint">暂未添加家庭成员信息</div>
<div v-if="!familyMembers.length" class="empty-hint">暂未添加家庭成员信息,可点击右上角添加(至少填写一位直系亲属)</div>
<div v-for="(item, idx) in familyMembers" :id="`family-member-${idx}`" :key="idx"
class="dynamic-row-box">
<div class="row-badge-header">
@@ -435,7 +442,7 @@
</el-col>
<el-col :xs="24" :sm="8">
<el-form-item label="工作单位">
<el-input v-model="item.company" placeholder="工作单位及职务" :disabled="!canEdit" />
<el-input v-model="item.company" placeholder="工作单位及职务(无则填无)" :disabled="!canEdit" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="5">
@@ -450,18 +457,13 @@
</div>
<!-- 步骤 2: 合同专属增量补充项 (自适应差集) -->
<div v-show="currentStep === 1" class="step-content">
<div v-if="extraVariables.length" v-show="currentStepKey === 'extra'" class="step-content">
<div class="section-header">
<h3 class="section-title">签署材料专属补充信息</h3>
<p class="section-desc">以下是本次签署材料中特殊约定的增量字段,登记表未涵盖的项需在此补充确认</p>
</div>
<div v-if="!extraVariables.length" class="all-covered-card">
<el-result icon="success" title="个人信息已完整覆盖"
sub-title="您在《入职登记表》中填写的姓名、身份证、电话、银行卡等信息已完整满足本次签署材料所需,无需额外补充!" />
</div>
<el-form v-else label-position="top" class="extra-form">
<el-form label-position="top" class="extra-form">
<el-alert title="请补充填写以下签署材料专有信息:" type="info" show-icon :closable="false" class="mb-3" />
<el-row :gutter="16">
<el-col v-for="(v, index) in extraVariables" :id="`extra-variable-${index}`" :key="v.variableCode"
@@ -490,7 +492,7 @@
</div>
<!-- 步骤 3: 证件材料上传 -->
<div v-show="currentStep === 2" class="step-content">
<div v-show="currentStepKey === 'materials'" class="step-content">
<div class="section-header">
<h3 class="section-title">证件与材料上传</h3>
<p class="section-desc">请上传清晰真实有效的证件扫描件或照片(支持 PNG、JPG、PDF)</p>
@@ -575,7 +577,7 @@
</div>
<!-- 步骤 4: 合同查阅与签署 -->
<div v-show="currentStep === 3" class="step-content">
<div v-show="currentStepKey === 'confirmation'" class="step-content">
<div v-if="canEdit" id="identity-confirmation-card" class="identity-confirmation-card">
<div class="identity-confirmation-heading">
<div>
@@ -664,25 +666,60 @@
<!-- 底部向导操作栏 -->
<div class="wizard-footer">
<el-button v-if="currentStep > 0" size="large" @click="currentStep--">上一步</el-button>
<el-button v-if="currentStep < 3" type="primary" size="large" @click="nextStep">
下一步
<el-button v-if="currentStep > 0" size="large" :disabled="savingStep || isAnyMaterialUploading" @click="currentStep--">上一步</el-button>
<el-button v-if="currentStep < visibleSteps.length - 1" type="primary" size="large"
:loading="savingStep || (currentStepKey === 'materials' && isAnyMaterialUploading)"
:disabled="savingStep || isAnyMaterialUploading"
@click="nextStep">
{{ (currentStepKey === 'materials' && isAnyMaterialUploading) ? "材料上传中..." : (savingStep ? "保存中..." : "下一步") }}
</el-button>
<el-button v-if="currentStep === 3 && canEdit" type="success" size="large" icon="Check"
:loading="submitting" @click="submitAll">
<el-button v-if="currentStepKey === 'confirmation' && canEdit" type="success" size="large" icon="Check"
:loading="submitting" :disabled="savingStep || isAnyMaterialUploading" @click="submitAll">
确认无误并提交入职资料
</el-button>
</div>
</div>
</template>
</div>
</template>
</main>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { ElMessage, ElMessageBox, FormInstance, FormRules } from "element-plus";
import { ElMessage as RawElMessage, ElMessageBox, FormInstance, FormRules } from "element-plus";
/**
* 移动端/公开端单例独占式 Toast 封装:
* 1. 弹出新提示前自动 closeAll(),彻底杜绝手机端多条提示同时排队堆叠满屏的问题
* 2. 启用 grouping: true 自动合并短时间内同一文案的提示
*/
const ElMessage = {
success(options: any) {
RawElMessage.closeAll();
const config = typeof options === "string" ? { message: options } : options;
return RawElMessage.success({ grouping: true, ...config });
},
warning(options: any) {
RawElMessage.closeAll();
const config = typeof options === "string" ? { message: options } : options;
return RawElMessage.warning({ grouping: true, ...config });
},
error(options: any) {
RawElMessage.closeAll();
const config = typeof options === "string" ? { message: options } : options;
return RawElMessage.error({ grouping: true, ...config });
},
info(options: any) {
RawElMessage.closeAll();
const config = typeof options === "string" ? { message: options } : options;
return RawElMessage.info({ grouping: true, ...config });
},
closeAll() {
RawElMessage.closeAll();
}
};
import {
OnboardingPublicTask,
onboardingPublicApi
@@ -707,11 +744,34 @@ const originalViewportContent = viewportMeta?.content;
const loading = ref(false);
const submitting = ref(false);
const savingStep = ref(false);
const error = ref("");
const errorTitle = computed(() => {
const msg = error.value || "";
if (msg.includes("链接不存在") || msg.includes("Token") || msg.includes("无效")) {
return "入职链接无效";
}
if (msg.includes("过期")) {
return "入职链接已过期";
}
return "访问受限";
});
const errorDescription = computed(() => {
const msg = error.value || "";
if (msg.includes("链接不存在") || msg.includes("Token") || msg.includes("无效")) {
return "入职链接不存在或已失效,请联系招聘人员重新获取入职链接。";
}
if (msg.includes("过期")) {
return "该链接已超过有效期,不能继续填写。请联系招聘人员重新获取入职链接。";
}
return `${msg},请检查网络或联系招聘人员协助处理。`;
});
const currentTimestamp = ref(Date.now());
let linkExpiryTimer: number | undefined;
const currentStep = ref(0);
const uploadingMaterials = reactive<Record<string, boolean>>({});
const isAnyMaterialUploading = computed(() => Object.values(uploadingMaterials).some(Boolean));
type ContractPrepareStatus = "idle" | "preparing" | "ready" | "error";
const contractPrepareStatus = reactive<Record<string, ContractPrepareStatus>>({});
const contractPrepareErrors = reactive<Record<string, string>>({});
@@ -1088,6 +1148,23 @@ const extraVariables = computed(() => {
return result;
});
type OnboardingStepKey = "registration" | "extra" | "materials" | "confirmation";
const visibleSteps = computed(() => {
const steps = [
{ key: "registration" as OnboardingStepKey, title: "入职登记表", shortTitle: "登记表", description: "入职登记表填写", icon: "User" }
];
if (extraVariables.value.length) {
steps.push({ key: "extra", title: "材料补充项", shortTitle: "补充项", description: "签署材料补充项", icon: "Document" });
}
steps.push(
{ key: "materials", title: "证件材料", shortTitle: "证件材料", description: "证件材料上传", icon: "FolderChecked" },
{ key: "confirmation", title: "材料确认", shortTitle: "材料确认", description: "签署材料查阅与确认", icon: "Tickets" }
);
return steps;
});
const currentStepDefinition = computed(() => visibleSteps.value[currentStep.value] || visibleSteps.value[0]);
const currentStepKey = computed<OnboardingStepKey>(() => currentStepDefinition.value.key);
function addEducation() {
educationHistory.value.push({ timeRange: "", location: "", content: "", result: "" });
}
@@ -1219,6 +1296,12 @@ function validateExtraVariables() {
}
function validateRegistrationLists() {
if (!educationHistory.value.length) {
ElMessage.warning("请至少添加一条教育学习经历(最高学历)");
locateValidationTarget("education-history-section");
return false;
}
for (let index = 0; index < educationHistory.value.length; index++) {
const row = educationHistory.value[index];
const requiredFields = [
@@ -1235,6 +1318,12 @@ function validateRegistrationLists() {
}
}
if (!workHistory.value.length) {
ElMessage.warning("请至少添加一条工作经历(应届生可填写兼职、在校或实习经历)");
locateValidationTarget("work-history-section");
return false;
}
for (let index = 0; index < workHistory.value.length; index++) {
const row = workHistory.value[index];
const requiredFields = [
@@ -1251,6 +1340,12 @@ function validateRegistrationLists() {
}
}
if (!familyMembers.value.length) {
ElMessage.warning("请至少添加一位家庭成员信息(如父母或配偶)");
locateValidationTarget("family-members-section");
return false;
}
for (let index = 0; index < familyMembers.value.length; index++) {
const row = familyMembers.value[index];
const requiredFields = [
@@ -1301,12 +1396,16 @@ function getRemainingFileCount(mat: any) {
);
}
const PUBLIC_MATERIAL_MAX_FILE_SIZE_MB = 20;
const PUBLIC_MATERIAL_DEFAULT_EXTENSIONS = ["png", "jpg", "jpeg", "pdf"];
function getAllowedExtensions(mat: any) {
return String(mat.allowedExtensions || "")
const configured = String(mat.allowedExtensions || "")
.toLowerCase()
.split(",")
.map((item: string) => item.trim().replace(/^\./, ""))
.filter(Boolean);
return configured.length ? configured : [...PUBLIC_MATERIAL_DEFAULT_EXTENSIONS];
}
function getUploadAccept(mat: any) {
@@ -1320,9 +1419,11 @@ function getMaterialLimitText(mat: any) {
const formatText = allowedExtensions.length
? allowedExtensions.join("、").toUpperCase()
: "不限";
const sizeText = mat.maxFileSizeMb
? ` · 单个不超过 ${mat.maxFileSizeMb} MB`
: "";
const configuredSize = Number(mat.maxFileSizeMb);
const maxFileSizeMb = configuredSize > 0
? Math.min(configuredSize, PUBLIC_MATERIAL_MAX_FILE_SIZE_MB)
: PUBLIC_MATERIAL_MAX_FILE_SIZE_MB;
const sizeText = ` · 单个不超过 ${maxFileSizeMb} MB`;
return `最多 ${getMaxFileCount(mat)} 份 · 格式:${formatText}${sizeText}`;
}
@@ -1388,7 +1489,8 @@ async function load(silent = false) {
uploadedMaterials.value = data.materials || [];
contractVariables.value = data.contractVariables || [];
formFieldRequirements.value = data.formFields || [];
contractVariables.value.forEach((variable: any) => {
Object.keys(customVariables).forEach(key => delete customVariables[key]);
extraVariables.value.forEach((variable: any) => {
if (!(variable.variableCode in customVariables) && variable.defaultValue != null) {
customVariables[variable.variableCode] = String(variable.defaultValue);
}
@@ -1407,7 +1509,14 @@ async function load(silent = false) {
try { familyMembers.value = JSON.parse(data.employeeInfo.familyMembersJson); } catch { }
}
if (data.employeeInfo.customVariablesJson) {
try { Object.assign(customVariables, JSON.parse(data.employeeInfo.customVariablesJson)); } catch { }
try {
const savedVariables = JSON.parse(data.employeeInfo.customVariablesJson);
extraVariables.value.forEach((variable: any) => {
if (Object.prototype.hasOwnProperty.call(savedVariables, variable.variableCode)) {
customVariables[variable.variableCode] = savedVariables[variable.variableCode];
}
});
} catch { }
}
}
await refreshMaterialPreviews();
@@ -1434,7 +1543,9 @@ async function saveCurrentEmployeeInfo() {
educationHistoryJson: JSON.stringify(educationHistory.value),
workHistoryJson: JSON.stringify(workHistory.value),
familyMembersJson: JSON.stringify(familyMembers.value),
customVariablesJson: JSON.stringify(customVariables)
customVariablesJson: JSON.stringify(Object.fromEntries(
extraVariables.value.map((variable: any) => [variable.variableCode, customVariables[variable.variableCode] ?? ""])
))
};
await onboardingPublicApi.saveEmployeeInfo(payload);
}
@@ -1465,7 +1576,17 @@ async function validateRegistrationForm() {
}
async function nextStep() {
if (currentStep.value === 0) {
if (savingStep.value) {
return;
}
if (isAnyMaterialUploading.value) {
ElMessage.closeAll();
ElMessage.warning("材料正在上传中,请稍候再点击下一步");
return;
}
savingStep.value = true;
try {
if (currentStepKey.value === "registration") {
if (!await validateRegistrationForm()) {
return;
}
@@ -1474,18 +1595,25 @@ async function nextStep() {
}
if (canEdit.value) {
await saveCurrentEmployeeInfo();
ElMessage.closeAll();
ElMessage.success("入职登记表已临时保存");
}
} else if (currentStep.value === 1) {
} else if (currentStepKey.value === "extra") {
if (!validateExtraVariables()) {
return;
}
if (canEdit.value) {
await saveCurrentEmployeeInfo();
}
} else if (currentStep.value === 2) {
} else if (currentStepKey.value === "materials") {
if (isAnyMaterialUploading.value) {
ElMessage.closeAll();
ElMessage.warning("材料正在上传中,请稍候再点击下一步");
return;
}
const missing = materialRequirements.value.filter(m => m.required && !getUploadedMaterials(m.materialTypeId).length);
if (missing.length) {
ElMessage.closeAll();
ElMessage.warning(`请先上传必传材料:${missing.map((m: any) => m.materialName).join("、")}`);
locateValidationTarget(`material-requirement-${missing[0].materialTypeId}`);
return;
@@ -1493,9 +1621,20 @@ async function nextStep() {
}
currentStep.value++;
window.scrollTo({ top: 0, behavior: "smooth" });
if (currentStep.value === 3) {
if (currentStepKey.value === "confirmation") {
await prepareContracts();
}
} catch (error: any) {
const message = error?.msg || error?.message || "临时保存失败,请稍后重试";
ElMessage.closeAll();
await ElMessageBox.alert(message, "无法进入下一步", {
type: "warning",
confirmButtonText: "我知道了",
closeOnClickModal: false
}).catch(() => undefined);
} finally {
savingStep.value = false;
}
}
function materialPreviewKey(file: any) {
@@ -1569,8 +1708,12 @@ function beforeUpload(file: File, mat: any) {
ElMessage.error(`仅支持格式:${mat.allowedExtensions}`);
return false;
}
if (mat.maxFileSizeMb && file.size > Number(mat.maxFileSizeMb) * 1024 * 1024) {
ElMessage.error(`单个文件不能超过 ${mat.maxFileSizeMb} MB`);
const configuredSize = Number(mat.maxFileSizeMb);
const maxFileSizeMb = configuredSize > 0
? Math.min(configuredSize, PUBLIC_MATERIAL_MAX_FILE_SIZE_MB)
: PUBLIC_MATERIAL_MAX_FILE_SIZE_MB;
if (file.size > maxFileSizeMb * 1024 * 1024) {
ElMessage.error(`单个文件不能超过 ${maxFileSizeMb} MB,请压缩后重新上传`);
return false;
}
setMaterialUploading(mat.materialTypeId, true);
@@ -1933,6 +2076,87 @@ onBeforeUnmount(() => {
padding-bottom: 60px;
}
/* 异常状态居中卡片样式(对齐 public-status 风格) */
.fill-page.is-error-state {
display: flex;
min-height: 100vh;
padding: 24px;
align-items: center;
justify-content: center;
box-sizing: border-box;
background: #f0fdf7;
}
.public-error-container {
display: flex;
width: 100%;
align-items: center;
justify-content: center;
}
.error-status-card {
width: min(100%, 420px);
padding: 42px 28px;
text-align: center;
background: #ffffff;
border: 1px solid #d1fae5;
border-radius: 16px;
box-shadow: 0 12px 36px rgb(5 150 105 / 10%);
}
.error-status-mark {
display: flex;
width: 56px;
height: 56px;
margin: 0 auto 20px;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 30px;
font-weight: 700;
background: #059669;
border-radius: 50%;
}
.error-status-title {
margin: 0;
color: #064e3b;
font-size: 24px;
font-weight: 700;
line-height: 1.3;
}
.error-status-desc {
margin: 14px 0 0;
color: #64748b;
font-size: 15px;
line-height: 1.8;
}
.error-status-actions {
margin-top: 24px;
}
.error-retry-btn {
height: 40px;
padding: 0 26px;
font-size: 15px;
font-weight: 500;
border-radius: 8px;
background-color: #059669 !important;
border-color: #059669 !important;
color: #ffffff !important;
box-shadow: 0 4px 12px rgba(5, 150, 105, 0.2);
transition: all 0.2s ease;
}
.error-retry-btn:hover {
background-color: #047857 !important;
border-color: #047857 !important;
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(5, 150, 105, 0.3);
}
/* 顶部大背景 (Hero Header Background) - 纯色柔和淡绿,去渐变 */
.brand-header {
background: #ecfdf5;
@@ -2189,6 +2413,46 @@ onBeforeUnmount(() => {
margin-bottom: 14px;
}
.card-section-header .card-section-title {
margin-bottom: 0;
}
.required-asterisk {
color: #e11d48;
margin-right: 4px;
font-weight: 700;
font-size: 15px;
}
.section-action-btn {
background-color: #ecfdf5 !important;
border: 1px solid #a7f3d0 !important;
color: #059669 !important;
font-weight: 500;
border-radius: 6px;
height: 28px;
line-height: 26px;
padding: 0 10px;
font-size: 12px;
transition: all 0.2s ease-in-out;
}
.section-action-btn :deep(.el-icon) {
color: inherit !important;
}
.section-action-btn:hover {
background-color: #059669 !important;
border-color: #059669 !important;
color: #ffffff !important;
}
.section-action-btn:active {
background-color: #047857 !important;
border-color: #047857 !important;
color: #ffffff !important;
}
.dynamic-row-box {
background: #fbfdfb;
border: 1px solid #e5e7eb;
@@ -2625,6 +2889,72 @@ onBeforeUnmount(() => {
background-color: #ecfdf5;
}
.wizard-footer .el-button--success {
background-color: #059669 !important;
border-color: #059669 !important;
color: #ffffff !important;
font-weight: 600;
border-radius: 6px;
padding: 10px 24px;
height: 40px;
box-shadow: 0 2px 8px rgba(5, 150, 105, 0.25);
transition: all 0.2s;
}
.wizard-footer .el-button--success:hover {
background-color: #047857 !important;
border-color: #047857 !important;
color: #ffffff !important;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(5, 150, 105, 0.35);
}
.wizard-footer .el-button--success:active {
background-color: #065f46 !important;
border-color: #065f46 !important;
color: #ffffff !important;
}
/* 个人信息已完整覆盖卡片与图标 */
.all-covered-card {
padding: 38px 24px;
background: #ffffff;
border: 1px solid #d1fae5;
border-radius: 14px;
box-shadow: 0 4px 20px rgba(5, 150, 105, 0.06);
text-align: center;
margin: 16px 0 24px;
}
.covered-status-mark {
display: flex;
width: 56px;
height: 56px;
margin: 0 auto 16px;
align-items: center;
justify-content: center;
color: #ffffff;
background: #059669;
border-radius: 50%;
box-shadow: 0 4px 14px rgba(5, 150, 105, 0.25);
}
.covered-status-title {
margin: 0 0 10px;
color: #064e3b;
font-size: 20px;
font-weight: 700;
letter-spacing: 0.5px;
}
.covered-status-desc {
max-width: 480px;
margin: 0 auto;
color: #64748b;
font-size: 14px;
line-height: 1.7;
}
.readonly-banner {
margin-bottom: 24px;
}
+278
View File
@@ -0,0 +1,278 @@
// Offline checks: execute the real composable and page setup code with stubbed APIs.
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import vm from "node:vm";
import test from "node:test";
import { compileScript, parse } from "@vue/compiler-sfc";
import ts from "typescript";
import * as vue from "vue";
const require = createRequire(import.meta.url);
const root = new URL("../", import.meta.url);
const read = path => readFileSync(new URL(path, root), "utf8");
const deferred = () => {
let resolve, reject;
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
};
function harness(overrides = {}) {
const calls = [];
const api = new Proxy(overrides, {
get(target, method) {
return (...args) => {
calls.push({ method, args });
if (method in target) return target[method](...args);
if (method === "listCompanies") return Promise.resolve({ data: [{ id: 1, companyName: "A" }, { id: 2, companyName: "B" }] });
if (method === "listDepartments") return Promise.resolve({ data: [{ id: args[0] * 10, departmentName: "部门" }] });
if (method === "listPositionsByDepartment") return Promise.resolve({ data: [{ id: args[0] * 10, positionName: "岗位" }] });
if (method === "listMaterialTypes") return Promise.resolve({ data: [{ id: 1, status: "enable" }] });
if (method === "onboardingTemplatePage") return Promise.resolve({ data: { list: [], total: 0 } });
throw new Error("Unexpected API: " + method);
};
}
});
function evaluate(source, imports = {}) {
const output = ts.transpileModule(source.replaceAll("import.meta.env", "({})"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true }
}).outputText;
const module = { exports: {} };
vm.runInNewContext(output, {
module, exports: module.exports, console,
require(name) {
if (name in imports) return imports[name];
if (name === "vue") return vue;
if (name === "@/api/modules/biz/onboarding") return { onboardingTaskApi: api };
if (name === "@/hooks/useOnboardingScopeOptions") return scopeModule;
if (name === "@/utils/onboardingPermission") return {
useOnboardingPermission: () => ({ canManage: () => true, disabledReason: () => "", hasAction: () => true })
};
if (name === "@/utils/messageBox" || name === "@/utils") return {};
if (name === "element-plus") return { ElMessage: { success() {}, warning() {}, error() {} }, ElMessageBox: {} };
if (name === "@element-plus/icons-vue" || name.endsWith(".vue")) return {};
return require(name);
}
});
return module.exports;
}
const scopeModule = evaluate(read("src/hooks/useOnboardingScopeOptions.ts"));
function page(path, props = {}) {
const mounted = [];
const descriptor = parse(read(path), { filename: path }).descriptor;
const code = compileScript(descriptor, { id: path }).content;
const component = evaluate(code, { vue: { ...vue, onMounted: callback => mounted.push(callback), onBeforeUnmount() {} } }).default;
const effects = vue.effectScope();
const state = effects.run(() => component.setup(props, { expose() {} }));
return { state, mount: () => Promise.all(mounted.map(callback => callback())), stop: () => effects.stop() };
}
return { api, calls, scopeModule, page };
}
test("dependent options load only after selecting their parent, and reset makes no request", async () => {
const h = harness();
const selection = vue.reactive({});
const options = h.scopeModule.useOnboardingScopeOptions(() => selection);
assert.equal(h.calls.length, 0);
await options.onDepartmentChange(10);
assert.equal(h.calls.length, 0);
selection.companyId = 1;
await options.onCompanyChange(1);
assert.equal(h.calls.length, 1);
assert.equal(h.calls[0].method, "listDepartments");
assert.equal(options.positions.value.length, 0);
selection.departmentId = 10;
await options.onDepartmentChange(10);
assert.equal(h.calls[1].method, "listPositionsByDepartment");
selection.positionId = 100;
selection.companyId = undefined;
options.reset();
assert.equal(selection.departmentId, undefined);
assert.equal(selection.positionId, undefined);
assert.equal(options.departments.value.length, 0);
assert.equal(options.positions.value.length, 0);
assert.equal(h.calls.length, 2);
});
test("slow department responses cannot restore a previous company or survive reset", async () => {
const a = deferred(), b = deferred(), c = deferred();
const h = harness({ listDepartments: id => ({ 1: a, 2: b, 3: c })[id].promise });
const selection = { companyId: 1 };
const options = h.scopeModule.useOnboardingScopeOptions(() => selection);
const first = options.onCompanyChange(1);
selection.companyId = 2;
const second = options.onCompanyChange(2);
b.resolve({ data: [{ id: 20 }] });
await second;
a.resolve({ data: [{ id: 10 }] });
await first;
assert.equal(options.departments.value[0].id, 20);
selection.companyId = 3;
const third = options.onCompanyChange(3);
options.reset();
c.resolve({ data: [{ id: 30 }] });
await third;
assert.equal(options.departments.value.length, 0);
});
test("slow position responses cannot overwrite a new department; failure leaves options empty", async () => {
const a = deferred(), b = deferred();
const h = harness({ listPositionsByDepartment: id => (id === 10 ? a : b).promise });
const selection = { companyId: 1, departmentId: 10 };
const options = h.scopeModule.useOnboardingScopeOptions(() => selection);
const first = options.onDepartmentChange(10);
selection.departmentId = 20;
const second = options.onDepartmentChange(20);
b.resolve({ data: [{ id: 200 }] });
await second;
a.resolve({ data: [{ id: 100 }] });
await first;
assert.equal(options.positions.value[0].id, 200);
const failed = harness({ listDepartments: () => Promise.reject(new Error("offline")) });
const empty = failed.scopeModule.useOnboardingScopeOptions(() => ({ companyId: 1 }));
await empty.onCompanyChange(1);
assert.equal(empty.departments.value.length, 0);
});
for (const [name, tableName, columnName] of [
["contract", "templateTable", "templateColumns"],
["task", "proTable", "columns"],
["employee", "proTable", "columns"]
]) {
test(name + ": initialization, filter cascade and reset use only necessary requests", async () => {
const h = harness();
const page = h.page("src/views/biz/onboarding/" + name + "/index.vue");
const state = page.state;
state[tableName].value = { searchParam: {}, enumMap: new Map() };
await page.mount();
assert.equal(h.calls.length, 1);
assert.equal(h.calls[0].method, "listCompanies");
const department = state[columnName].find(item => item.prop === "departmentId");
const position = state[columnName].find(item => item.prop === "positionId");
assert.equal(department.search.props.disabled, true);
assert.equal(position.search.props.disabled, true);
state[tableName].value.searchParam.companyId = 1;
await state.onSearchCompanyChange(1);
assert.equal(department.search.props.disabled, false);
assert.equal(position.search.props.disabled, true);
assert.equal(state[tableName].value.enumMap.get("departmentId")[0].value, 10);
state[tableName].value.searchParam.departmentId = 10;
await state.onSearchDepartmentChange(10);
assert.equal(position.search.props.disabled, false);
assert.equal(state[tableName].value.enumMap.get("positionId")[0].value, 100);
state[tableName].value.searchParam = {};
state.resetSearchScope();
assert.equal(h.calls.length, 3);
assert.equal(state[tableName].value.enumMap.get("departmentId").length, 0);
assert.equal(state[tableName].value.enumMap.get("positionId").length, 0);
page.stop();
});
}
test("entry config loads no hidden data; material loads share one in-flight request", async () => {
const pending = deferred();
const h = harness({ listMaterialTypes: () => pending.promise });
const page = h.page("src/views/biz/onboarding/entry-config/index.vue");
await page.mount();
assert.equal(h.calls.length, 0);
page.state.activeTab.value = "materials";
await vue.nextTick();
const shared = page.state.loadMaterials();
assert.equal(h.calls.length, 1);
pending.resolve({ data: [{ id: 5 }] });
await shared;
assert.equal(page.state.materialTypes.value[0].id, 5);
page.stop();
});
test("task form fetches only the selected template's materials and preserves names and HR fields", async () => {
const h = harness({
onboardingTemplateDetail: () => Promise.resolve({ data: {
contracts: [{ contractTemplateId: 11, contractTemplateVersionId: 12, required: true }]
} }),
templateDetail: () => Promise.resolve({ data: {
id: 11, companyId: 1, departmentId: 10, status: "enable", templateName: "劳动合同"
} }),
variablePage: () => Promise.resolve({ data: { list: [{
variableCode: "hr.Salary", variableName: "薪资", variableType: "contract",
hrEditable: true, defaultValue: "5000", dataType: "number", required: true
}] } })
});
const page = h.page("src/views/biz/onboarding/task/index.vue");
await page.mount();
page.state.form.companyId = 1;
page.state.form.departmentId = 10;
await page.state.onOnboardingTemplateChange(7);
assert.equal(h.calls.length, 4);
assert.equal(h.calls[1].method, "onboardingTemplateDetail");
assert.equal(h.calls[2].method, "templateDetail");
assert.equal(h.calls[2].args[0].id, 11);
assert.equal(h.calls[3].method, "variablePage");
assert.equal(page.state.contractPreviews.value[0].contractName, "劳动合同");
assert.equal(page.state.form.contractVariables[0].variableValue, "5000");
assert.equal(page.state.form.contractVariables[0].contractName, "劳动合同");
assert.equal(page.state.contractConfigurationError.value, "");
page.stop();
});
test("entry template list loads just companies and its page; form data is deferred", async () => {
const h = harness();
let materials = 0;
const page = h.page("src/views/biz/onboarding/entry-config/components/OnboardingTemplatePanel.vue", {
loadMaterials: async () => { materials++; return [{ id: 7, status: "enable" }]; }
});
await page.mount();
assert.equal(h.calls.length, 2);
assert.equal(materials, 0);
page.state.query.companyId = 1;
await page.state.onQueryCompanyChange(1);
assert.equal(h.calls.length, 4);
assert.equal(h.calls[2].method, "listDepartments");
assert.equal(h.calls[3].method, "onboardingTemplatePage");
page.state.resetQuery();
await vue.nextTick();
assert.equal(h.calls.length, 5);
assert.equal(page.state.queryDepartments.value.length, 0);
await page.state.openCreate();
assert.equal(materials, 1);
assert.equal(page.state.materialTypes.value[0].id, 7);
assert.equal(h.calls.length, 5);
page.stop();
});
test("entry template list ignores older search responses", async () => {
const a = deferred(), b = deferred();
let count = 0;
const h = harness({ onboardingTemplatePage: () => (++count === 1 ? a : b).promise });
const page = h.page("src/views/biz/onboarding/entry-config/components/OnboardingTemplatePanel.vue", { loadMaterials: async () => [] });
const first = page.state.loadTemplates();
page.state.query.keyword = "new";
const second = page.state.loadTemplates();
b.resolve({ data: { list: [{ id: 2 }], total: 1 } });
await second;
a.resolve({ data: { list: [{ id: 1 }], total: 1 } });
await first;
assert.equal(page.state.rows.value[0].id, 2);
assert.equal(page.state.loading.value, false);
page.stop();
});
test("company setup defers options until needed and shares loading across fast tab changes", async () => {
const pending = deferred();
const h = harness({ listCompanies: () => pending.promise });
const page = h.page("src/views/biz/onboarding/setup/index.vue");
await page.mount();
assert.equal(h.calls.length, 0);
page.state.activeTab.value = "department";
await vue.nextTick();
page.state.activeTab.value = "position";
await vue.nextTick();
assert.equal(h.calls.length, 1);
pending.resolve({ data: [{ id: 1, companyName: "A" }] });
await page.state.loadCompanies();
page.state.activeTab.value = "department";
await vue.nextTick();
assert.equal(h.calls.length, 1);
page.stop();
});