feat: 增强员工管理与报销单全流程功能

- 新增员工Excel导入服务(employee_spreadsheet)及导入/导出API端点
- 员工服务增加批量创建、邮箱唯一校验、组织架构关联等能力
- 报销单提交补充身份回填、部门信息透传及预审结果展示优化
- 认证流程增加部门信息(departmentName)并在schema中同步扩展
- 用户Agent服务增加部门关联与报销单回填逻辑
- 前端员工管理页面全面重构,新增导入导出、搜索过滤、分页等功能
- 前端审批中心、审计、差旅报销等视图交互与样式优化
- 新增TableLoadingState共享组件及员工导入测试用例
This commit is contained in:
caoxiaozhu
2026-05-20 14:21:56 +08:00
parent 57957d11a0
commit d7e98a58b9
46 changed files with 4022 additions and 305 deletions

View File

@@ -61,11 +61,23 @@
</label>
<label class="field">
<span>年龄</span>
<input :value="detailAge" readonly />
<input
v-model="employeeForm.age"
type="number"
min="0"
max="120"
inputmode="numeric"
placeholder="请输入年龄"
@input="syncBirthDateFromAge"
/>
</label>
<label class="field">
<span>出生日期</span>
<input v-model="employeeForm.birthDate" type="date" />
<input
v-model="employeeForm.birthDate"
type="date"
@change="syncAgeFromBirthDate"
/>
</label>
<label class="field">
<span>手机号</span>
@@ -104,9 +116,50 @@
</div>
<div class="form-grid">
<label class="field">
<label
class="field manager-picker department-picker"
:class="{ open: departmentPickerOpen }"
>
<span>所属部门</span>
<input v-model="employeeForm.department" readonly />
<button
class="manager-picker-trigger"
type="button"
@click.stop="toggleDepartmentPicker"
>
<span class="manager-picker-label">{{ departmentDisplayLabel }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<div
v-if="departmentPickerOpen"
class="manager-picker-panel"
@click.stop
>
<input
v-model="departmentSearchKeyword"
type="search"
placeholder="输入部门名称或编码搜索"
@keydown.enter.prevent="resolveDepartmentSelectionFromKeyword"
/>
<div class="manager-picker-options">
<button
v-for="option in filteredDepartmentOptions"
:key="option.id"
type="button"
class="manager-picker-option"
:class="{ active: employeeForm.organizationUnitCode === option.code }"
@click="selectDepartment(option)"
>
<strong>{{ option.name }}{{ option.code }}</strong>
<span v-if="option.unitType">{{ option.unitType }}</span>
</button>
<p
v-if="!filteredDepartmentOptions.length"
class="manager-picker-empty"
>
没有匹配的部门请调整搜索关键词
</p>
</div>
</div>
</label>
<label class="field">
<span>岗位</span>
@@ -116,9 +169,56 @@
<span>职级</span>
<input v-model="employeeForm.grade" />
</label>
<label class="field">
<label class="field manager-picker" :class="{ open: managerPickerOpen }">
<span>直属上级</span>
<input v-model="employeeForm.manager" readonly />
<button
class="manager-picker-trigger"
type="button"
@click.stop="toggleManagerPicker"
>
<span class="manager-picker-label">{{ managerDisplayLabel }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<div
v-if="managerPickerOpen"
class="manager-picker-panel"
@click.stop
>
<input
v-model="managerSearchKeyword"
type="search"
placeholder="输入姓名、工号或部门搜索"
@keydown.enter.prevent="resolveManagerSelectionFromKeyword"
/>
<div class="manager-picker-options">
<button
type="button"
class="manager-picker-option"
:class="{ active: !hasManagerAssignment }"
@click="selectManager(null)"
>
<strong>无直属上级</strong>
<span>清空当前设置</span>
</button>
<button
v-for="option in filteredManagerOptions"
:key="option.id"
type="button"
class="manager-picker-option"
:class="{ active: employeeForm.managerEmployeeNo === option.employeeNo }"
@click="selectManager(option)"
>
<strong>{{ option.name }}{{ option.employeeNo }}</strong>
<span>{{ option.department }} / {{ option.position }}</span>
</button>
<p
v-if="!filteredManagerOptions.length"
class="manager-picker-empty"
>
没有匹配的员工请调整搜索关键词
</p>
</div>
</div>
</label>
<label class="field">
<span>财务归口</span>
@@ -177,15 +277,26 @@
<div class="card-head">
<div>
<h3>最近变更</h3>
<p>查看角色与档案调整记录</p>
<p>仅保留最近 5 角色与档案调整记录</p>
</div>
</div>
<div class="history-list">
<div v-for="item in selectedEmployee.history" :key="item.time" class="history-row">
<div
v-for="item in recentEmployeeHistory"
:key="`${item.occurredAt || item.time}-${item.action}`"
class="history-row"
>
<strong>{{ item.action }}</strong>
<span>{{ item.owner }}</span>
<small>{{ item.time }}</small>
<div class="history-row-meta">
<span class="history-row-owner">{{ item.owner }}</span>
<small class="history-row-time">{{
formatEmployeeHistoryTime(item.time || item.occurredAt)
}}</small>
</div>
</div>
<p v-if="!recentEmployeeHistory.length" class="manager-picker-empty">
暂无变更记录
</p>
</div>
</article>
@@ -392,16 +503,34 @@
<span>清空筛选</span>
</button>
<button class="create-btn" type="button">
<i class="mdi mdi-plus"></i>
<span>新增员工</span>
<button class="template-btn" type="button" :disabled="importExportBusy" @click="handleDownloadTemplate">
<i class="mdi mdi-file-download-outline"></i>
<span>下载模板</span>
</button>
<button class="export-btn" type="button" :disabled="importExportBusy" @click="handleExportEmployees">
<i class="mdi mdi-export"></i>
<span>{{ actionState === 'export' ? '导出中...' : '导出员工' }}</span>
</button>
<button class="create-btn" type="button" :disabled="importExportBusy" @click="openImportFilePicker">
<i class="mdi mdi-file-upload-outline"></i>
<span>{{ actionState === 'import' ? '导入中...' : '导入员工' }}</span>
</button>
<input
ref="importFileInput"
class="import-file-input"
type="file"
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
@change="handleImportFileChange"
/>
</div>
</div>
<p class="hint">
<i class="mdi mdi-information-outline"></i>
点击任意员工行可进入基础信息与角色权限编辑界面
点击任意员工行可进入基础信息与角色权限编辑界面导入将按员工编号覆盖已有档案任一行校验失败则整批不写入
</p>
<div v-if="activeFilterTokens.length" class="active-filter-strip">
@@ -412,8 +541,11 @@
<div class="table-wrap">
<div v-if="loading" class="table-state">
<i class="mdi mdi-loading mdi-spin"></i>
<p>正在加载员工数据...</p>
<TableLoadingState
title="员工数据同步中"
message="正在加载员工档案与角色权限"
icon="mdi mdi-account-group-outline"
/>
</div>
<div v-else-if="errorMessage" class="table-state error">
@@ -496,7 +628,7 @@
<td>
<span class="status-pill" :class="employee.statusTone">{{ employee.status }}</span>
</td>
<td>{{ employee.updatedAt }}</td>
<td class="cell-updated">{{ employee.updatedAt }}</td>
</tr>
</tbody>
</table>
@@ -572,9 +704,62 @@
@close="closeDisableDialog"
@confirm="confirmDisableEmployeeAccount"
/>
<ConfirmDialog
:open="importConfirmDialogOpen"
badge="导入确认"
badge-tone="warning"
title="确认导入员工 Excel"
description="系统将先校验全部数据,全部通过后才写入数据库。若存在错误,将不会修改任何现有员工信息。"
cancel-text="取消"
confirm-text="开始导入"
busy-text="导入中..."
confirm-tone="primary"
confirm-icon="mdi mdi-file-upload-outline"
:busy="actionState === 'import'"
@close="closeImportConfirmDialog"
@confirm="confirmImportEmployees"
/>
<ConfirmDialog
:open="importErrorDialogOpen"
badge="导入失败"
badge-tone="danger"
title="导入未执行"
:description="importResultMessage"
cancel-text="关闭"
confirm-text="下载模板"
confirm-tone="primary"
confirm-icon="mdi mdi-file-download-outline"
@close="closeImportErrorDialog"
@confirm="handleDownloadTemplate"
>
<div class="import-error-table-wrap">
<table class="import-error-table">
<thead>
<tr>
<th>行号</th>
<th>字段</th>
<th>工号</th>
<th>原因</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in importErrors" :key="`${item.row}-${item.column}-${index}`">
<td>{{ item.row || '-' }}</td>
<td>{{ item.column }}</td>
<td>{{ item.employeeNo || '-' }}</td>
<td>{{ item.message }}</td>
</tr>
</tbody>
</table>
</div>
</ConfirmDialog>
</section>
</template>
<script src="./scripts/EmployeeManagementView.js"></script>
<style scoped src="../assets/styles/views/employee-management-view.css"></style>
<style scoped>
@import "../assets/styles/views/employee-management-view.css";
</style>