增加员工档案

This commit is contained in:
2026-08-28 18:01:09 +08:00
parent d3bc1833f8
commit 2dfc6d6aaf
14 changed files with 2716 additions and 24 deletions
+65
View File
@@ -0,0 +1,65 @@
import request from '@/utils/request';
/**
* 员工档案接口(OA 人事档案)
*
* 档案与员工(yz_backend_employee)一对一;子记录接口统一管理
* 教育经历 / 工作经历 / 合同信息 / 证照附件四类记录。
* 后端统一返回 { code, data, msg } 包装,调用方需自行取 res.data 使用。
*/
const BASE = '/backend/oa/employeefile';
/* --------------------------------- 档案 --------------------------------- */
/** 分页查询档案列表,支持 keyword / status / org_id 筛选 */
export function getEmployeeFileList(params) {
return request({ url: `${BASE}/list`, method: 'get', params });
}
/** 档案数量统计(总数 + 在职状态分布) */
export function getEmployeeFileStats() {
return request({ url: `${BASE}/stats`, method: 'get' });
}
/** 档案详情(含员工展示信息与全部子记录) */
export function getEmployeeFileDetail(id) {
return request({ url: `${BASE}/detail/${id}`, method: 'get' });
}
/** 为员工建档;file_no 留空由后端自动生成 */
export function createEmployeeFile(data) {
return request({ url: `${BASE}/create`, method: 'post', data });
}
/** 更新档案基本资料(不允许修改员工归属) */
export function updateEmployeeFile(id, data) {
return request({ url: `${BASE}/update/${id}`, method: 'post', data });
}
/** 删除档案(软删除) */
export function deleteEmployeeFile(id) {
return request({ url: `${BASE}/delete/${id}`, method: 'delete' });
}
/** 已建档员工ID列表,用于过滤出可建档(未建档)员工 */
export function getFiledEmployeeIds() {
return request({ url: `${BASE}/filedEmployees`, method: 'get' });
}
/* -------------------------------- 子记录 -------------------------------- */
/** 新增子记录,type: 1-教育经历 2-工作经历 3-合同信息 4-证照附件 */
export function createFileRecord(data) {
return request({ url: `${BASE}/record/create`, method: 'post', data });
}
/** 更新子记录 */
export function updateFileRecord(id, data) {
return request({ url: `${BASE}/record/update/${id}`, method: 'post', data });
}
/** 删除子记录(软删除) */
export function deleteFileRecord(id) {
return request({ url: `${BASE}/record/delete/${id}`, method: 'delete' });
}
+6
View File
@@ -110,6 +110,12 @@ const staticMainChildren = [
props: { module: "oa" },
meta: { requiresAuth: true, title: "职位管理", modulePath: "/apps/oa" }
},
{
path: "/apps/oa/employeefile",
name: "EmployeeFile",
component: () => import("@/views/apps/oa/employeefile/index.vue"),
meta: { requiresAuth: true, title: "员工档案", modulePath: "/apps/oa" }
},
{
path: "/apps/oa/schedule",
name: "OaSchedule",
@@ -0,0 +1,547 @@
<template>
<el-drawer v-model="visible" size="74%" :with-header="true" :title="`员工档案 - ${file.employee_name || ''}`">
<div v-loading="loading" class="file-detail">
<!-- 头部:员工概要 + 操作 -->
<div class="detail-header">
<div class="employee-info">
<div class="name-line">
<span class="name">{{ file.employee_name || '-' }}</span>
<el-tag :type="statusMeta(file.employment_status).type" size="small">
{{ statusMeta(file.employment_status).text }}
</el-tag>
<span class="file-no">{{ file.file_no || '-' }}</span>
</div>
<div class="meta-line">
{{ file.department_name || '-' }} · {{ file.position || '-' }}
<template v-if="file.employee_account"> · 工号 {{ file.employee_account }}</template>
<template v-if="file.phone"> · {{ file.phone }}</template>
</div>
</div>
<div class="header-actions">
<el-button :icon="Refresh" circle @click="loadDetail" />
<el-button type="primary" plain :icon="Edit" @click="emit('edit', file)">编辑资料</el-button>
</div>
</div>
<el-tabs v-model="activeTab" class="detail-tabs">
<!-- 基本资料 -->
<el-tab-pane label="基本资料" name="basic">
<div class="section-title">员工信息</div>
<el-descriptions :column="3" border size="small">
<el-descriptions-item label="姓名">{{ file.employee_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="性别">{{ genderText(file) }}</el-descriptions-item>
<el-descriptions-item label="生日">{{ file.birthday || '-' }}</el-descriptions-item>
<el-descriptions-item label="学历">{{ file.education || '-' }}</el-descriptions-item>
<el-descriptions-item label="民族">{{ file.nation || '-' }}</el-descriptions-item>
<el-descriptions-item label="手机">{{ file.phone || '-' }}</el-descriptions-item>
<el-descriptions-item label="邮箱">{{ file.email || '-' }}</el-descriptions-item>
<el-descriptions-item label="隶属单位">
{{ file.affiliate_unit_name || '-' }}
</el-descriptions-item>
<el-descriptions-item label="部门">{{ file.department_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="家庭住址" :span="2">
{{ file.home_address || '-' }}
</el-descriptions-item>
<el-descriptions-item label="账号状态">
<el-tag :type="employeeStatusTagType(file)" size="small">
{{ employeeStatusText(file) }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
<div class="section-title">档案信息</div>
<el-descriptions :column="3" border size="small">
<el-descriptions-item label="身份证号">{{ file.id_card || '-' }}</el-descriptions-item>
<el-descriptions-item label="政治面貌">{{ file.political_status || '-' }}</el-descriptions-item>
<el-descriptions-item label="婚姻状况">{{ maritalText(file.marital_status) }}</el-descriptions-item>
<el-descriptions-item label="籍贯">{{ file.native_place || '-' }}</el-descriptions-item>
<el-descriptions-item label="工作邮箱">{{ file.work_email || '-' }}</el-descriptions-item>
<el-descriptions-item label="入职日期">{{ file.hire_date || '-' }}</el-descriptions-item>
<el-descriptions-item label="转正日期">{{ file.regular_date || '-' }}</el-descriptions-item>
<el-descriptions-item label="离职日期">{{ file.leave_date || '-' }}</el-descriptions-item>
<el-descriptions-item label="紧急联系人">
<template v-if="file.emergency_contact">
{{ file.emergency_contact }}{{ file.emergency_phone ? `(${file.emergency_phone})` : '' }}
</template>
<template v-else>-</template>
</el-descriptions-item>
<el-descriptions-item label="户籍地址" :span="2">
{{ file.household_address || '-' }}
</el-descriptions-item>
<el-descriptions-item label="现居住址">{{ file.current_address || '-' }}</el-descriptions-item>
<el-descriptions-item label="备注" :span="3">{{ file.remark || '-' }}</el-descriptions-item>
</el-descriptions>
<div class="section-title">证照资料</div>
<div class="cert-grid">
<div v-for="cert in certPhotos" :key="cert.label" class="cert-item">
<div
v-if="cert.url"
class="cert-body"
:title="`点击预览${cert.label}`"
@click="previewCert(cert)"
>
<el-image
v-if="isImage(cert.url)"
:src="cert.url"
:preview-src-list="[cert.url]"
hide-on-click-modal
preview-teleported
fit="cover"
class="cert-image"
>
<template #placeholder>
<div class="cert-loading"><el-icon :size="22"><Loading /></el-icon></div>
</template>
</el-image>
<div v-else class="cert-pdf">
<el-icon :size="34"><Document /></el-icon>
<span>PDF 附件</span>
</div>
</div>
<div v-else class="cert-body cert-empty">未上传</div>
<div class="cert-label">{{ cert.label }}</div>
</div>
</div>
</el-tab-pane>
<!-- 教育经历 / 工作经历 / 合同信息:同构表格,配置驱动 -->
<el-tab-pane
v-for="conf in recordTabs"
:key="conf.type"
:label="`${conf.label}(${recordsOf(conf.type).length})`"
:name="conf.name"
>
<div class="tab-toolbar">
<el-button
type="primary"
size="small"
:icon="Plus"
@click="openRecordDialog({ type: conf.type })"
>
新增{{ conf.label }}
</el-button>
</div>
<el-table :data="recordsOf(conf.type)" stripe>
<el-table-column :label="conf.nameLabel" prop="title" min-width="150" show-overflow-tooltip />
<el-table-column :label="conf.subLabel" prop="sub_title" min-width="120" show-overflow-tooltip>
<template #default="{ row }">{{ row.sub_title || '-' }}</template>
</el-table-column>
<el-table-column v-if="conf.extraLabel" :label="conf.extraLabel" prop="extra" min-width="100">
<template #default="{ row }">{{ row.extra || '-' }}</template>
</el-table-column>
<el-table-column label="起止时间" min-width="190">
<template #default="{ row }">{{ dateRangeText(row) }}</template>
</el-table-column>
<el-table-column v-if="conf.type === 3" label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="contractStatus(row).type" size="small">{{ contractStatus(row).text }}</el-tag>
</template>
</el-table-column>
<el-table-column label="描述" prop="description" min-width="150" show-overflow-tooltip>
<template #default="{ row }">{{ row.description || '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right" align="center">
<template #default="{ row }">
<el-button
link
type="primary"
size="small"
@click="openRecordDialog({ type: conf.type, record: row })"
>
编辑
</el-button>
<el-button link type="danger" size="small" @click="handleDeleteRecord(row)">删除</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty :description="`暂无${conf.label}`" :image-size="70" />
</template>
</el-table>
</el-tab-pane>
<!-- 证照附件 -->
<el-tab-pane :label="`证照附件(${recordsOf(4).length})`" name="attachment">
<div class="tab-toolbar">
<el-button
type="primary"
size="small"
:icon="Plus"
@click="openRecordDialog({ type: 4 })"
>
上传证照
</el-button>
</div>
<div v-if="recordsOf(4).length" class="attachment-grid">
<div v-for="item in recordsOf(4)" :key="item.id" class="attachment-card">
<div class="attachment-body" @click="previewAttachment(item)">
<img
v-if="isImage(item.attachment_url)"
:src="item.attachment_url"
class="attachment-thumb"
alt=""
/>
<div v-else class="attachment-file">
<el-icon :size="34"><Document /></el-icon>
<span>PDF 附件</span>
</div>
</div>
<div class="attachment-name" :title="item.title">{{ item.title }}</div>
<div class="attachment-sub" :title="item.sub_title">{{ item.sub_title || '&nbsp;' }}</div>
<div class="attachment-actions">
<el-button link type="primary" size="small" @click="previewAttachment(item)">预览</el-button>
<el-button
link
type="primary"
size="small"
@click="openRecordDialog({ type: 4, record: item })"
>
编辑
</el-button>
<el-button link type="danger" size="small" @click="handleDeleteRecord(item)">删除</el-button>
</div>
</div>
</div>
<el-empty v-else description="暂无证照附件" :image-size="70" />
</el-tab-pane>
</el-tabs>
</div>
<RecordEditDialog ref="recordDialogRef" @success="loadDetail" />
</el-drawer>
</template>
<script setup>
import { computed, ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { Document, Edit, Loading, Plus, Refresh } from '@element-plus/icons-vue';
import { deleteFileRecord, getEmployeeFileDetail } from '@/api/employeeFile';
import {
employeeStatusTagType,
employeeStatusText,
genderText,
} from '@/views/apps/organization/composables';
import RecordEditDialog from './recordEditDialog.vue';
/**
* 员工档案详情抽屉:基本资料 + 教育/工作/合同/证照四类子记录管理。
* 档案编辑(基本资料)通过 edit 事件交给父页面打开编辑弹窗。
*/
const emit = defineEmits(['edit']);
const visible = ref(false);
const loading = ref(false);
const recordDialogRef = ref();
const file = ref({});
const records = ref([]);
const activeTab = ref('basic');
/** 教育经历/工作经历/合同信息三类同构表格的列配置 */
const recordTabs = [
{ type: 1, name: 'education', label: '教育经历', nameLabel: '学校', subLabel: '专业', extraLabel: '学历' },
{ type: 2, name: 'work', label: '工作经历', nameLabel: '公司', subLabel: '职位', extraLabel: '所在部门' },
{ type: 3, name: 'contract', label: '合同信息', nameLabel: '合同名称', subLabel: '合同类型', extraLabel: '签订主体' },
];
const MARITAL_TEXT = { 0: '未知', 1: '未婚', 2: '已婚', 3: '离异', 4: '丧偶' };
const STATUS_META = {
1: { text: '试用', type: 'warning' },
2: { text: '正式', type: 'success' },
3: { text: '离职', type: 'info' },
};
const maritalText = (value) => MARITAL_TEXT[Number(value ?? 0)] || '未知';
const statusMeta = (value) => STATUS_META[Number(value ?? 2)] || STATUS_META[2];
/** 证照资料:学历照片与身份证正反面(主表字段) */
const certPhotos = computed(() => [
{ label: '学历照片', url: file.value.education_photo || '' },
{ label: '身份证正面', url: file.value.id_card_front || '' },
{ label: '身份证反面', url: file.value.id_card_back || '' },
]);
const previewCert = (cert) => {
// PDF 无法用 el-image 预览,新窗口打开;图片走 el-image 内置放大
if (cert.url && !isImage(cert.url)) {
window.open(cert.url, '_blank');
}
};
const recordsOf = (type) => records.value.filter((item) => Number(item.type) === Number(type));
const dateRangeText = (row) => {
if (!row.start_date && !row.end_date) return '-';
return `${row.start_date || '?'} ~ ${row.end_date || '至今'}`;
};
/** 合同状态:以结束日期判断履行中 / 已到期 */
const contractStatus = (row) => {
if (!row.end_date) return { text: '长期', type: 'success' };
return row.end_date >= new Date().toISOString().slice(0, 10)
? { text: '履行中', type: 'success' }
: { text: '已到期', type: 'warning' };
};
const isImage = (url) => /\.(png|jpe?g|gif|webp|bmp)(\?.*)?$/i.test(String(url || ''));
const loadDetail = async () => {
const id = file.value?.id;
if (!id) return;
loading.value = true;
try {
const res = await getEmployeeFileDetail(id);
const data = res?.data || res || {};
file.value = data.file || {};
records.value = data.records || [];
} catch (error) {
ElMessage.error(error?.message || '加载档案详情失败');
} finally {
loading.value = false;
}
};
/** 打开抽屉;row 为列表行(至少含 id) */
const open = (row) => {
const id = typeof row === 'object' ? row?.id : row;
if (!id) {
ElMessage.error('未提供有效的档案ID');
return;
}
file.value = typeof row === 'object' ? { ...row } : { id };
records.value = [];
visible.value = true;
loadDetail();
};
const openRecordDialog = (options) => {
recordDialogRef.value?.open({ employeeId: file.value.employee_id, ...options });
};
const previewAttachment = (item) => {
const url = item.attachment_url;
if (!url) {
ElMessage.warning('该证照未上传附件');
return;
}
window.open(url, '_blank');
};
const handleDeleteRecord = async (row) => {
try {
await ElMessageBox.confirm(`确定删除「${row.title}」这条记录吗?`, '删除确认', { type: 'warning' });
} catch {
return;
}
try {
await deleteFileRecord(row.id);
ElMessage.success('删除成功');
await loadDetail();
} catch (error) {
ElMessage.error(error?.message || '删除失败');
}
};
defineExpose({ open, reload: loadDetail });
</script>
<style lang="less" scoped>
.file-detail {
display: flex;
flex-direction: column;
gap: 14px;
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
padding: 14px 16px;
border-radius: 8px;
background: #f5f7fa;
.name-line {
display: flex;
align-items: center;
gap: 8px;
.name {
font-size: 18px;
font-weight: 600;
color: #303133;
}
.file-no {
font-size: 12px;
color: #909399;
}
}
.meta-line {
margin-top: 6px;
font-size: 13px;
color: #606266;
}
}
.header-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.section-title {
margin: 14px 0 10px;
font-size: 14px;
font-weight: 600;
color: #303133;
&:first-child {
margin-top: 0;
}
}
.tab-toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: 10px;
}
.cert-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 14px;
}
.cert-item {
display: flex;
flex-direction: column;
align-items: center;
.cert-body {
width: 100%;
height: 150px;
border-radius: 8px;
overflow: hidden;
border: 1px solid #ebeef5;
background: #f5f7fa;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.cert-image {
width: 100%;
height: 100%;
display: block;
}
.cert-pdf {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
color: #909399;
font-size: 12px;
}
.cert-empty {
color: #c0c4cc;
font-size: 12px;
cursor: default;
}
.cert-loading {
color: #c0c4cc;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.cert-label {
margin-top: 6px;
font-size: 12px;
color: #606266;
}
}
.attachment-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 14px;
}
.attachment-card {
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 10px;
display: flex;
flex-direction: column;
align-items: center;
transition: box-shadow 0.2s;
&:hover {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
}
.attachment-body {
width: 100%;
height: 130px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
overflow: hidden;
background: #f5f7fa;
cursor: pointer;
.attachment-thumb {
width: 100%;
height: 100%;
object-fit: cover;
}
.attachment-file {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
color: #909399;
font-size: 12px;
}
}
.attachment-name {
margin-top: 8px;
width: 100%;
font-size: 13px;
color: #303133;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.attachment-sub {
width: 100%;
font-size: 12px;
color: #909399;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-height: 16px;
}
.attachment-actions {
margin-top: 4px;
display: flex;
gap: 2px;
}
}
</style>
@@ -0,0 +1,428 @@
<template>
<el-dialog
v-model="visible"
:title="isEdit ? '编辑档案' : '新建档案'"
width="680px"
:close-on-click-modal="false"
@closed="resetForm"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="关联员工" prop="employee_id">
<!-- 编辑时员工归属不可变更,只读展示 -->
<el-input v-if="isEdit" :value="editEmployeeLabel" disabled />
<el-select
v-else
v-model="form.employee_id"
filterable
placeholder="选择未建档的员工"
:loading="employeesLoading"
style="width: 100%"
>
<el-option
v-for="emp in candidateEmployees"
:key="emp.id"
:label="`${emp.name}(${emp.account})`"
:value="Number(emp.id)"
/>
</el-select>
</el-form-item>
<el-form-item label="档案编号" prop="file_no">
<el-input v-model="form.file_no" placeholder="留空自动生成" maxlength="50" />
</el-form-item>
<el-divider content-position="left">个人信息</el-divider>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="身份证号" prop="id_card">
<el-input v-model="form.id_card" maxlength="30" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="政治面貌" prop="political_status">
<el-select
v-model="form.political_status"
filterable
allow-create
default-first-option
clearable
placeholder="选择或输入"
style="width: 100%"
>
<el-option v-for="opt in politicalOptions" :key="opt" :label="opt" :value="opt" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="婚姻状况" prop="marital_status">
<el-select v-model="form.marital_status" style="width: 100%">
<el-option label="未知" :value="0" />
<el-option label="未婚" :value="1" />
<el-option label="已婚" :value="2" />
<el-option label="离异" :value="3" />
<el-option label="丧偶" :value="4" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="籍贯" prop="native_place">
<el-input v-model="form.native_place" maxlength="100" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="户籍地址" prop="household_address">
<el-input v-model="form.household_address" maxlength="255" />
</el-form-item>
<el-form-item label="现居住址" prop="current_address">
<el-input v-model="form.current_address" maxlength="255" />
</el-form-item>
<el-form-item label="工作邮箱" prop="work_email">
<el-input v-model="form.work_email" maxlength="100" />
</el-form-item>
<el-divider content-position="left">任职信息</el-divider>
<el-form-item label="在职状态" prop="employment_status">
<el-radio-group v-model="form.employment_status">
<el-radio-button :value="1">试用</el-radio-button>
<el-radio-button :value="2">正式</el-radio-button>
<el-radio-button :value="3">离职</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="入职日期" prop="hire_date">
<el-date-picker
v-model="form.hire_date"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择日期"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="转正日期" prop="regular_date">
<el-date-picker
v-model="form.regular_date"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择日期"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="离职日期" prop="leave_date">
<el-date-picker
v-model="form.leave_date"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择日期"
style="width: 100%"
/>
</el-form-item>
<el-divider content-position="left">证照资料</el-divider>
<el-form-item
v-for="cert in certFields"
:key="cert.field"
:label="cert.label"
>
<div class="upload-area">
<el-upload
:show-file-list="false"
:http-request="(opt) => handleCertUpload(cert.field, opt)"
:before-upload="beforeCertUpload"
accept="image/*,.pdf"
>
<img v-if="form[cert.field]" :src="form[cert.field]" class="cert-preview" alt="" />
<div v-else class="upload-trigger">
<el-icon><Plus /></el-icon>
<span>上传{{ cert.label }}</span>
</div>
</el-upload>
<el-button
v-if="form[cert.field]"
link
type="danger"
size="small"
@click="form[cert.field] = ''"
>
移除
</el-button>
</div>
</el-form-item>
<el-divider content-position="left">紧急联系人</el-divider>
<el-row :gutter="12">
<el-col :span="8">
<el-form-item label="联系人" prop="emergency_contact">
<el-input v-model="form.emergency_contact" maxlength="50" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="联系电话" prop="emergency_phone">
<el-input v-model="form.emergency_phone" maxlength="20" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="与本人关系" prop="emergency_relation">
<el-input v-model="form.emergency_relation" maxlength="30" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="2" maxlength="500" show-word-limit />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { computed, reactive, ref } from 'vue';
import { ElMessage } from 'element-plus';
import { createEmployeeFile, getFiledEmployeeIds, updateEmployeeFile } from '@/api/employeeFile';
import { oaOrganizationApi } from '@/api/organization';
import { uploadFile } from '@/api/file';
/**
* 建档 / 编辑档案弹窗。
* 新建时员工下拉只展示未建档员工(全部员工 - 已建档ID),
* 选项先于表单回填加载完成,避免下拉异步回显问题。
*/
const emit = defineEmits(['success']);
const visible = ref(false);
const saving = ref(false);
const employeesLoading = ref(false);
const formRef = ref();
const isEdit = ref(false);
const editId = ref(0);
const editEmployeeLabel = ref('');
const allEmployees = ref([]);
const filedIds = ref([]);
const politicalOptions = ['群众', '中共党员', '中共预备党员', '共青团员', '民主党派', '无党派人士'];
/** 证照资料上传项:field 对应 form 字段与后端列名 */
const certFields = [
{ field: 'education_photo', label: '学历照片' },
{ field: 'id_card_front', label: '身份证正面' },
{ field: 'id_card_back', label: '身份证反面' },
];
const form = reactive({
employee_id: null,
file_no: '',
id_card: '',
id_card_front: '',
id_card_back: '',
education_photo: '',
political_status: '',
marital_status: 0,
native_place: '',
household_address: '',
current_address: '',
work_email: '',
hire_date: '',
regular_date: '',
leave_date: '',
employment_status: 2,
emergency_contact: '',
emergency_phone: '',
emergency_relation: '',
remark: '',
});
const rules = {
employee_id: [{ required: true, message: '请选择员工', trigger: 'change' }],
work_email: [
{
type: 'email',
message: '邮箱格式不正确',
trigger: ['blur', 'change'],
},
],
};
/** 未建档员工 = 全部员工 - 已建档员工 */
const candidateEmployees = computed(() => {
const filed = new Set(filedIds.value.map(Number));
return allEmployees.value.filter((emp) => !filed.has(Number(emp.id)));
});
const resetForm = () => {
form.employee_id = null;
form.file_no = '';
form.id_card = '';
form.id_card_front = '';
form.id_card_back = '';
form.education_photo = '';
form.political_status = '';
form.marital_status = 0;
form.native_place = '';
form.household_address = '';
form.current_address = '';
form.work_email = '';
form.hire_date = '';
form.regular_date = '';
form.leave_date = '';
form.employment_status = 2;
form.emergency_contact = '';
form.emergency_phone = '';
form.emergency_relation = '';
form.remark = '';
formRef.value?.clearValidate();
};
/** 加载员工下拉选项:全部员工 + 已建档ID并行请求 */
const loadEmployeeOptions = async () => {
employeesLoading.value = true;
try {
const [empRes, filedRes] = await Promise.all([
oaOrganizationApi.getEmployeeList(),
getFiledEmployeeIds(),
]);
allEmployees.value = empRes?.data || empRes || [];
filedIds.value = filedRes?.data || filedRes || [];
} catch (error) {
allEmployees.value = [];
filedIds.value = [];
ElMessage.error(error?.message || '加载员工选项失败');
} finally {
employeesLoading.value = false;
}
};
/** 打开弹窗;file 为空表示新建,否则回填编辑 */
const open = async (file = null) => {
resetForm();
isEdit.value = !!file?.id;
editId.value = file?.id || 0;
if (isEdit.value) {
// 编辑前先并行刷新选项数据,再回填表单
await loadEmployeeOptions();
editEmployeeLabel.value = file.employee_name
? `${file.employee_name}(${file.employee_account || '-'})`
: `员工 #${file.employee_id}`;
form.file_no = file.file_no || '';
form.id_card = file.id_card || '';
form.id_card_front = file.id_card_front || '';
form.id_card_back = file.id_card_back || '';
form.education_photo = file.education_photo || '';
form.political_status = file.political_status || '';
form.marital_status = Number(file.marital_status ?? 0);
form.native_place = file.native_place || '';
form.household_address = file.household_address || '';
form.current_address = file.current_address || '';
form.work_email = file.work_email || '';
form.hire_date = file.hire_date || '';
form.regular_date = file.regular_date || '';
form.leave_date = file.leave_date || '';
form.employment_status = Number(file.employment_status ?? 2);
form.emergency_contact = file.emergency_contact || '';
form.emergency_phone = file.emergency_phone || '';
form.emergency_relation = file.emergency_relation || '';
form.remark = file.remark || '';
} else {
await loadEmployeeOptions();
}
visible.value = true;
};
const beforeCertUpload = (file) => {
const isImageOrPdf = file.type.startsWith('image/') || file.type === 'application/pdf';
const isLt10M = file.size / 1024 / 1024 < 10;
if (!isImageOrPdf) ElMessage.error('仅支持图片或 PDF 格式');
if (!isLt10M) ElMessage.error('文件大小不能超过 10MB');
return isImageOrPdf && isLt10M;
};
/** 通用证照上传:成功后写回对应表单字段 */
const handleCertUpload = async (field, { file }) => {
try {
const data = new FormData();
data.append('file', file);
const res = await uploadFile(data);
const url = res?.url || res?.data?.url;
if (!url) {
ElMessage.error(res?.msg || '上传失败');
return;
}
form[field] = url;
} catch (error) {
ElMessage.error(error?.message || '上传失败');
}
};
const handleSave = async () => {
const valid = await formRef.value?.validate().then(() => true).catch(() => false);
if (!valid) return;
const payload = { ...form };
delete payload.employee_id;
saving.value = true;
try {
if (isEdit.value) {
await updateEmployeeFile(editId.value, payload);
} else {
await createEmployeeFile({ ...payload, employee_id: form.employee_id });
}
ElMessage.success(isEdit.value ? '保存成功' : '建档成功');
visible.value = false;
emit('success');
} catch (error) {
ElMessage.error(error?.message || '保存失败');
} finally {
saving.value = false;
}
};
defineExpose({ open });
</script>
<style lang="less" scoped>
.upload-area {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
}
.cert-preview {
width: 140px;
height: 140px;
object-fit: cover;
border-radius: 6px;
border: 1px solid #dcdfe6;
display: block;
}
.upload-trigger {
width: 140px;
height: 140px;
border: 1px dashed #c0ccda;
border-radius: 6px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
color: #8c939d;
cursor: pointer;
font-size: 12px;
transition: border-color 0.2s;
&:hover {
border-color: #3973ff;
color: #3973ff;
}
}
</style>
@@ -0,0 +1,290 @@
<template>
<el-dialog
v-model="visible"
:title="`${meta.title} - ${isEdit ? '编辑' : '新增'}`"
width="560px"
:close-on-click-modal="false"
@closed="resetForm"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item :label="meta.nameLabel" prop="title">
<el-input v-model="form.title" :placeholder="`请输入${meta.nameLabel}`" maxlength="100" />
</el-form-item>
<el-form-item :label="meta.subLabel" prop="sub_title">
<el-input v-model="form.sub_title" :placeholder="`请输入${meta.subLabel}`" maxlength="100" />
</el-form-item>
<el-form-item v-if="meta.extraLabel" :label="meta.extraLabel" prop="extra">
<el-select
v-model="form.extra"
filterable
allow-create
default-first-option
clearable
:placeholder="`选择或输入${meta.extraLabel}`"
style="width: 100%"
>
<el-option v-for="opt in extraOptions" :key="opt" :label="opt" :value="opt" />
</el-select>
</el-form-item>
<el-form-item label="起止时间" prop="dateRange">
<el-date-picker
v-model="form.dateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input
v-model="form.description"
type="textarea"
:rows="3"
:placeholder="meta.type === 2 ? '工作内容简述' : '备注说明'"
maxlength="500"
show-word-limit
/>
</el-form-item>
<el-form-item v-if="meta.type === 4" label="附件" prop="attachment_url">
<div class="upload-area">
<el-upload
:show-file-list="false"
:http-request="handleUpload"
:before-upload="beforeUpload"
accept="image/*,.pdf"
>
<img v-if="form.attachment_url" :src="form.attachment_url" class="attachment-preview" alt="证照" />
<div v-else class="upload-trigger">
<el-icon><Plus /></el-icon>
<span>上传证照</span>
</div>
</el-upload>
<el-button
v-if="form.attachment_url"
link
type="danger"
size="small"
@click="form.attachment_url = ''"
>
移除附件
</el-button>
<div class="upload-tip">支持图片 / PDF,大小不超过 10MB</div>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { computed, reactive, ref } from 'vue';
import { ElMessage } from 'element-plus';
import { Plus } from '@element-plus/icons-vue';
import { createFileRecord, updateFileRecord } from '@/api/employeeFile';
import { uploadFile } from '@/api/file';
/**
* 档案子记录编辑弹窗:教育经历 / 工作经历 / 合同信息 / 证照附件共用。
* 字段语义随 type 变化(与后端建表注释保持一致):
* 教育经历(1):title=学校 sub_title=专业 extra=学历
* 工作经历(2):title=公司 sub_title=职位 extra=所在部门
* 合同信息(3):title=合同名称 sub_title=合同类型 extra=签订主体
* 证照附件(4):title=证照名称 sub_title=证件号码 extra 不展示
*/
const TYPE_META = {
1: { type: 1, title: '教育经历', nameLabel: '学校名称', subLabel: '专业', extraLabel: '学历' },
2: { type: 2, title: '工作经历', nameLabel: '公司名称', subLabel: '职位', extraLabel: '所在部门' },
3: { type: 3, title: '合同信息', nameLabel: '合同名称', subLabel: '合同类型', extraLabel: '签订主体' },
4: { type: 4, title: '证照附件', nameLabel: '证照名称', subLabel: '证件号码', extraLabel: '' },
};
const EXTRA_OPTIONS = {
1: ['小学', '初中', '高中', '中专', '大专', '本科', '硕士', '博士'],
2: [],
3: ['固定期限', '无固定期限', '劳务合同', '实习协议'],
4: [],
};
const emit = defineEmits(['success']);
const visible = ref(false);
const saving = ref(false);
const uploading = ref(false);
const formRef = ref();
const employeeId = ref(0);
const meta = ref(TYPE_META[1]);
const isEdit = ref(false);
const editId = ref(0);
const form = reactive({
title: '',
sub_title: '',
extra: '',
dateRange: null,
description: '',
attachment_url: '',
});
const rules = {
title: [{ required: true, message: '请输入名称', trigger: 'blur' }],
};
const extraOptions = computed(() => EXTRA_OPTIONS[meta.value.type] || []);
const resetForm = () => {
form.title = '';
form.sub_title = '';
form.extra = '';
form.dateRange = null;
form.description = '';
form.attachment_url = '';
formRef.value?.clearValidate();
};
/**
* 打开弹窗。
* @param {object} options
* @param {number} options.employeeId 员工ID(记录挂载主体)
* @param {number} options.type 记录类型 1-4
* @param {object} [options.record] 编辑时传入的记录;缺省为新增
*/
const open = (options = {}) => {
employeeId.value = Number(options.employeeId) || 0;
meta.value = TYPE_META[options.type] || TYPE_META[1];
resetForm();
const record = options.record;
isEdit.value = !!record?.id;
editId.value = record?.id || 0;
if (record) {
form.title = record.title || '';
form.sub_title = record.sub_title || '';
form.extra = record.extra || '';
form.dateRange = record.start_date || record.end_date
? [record.start_date || '', record.end_date || '']
: null;
form.description = record.description || '';
form.attachment_url = record.attachment_url || '';
}
visible.value = true;
};
const beforeUpload = (file) => {
const isImageOrPdf = file.type.startsWith('image/') || file.type === 'application/pdf';
const isLt10M = file.size / 1024 / 1024 < 10;
if (!isImageOrPdf) ElMessage.error('仅支持图片或 PDF 格式');
if (!isLt10M) ElMessage.error('文件大小不能超过 10MB');
return isImageOrPdf && isLt10M;
};
const handleUpload = async ({ file }) => {
uploading.value = true;
try {
const data = new FormData();
data.append('file', file);
const res = await uploadFile(data);
const url = res?.url || res?.data?.url;
if (!url) {
ElMessage.error(res?.msg || '上传失败');
return;
}
form.attachment_url = url;
} catch (error) {
ElMessage.error(error?.message || '上传失败');
} finally {
uploading.value = false;
}
};
const handleSave = async () => {
const valid = await formRef.value?.validate().then(() => true).catch(() => false);
if (!valid) return;
if (!employeeId.value) {
ElMessage.error('缺少员工信息');
return;
}
const range = Array.isArray(form.dateRange) ? form.dateRange : [null, null];
const payload = {
employee_id: employeeId.value,
type: meta.value.type,
title: form.title,
sub_title: form.sub_title,
extra: form.extra,
start_date: range[0] || '',
end_date: range[1] || '',
description: form.description,
attachment_url: form.attachment_url,
sort: 0,
};
saving.value = true;
try {
if (isEdit.value) {
await updateFileRecord(editId.value, payload);
} else {
await createFileRecord(payload);
}
ElMessage.success('保存成功');
visible.value = false;
emit('success');
} catch (error) {
ElMessage.error(error?.message || '保存失败');
} finally {
saving.value = false;
}
};
defineExpose({ open, uploading });
</script>
<style lang="less" scoped>
.upload-area {
display: flex;
flex-direction: column;
gap: 6px;
}
.attachment-preview {
width: 120px;
height: 120px;
object-fit: cover;
border-radius: 6px;
border: 1px solid #dcdfe6;
display: block;
}
.upload-trigger {
width: 120px;
height: 120px;
border: 1px dashed #c0ccda;
border-radius: 6px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
color: #8c939d;
cursor: pointer;
transition: border-color 0.2s;
&:hover {
border-color: #3973ff;
color: #3973ff;
}
}
.upload-tip {
font-size: 12px;
color: #999;
}
</style>
@@ -0,0 +1,348 @@
<template>
<div class="employee-file-page">
<div class="page-header">
<div>
<h2>员工档案</h2>
<p>一人一档,集中管理人事资料、教育经历、工作经历、合同与证照</p>
</div>
<div class="header-actions">
<el-button :icon="Refresh" @click="loadAll">刷新</el-button>
<el-button type="primary" :icon="Plus" @click="editDialogRef?.open()">新建档案</el-button>
</div>
</div>
<!-- 统计卡片 -->
<div class="stats-bar">
<div v-for="item in statCards" :key="item.label" class="stat-card" :class="item.tone">
<div class="stat-value">{{ stats[item.key] ?? 0 }}</div>
<div class="stat-label">{{ item.label }}</div>
</div>
</div>
<el-card shadow="never">
<div class="filter-bar">
<el-input
v-model="filters.keyword"
placeholder="姓名 / 账号 / 手机 / 档案编号 / 身份证"
clearable
class="filter-item"
:prefix-icon="Search"
@change="handleSearch"
/>
<el-tree-select
v-model="filters.org_id"
:data="departmentTree"
:props="treeSelectProps"
check-strictly
clearable
placeholder="按部门筛选(含下级)"
class="filter-item"
@change="handleSearch"
/>
<el-select
v-model="filters.status"
clearable
placeholder="在职状态"
class="filter-item status-filter"
@change="handleSearch"
>
<el-option label="试用" :value="1" />
<el-option label="正式" :value="2" />
<el-option label="离职" :value="3" />
</el-select>
<el-button :icon="RefreshLeft" @click="resetFilters">重置</el-button>
</div>
<el-table :data="rows" v-loading="loading" stripe>
<el-table-column prop="file_no" label="档案编号" width="150">
<template #default="{ row }">{{ row.file_no || '-' }}</template>
</el-table-column>
<el-table-column prop="employee_name" label="姓名" width="110">
<template #default="{ row }">
<span class="name-link" @click="detailDrawerRef?.open(row)">{{ row.employee_name || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="性别" width="70" align="center">
<template #default="{ row }">{{ genderText(row) }}</template>
</el-table-column>
<el-table-column label="部门" min-width="130">
<template #default="{ row }">{{ row.department_name || '-' }}</template>
</el-table-column>
<el-table-column label="职位" min-width="110">
<template #default="{ row }">{{ row.position || '-' }}</template>
</el-table-column>
<el-table-column label="手机" width="130">
<template #default="{ row }">{{ row.phone || '-' }}</template>
</el-table-column>
<el-table-column label="入职日期" width="110">
<template #default="{ row }">{{ row.hire_date || '-' }}</template>
</el-table-column>
<el-table-column label="在职状态" width="95" align="center">
<template #default="{ row }">
<el-tag :type="statusMeta(row.employment_status).type" size="small">
{{ statusMeta(row.employment_status).text }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="190" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="detailDrawerRef?.open(row)">查看档案</el-button>
<el-button link type="primary" size="small" @click="editDialogRef?.open(row)">编辑</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无员工档案" :image-size="90" />
</template>
</el-table>
<div class="pagination-bar">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="loadData"
@size-change="handleSearch"
/>
</div>
</el-card>
<FileEditDialog ref="editDialogRef" @success="loadAll" />
<FileDetailDrawer ref="detailDrawerRef" @edit="(file) => editDialogRef?.open(file)" />
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { Plus, Refresh, RefreshLeft, Search } from '@element-plus/icons-vue';
import {
deleteEmployeeFile,
getEmployeeFileList,
getEmployeeFileStats,
} from '@/api/employeeFile';
import { oaOrganizationApi } from '@/api/organization';
import { buildOrgTree, genderText } from '@/views/apps/organization/composables';
import FileEditDialog from './components/fileEditDialog.vue';
import FileDetailDrawer from './components/fileDetailDrawer.vue';
/**
* 员工档案页面(OA 人事档案)。
* 列表后端分页;档案编辑/详情通过弹窗与抽屉完成,
* 档案详情内直接管理教育经历、工作经历、合同与证照子记录。
*/
const editDialogRef = ref();
const detailDrawerRef = ref();
const rows = ref([]);
const total = ref(0);
const loading = ref(false);
const page = ref(1);
const pageSize = ref(10);
const departmentTree = ref([]);
const stats = ref({});
const statCards = [
{ key: 'total', label: '档案总数', tone: '' },
{ key: 'active', label: '正式', tone: 'success' },
{ key: 'probation', label: '试用', tone: 'warning' },
{ key: 'resigned', label: '离职', tone: 'info' },
];
const STATUS_META = {
1: { text: '试用', type: 'warning' },
2: { text: '正式', type: 'success' },
3: { text: '离职', type: 'info' },
};
const statusMeta = (value) => STATUS_META[Number(value ?? 2)] || STATUS_META[2];
const treeSelectProps = { value: 'id', label: 'org_name', children: 'children' };
const filters = reactive({ keyword: '', org_id: null, status: '' });
const loadData = async () => {
loading.value = true;
try {
const params = { page: page.value, pageSize: pageSize.value };
if (filters.keyword) params.keyword = filters.keyword;
if (filters.org_id) params.org_id = filters.org_id;
if (filters.status !== '' && filters.status !== null) params.status = filters.status;
const res = await getEmployeeFileList(params);
const data = res?.data || res || {};
rows.value = data.list || [];
total.value = Number(data.total || 0);
} catch (error) {
ElMessage.error(error?.message || '获取档案列表失败');
} finally {
loading.value = false;
}
};
const loadStats = async () => {
try {
const res = await getEmployeeFileStats();
stats.value = res?.data || res || {};
} catch {
stats.value = {};
}
};
const loadDepartments = async () => {
try {
const res = await oaOrganizationApi.getOrganizationList();
departmentTree.value = buildOrgTree(res?.data || res || []);
} catch {
departmentTree.value = [];
}
};
/** 列表 + 统计一起刷新(增删改后调用) */
const loadAll = () => {
loadData();
loadStats();
};
const handleSearch = () => {
page.value = 1;
loadData();
};
const resetFilters = () => {
filters.keyword = '';
filters.org_id = null;
filters.status = '';
handleSearch();
};
const handleDelete = async (row) => {
const name = row.employee_name || row.file_no || `#${row.id}`;
try {
await ElMessageBox.confirm(
`确定删除「${name}」的档案吗?档案下的教育经历、合同等记录仍会保留。`,
'删除确认',
{ type: 'warning' }
);
} catch {
return;
}
try {
await deleteEmployeeFile(row.id);
ElMessage.success('删除成功');
loadAll();
} catch (error) {
ElMessage.error(error?.message || '删除失败');
}
};
onMounted(() => {
loadDepartments();
loadAll();
});
</script>
<style lang="less" scoped>
.employee-file-page {
display: flex;
flex-direction: column;
gap: 12px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: 12px;
h2 {
margin: 0 0 6px;
font-size: 20px;
color: #303133;
}
p {
margin: 0;
font-size: 13px;
color: #909399;
}
}
.header-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.stats-bar {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.stat-card {
padding: 14px 18px;
border-radius: 8px;
background: #fff;
border: 1px solid #ebeef5;
.stat-value {
font-size: 24px;
font-weight: 600;
color: #303133;
line-height: 1.2;
}
.stat-label {
margin-top: 4px;
font-size: 13px;
color: #909399;
}
&.success .stat-value {
color: #67c23a;
}
&.warning .stat-value {
color: #e6a23c;
}
&.info .stat-value {
color: #909399;
}
}
.filter-bar {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.filter-item {
width: 240px;
}
.status-filter {
width: 140px;
}
.name-link {
color: #3973ff;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
.pagination-bar {
display: flex;
justify-content: flex-end;
margin-top: 14px;
}
</style>