feat: 增强员工管理与报销单全流程功能
- 新增员工Excel导入服务(employee_spreadsheet)及导入/导出API端点 - 员工服务增加批量创建、邮箱唯一校验、组织架构关联等能力 - 报销单提交补充身份回填、部门信息透传及预审结果展示优化 - 认证流程增加部门信息(departmentName)并在schema中同步扩展 - 用户Agent服务增加部门关联与报销单回填逻辑 - 前端员工管理页面全面重构,新增导入导出、搜索过滤、分页等功能 - 前端审批中心、审计、差旅报销等视图交互与样式优化 - 新增TableLoadingState共享组件及员工导入测试用例
This commit is contained in:
@@ -391,9 +391,34 @@
|
||||
<span>退回列表</span>
|
||||
</button>
|
||||
<div class="approval-action-group" aria-label="审批操作">
|
||||
<button class="approve-action" type="button"><i class="mdi mdi-check-circle-outline"></i> 通过</button>
|
||||
<button class="reject-action" type="button"><i class="mdi mdi-close-circle-outline"></i> 驳回</button>
|
||||
<button class="supplement-action" type="button"><i class="mdi mdi-undo"></i> 补充</button>
|
||||
<button class="approve-action" type="button" :disabled="actionBusy">
|
||||
<i class="mdi mdi-check-circle-outline"></i> 通过
|
||||
</button>
|
||||
<button
|
||||
class="reject-action"
|
||||
type="button"
|
||||
:disabled="!canManageClaims || actionBusy"
|
||||
@click="handleReturnSelected"
|
||||
>
|
||||
<i class="mdi mdi-close-circle-outline"></i> 驳回
|
||||
</button>
|
||||
<button
|
||||
class="supplement-action"
|
||||
type="button"
|
||||
:disabled="!canManageClaims || actionBusy"
|
||||
@click="handleReturnSelected"
|
||||
>
|
||||
<i class="mdi mdi-undo"></i> 补充
|
||||
</button>
|
||||
<button
|
||||
v-if="canManageClaims"
|
||||
class="reject-action"
|
||||
type="button"
|
||||
:disabled="actionBusy"
|
||||
@click="handleDeleteSelected"
|
||||
>
|
||||
<i class="mdi mdi-trash-can-outline"></i> 删除
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -430,9 +455,11 @@
|
||||
|
||||
<div class="table-wrap" :class="{ 'is-empty': showEmpty }">
|
||||
<div v-if="loading" class="table-state">
|
||||
<i class="mdi mdi-loading mdi-spin"></i>
|
||||
<strong>正在加载审批待办</strong>
|
||||
<p>直属领导和财务节点下可处理的报销单据会直接展示在这里。</p>
|
||||
<TableLoadingState
|
||||
title="审批待办同步中"
|
||||
message="正在加载当前可见的待审报销单据"
|
||||
icon="mdi mdi-clipboard-check-outline"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="table-state error">
|
||||
@@ -502,6 +529,38 @@
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="returnDialogOpen"
|
||||
badge="退回单据"
|
||||
badge-tone="warning"
|
||||
:title="`确认退回 ${selectedRow?.id || ''} 吗?`"
|
||||
description="退回后该单据会进入待补充状态,申请人需要补充后重新提交。"
|
||||
cancel-text="取消"
|
||||
confirm-text="确认退回"
|
||||
busy-text="退回中..."
|
||||
confirm-tone="primary"
|
||||
confirm-icon="mdi mdi-undo"
|
||||
:busy="actionBusy"
|
||||
@close="closeReturnDialog"
|
||||
@confirm="confirmReturnSelected"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="deleteDialogOpen"
|
||||
badge="删除单据"
|
||||
badge-tone="danger"
|
||||
:title="`确认删除 ${selectedRow?.id || ''} 吗?`"
|
||||
description="删除后该报销单及费用明细将不可恢复,请确认本次操作。"
|
||||
cancel-text="取消"
|
||||
confirm-text="确认删除"
|
||||
busy-text="删除中..."
|
||||
confirm-tone="danger"
|
||||
confirm-icon="mdi mdi-trash-can-outline"
|
||||
:busy="actionBusy"
|
||||
@close="closeDeleteDialog"
|
||||
@confirm="confirmDeleteSelected"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -64,6 +64,40 @@
|
||||
<div class="hero-stat">
|
||||
<span>{{ selectedSkillIsRule ? '当前展示版本' : '当前版本' }}</span>
|
||||
<strong>{{ selectedSkill.displayVersion || selectedSkill.version }}</strong>
|
||||
</div>
|
||||
<div class="hero-stat">
|
||||
<span>最近更新</span>
|
||||
<strong>{{ selectedSkill.updatedAt }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="detailError" class="detail-inline-state panel error">
|
||||
<i class="mdi mdi-alert-circle-outline"></i>
|
||||
<div>
|
||||
<strong>资产详情加载失败</strong>
|
||||
<p>{{ detailError }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<TableLoadingState
|
||||
v-else-if="detailLoading && selectedSkill.loading"
|
||||
class="detail-inline-state panel"
|
||||
variant="detail"
|
||||
title="正在加载资产详情"
|
||||
message="列表数据已就绪,正在补充版本、审核和运行信息"
|
||||
icon="mdi mdi-file-document-outline"
|
||||
:show-skeleton="false"
|
||||
/>
|
||||
|
||||
<section
|
||||
v-else-if="selectedSkill.usesSpreadsheetRule"
|
||||
class="spreadsheet-editor-shell panel"
|
||||
>
|
||||
<header class="spreadsheet-editor-head">
|
||||
<div class="spreadsheet-editor-title">
|
||||
<div class="skill-badge" :class="selectedSkill.badgeTone">{{ selectedSkill.typeLabel }}</div>
|
||||
<div>
|
||||
<h2>{{ selectedSkill.name }}</h2>
|
||||
<p>{{ selectedSkill.summary || '当前资产尚未补充说明。' }}</p>
|
||||
</div>
|
||||
@@ -71,7 +105,7 @@
|
||||
|
||||
<div class="spreadsheet-editor-actions">
|
||||
<span class="spreadsheet-mode-pill">
|
||||
{{ selectedSpreadsheetModeLabel }}
|
||||
{{ selectedSpreadsheetModeLabel }}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
@@ -99,10 +133,15 @@
|
||||
class="rule-spreadsheet-host"
|
||||
:class="{ hidden: !spreadsheetOnlyOfficeReady && !spreadsheetOnlyOfficeError }"
|
||||
></div>
|
||||
<div v-if="spreadsheetOnlyOfficeLoading" class="rule-spreadsheet-state">
|
||||
<i class="mdi mdi-loading mdi-spin"></i>
|
||||
<span>正在加载 Excel 规则表...</span>
|
||||
</div>
|
||||
<TableLoadingState
|
||||
v-if="spreadsheetOnlyOfficeLoading"
|
||||
class="rule-spreadsheet-state"
|
||||
variant="overlay"
|
||||
tone="sky"
|
||||
message="正在加载 Excel 规则表"
|
||||
icon="mdi mdi-table-large"
|
||||
:show-skeleton="false"
|
||||
/>
|
||||
<div v-else-if="spreadsheetOnlyOfficeError" class="rule-spreadsheet-state error">
|
||||
<i class="mdi mdi-alert-circle-outline"></i>
|
||||
<span>{{ spreadsheetOnlyOfficeError }}</span>
|
||||
@@ -121,34 +160,34 @@
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<aside class="spreadsheet-change-center">
|
||||
<header class="change-center-head">
|
||||
<div>
|
||||
<h3>最近修改</h3>
|
||||
<p>展示最近 30 次保存后的具体改动。</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="change-center-section change-history-section">
|
||||
<div v-if="selectedSpreadsheetChangeRecords.length" class="change-center-list">
|
||||
<button
|
||||
v-for="item in selectedSpreadsheetChangeRecords"
|
||||
:key="`spreadsheet-change-${item.id || item.changed_at}-${item.actor}`"
|
||||
type="button"
|
||||
class="change-center-item change-record-item"
|
||||
@click="openSpreadsheetChangeDetail(item)"
|
||||
>
|
||||
<aside class="spreadsheet-change-center">
|
||||
<header class="change-center-head">
|
||||
<div>
|
||||
<h3>最近修改</h3>
|
||||
<p>展示最近 30 次保存后的具体改动。</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="change-center-section change-history-section">
|
||||
<div v-if="selectedSpreadsheetChangeRecords.length" class="change-center-list">
|
||||
<button
|
||||
v-for="item in selectedSpreadsheetChangeRecords"
|
||||
:key="`spreadsheet-change-${item.id || item.changed_at}-${item.actor}`"
|
||||
type="button"
|
||||
class="change-center-item change-record-item"
|
||||
@click="openSpreadsheetChangeDetail(item)"
|
||||
>
|
||||
<div class="change-record-head">
|
||||
<div>
|
||||
<strong>{{ item.actor }}</strong>
|
||||
<span>{{ item.time }}</span>
|
||||
</div>
|
||||
<b>{{ item.changeCountLabel }}</b>
|
||||
</div>
|
||||
<p>{{ item.summary }}</p>
|
||||
<small v-if="item.sheetPreview.length">
|
||||
涉及工作表:{{ item.sheetPreview.join('、') }}
|
||||
<template v-if="item.remainingSheetCount"> 等 {{ item.changedSheetNames.length }} 个</template>
|
||||
</div>
|
||||
<p>{{ item.summary }}</p>
|
||||
<small v-if="item.sheetPreview.length">
|
||||
涉及工作表:{{ item.sheetPreview.join('、') }}
|
||||
<template v-if="item.remainingSheetCount"> 等 {{ item.changedSheetNames.length }} 个</template>
|
||||
</small>
|
||||
<div v-if="item.previewChanges.length" class="change-record-preview">
|
||||
<span
|
||||
@@ -164,9 +203,9 @@
|
||||
</small>
|
||||
</button>
|
||||
</div>
|
||||
<p v-else class="change-flow-empty">暂无修改记录</p>
|
||||
</section>
|
||||
</aside>
|
||||
<p v-else class="change-flow-empty">暂无修改记录</p>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -319,10 +358,15 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="detailLoading" class="subtle-banner">
|
||||
<i class="mdi mdi-loading mdi-spin"></i>
|
||||
<span>正在刷新规则详情...</span>
|
||||
</div>
|
||||
<TableLoadingState
|
||||
v-if="detailLoading"
|
||||
class="subtle-banner"
|
||||
variant="banner"
|
||||
tone="sky"
|
||||
message="正在刷新规则详情"
|
||||
icon="mdi mdi-refresh"
|
||||
:show-skeleton="false"
|
||||
/>
|
||||
|
||||
<label class="field">
|
||||
<span>{{ selectedSkill.code }}</span>
|
||||
@@ -799,11 +843,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showStatusFilter"
|
||||
class="picker-filter"
|
||||
:class="{ open: activeFilterPopover === 'status' }"
|
||||
>
|
||||
<div
|
||||
v-if="showStatusFilter"
|
||||
class="picker-filter"
|
||||
:class="{ open: activeFilterPopover === 'status' }"
|
||||
>
|
||||
|
||||
<button
|
||||
class="picker-trigger"
|
||||
@@ -866,8 +910,12 @@
|
||||
|
||||
<div class="table-wrap" :class="{ 'is-empty': !loading && !errorMessage && !visibleSkills.length }">
|
||||
<div v-if="loading" class="table-state">
|
||||
<i class="mdi mdi-loading mdi-spin"></i>
|
||||
<p>正在加载{{ activeTabLabel }}资产...</p>
|
||||
<TableLoadingState
|
||||
variant="panel"
|
||||
:title="`${activeTabLabel}资产同步中`"
|
||||
:message="`正在加载${activeTabLabel}资产`"
|
||||
icon="mdi mdi-view-list-outline"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="errorMessage" class="table-state error">
|
||||
@@ -904,12 +952,11 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="skill in visibleSkills"
|
||||
:key="skill.id"
|
||||
:class="{ spotlight: skill.spotlight }"
|
||||
@click="openAssetDetail(skill)"
|
||||
>
|
||||
<tr
|
||||
v-for="skill in visibleSkills"
|
||||
:key="skill.id"
|
||||
@click="openAssetDetail(skill)"
|
||||
>
|
||||
<td>
|
||||
<div class="skill-name-cell">
|
||||
<span class="skill-avatar" :class="skill.badgeTone">{{ skill.short }}</span>
|
||||
@@ -1032,10 +1079,14 @@
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div v-if="versionTimelineLoading" class="rule-drawer-state">
|
||||
<i class="mdi mdi-loading mdi-spin"></i>
|
||||
<span>正在加载操作记录...</span>
|
||||
</div>
|
||||
<TableLoadingState
|
||||
v-if="versionTimelineLoading"
|
||||
class="rule-drawer-state"
|
||||
variant="drawer"
|
||||
message="正在加载操作记录"
|
||||
icon="mdi mdi-history"
|
||||
:show-skeleton="false"
|
||||
/>
|
||||
<div v-else-if="versionTimelineError" class="rule-drawer-state error">
|
||||
<i class="mdi mdi-alert-circle-outline"></i>
|
||||
<span>{{ versionTimelineError }}</span>
|
||||
@@ -1053,9 +1104,9 @@
|
||||
<span>{{ item.timeLabel }}</span>
|
||||
</header>
|
||||
<p>{{ item.description || item.note || '暂无补充说明' }}</p>
|
||||
<small>
|
||||
操作人:{{ item.actor }}
|
||||
</small>
|
||||
<small>
|
||||
操作人:{{ item.actor }}
|
||||
</small>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
@@ -1094,8 +1145,8 @@
|
||||
<span>修改时间</span>
|
||||
<strong>{{ selectedSpreadsheetChangeRecord.time }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>修改工作表</span>
|
||||
<article>
|
||||
<span>修改工作表</span>
|
||||
<strong>{{ selectedSpreadsheetChangeRecord.changed_sheet_count }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -128,8 +128,18 @@
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div v-if="!filteredHermesRuns.length" class="inline-empty">
|
||||
{{ hermesLoading ? '正在加载 Hermes 运行日志...' : '当前筛选条件下没有 Hermes 记录。' }}
|
||||
<div
|
||||
v-if="!filteredHermesRuns.length"
|
||||
class="inline-empty"
|
||||
:class="{ 'is-loading': hermesLoading }"
|
||||
>
|
||||
<TableLoadingState
|
||||
v-if="hermesLoading"
|
||||
title="Hermes 日志同步中"
|
||||
message="正在加载运行日志记录"
|
||||
icon="mdi mdi-text-box-search-outline"
|
||||
/>
|
||||
<span v-else>当前筛选条件下没有 Hermes 记录。</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -174,8 +184,18 @@
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div v-if="!filteredSystemLogEntries.length" class="inline-empty">
|
||||
{{ systemLogLoading ? '正在加载系统日志...' : '当前筛选条件下没有系统日志记录。' }}
|
||||
<div
|
||||
v-if="!filteredSystemLogEntries.length"
|
||||
class="inline-empty"
|
||||
:class="{ 'is-loading': systemLogLoading }"
|
||||
>
|
||||
<TableLoadingState
|
||||
v-if="systemLogLoading"
|
||||
title="系统日志同步中"
|
||||
message="正在加载系统日志记录"
|
||||
icon="mdi mdi-text-box-search-outline"
|
||||
/>
|
||||
<span v-else>当前筛选条件下没有系统日志记录。</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -129,11 +129,20 @@
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!visibleDocuments.length">
|
||||
<td colspan="7" class="empty-row">
|
||||
{{ loading ? '正在加载知识库文件...' : '当前文件夹暂无文件' }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="loading && !visibleDocuments.length">
|
||||
<td colspan="7" class="empty-row table-loading-row">
|
||||
<TableLoadingState
|
||||
title="知识库文件同步中"
|
||||
message="正在加载当前文件夹的知识库文件"
|
||||
icon="mdi mdi-folder-table-outline"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-else-if="!visibleDocuments.length">
|
||||
<td colspan="7" class="empty-row">
|
||||
当前文件夹暂无文件
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -62,9 +62,11 @@
|
||||
|
||||
<div class="table-wrap" :class="{ 'is-empty': showEmpty }">
|
||||
<div v-if="loading" class="table-state">
|
||||
<i class="mdi mdi-loading mdi-spin"></i>
|
||||
<strong>正在加载真实报销数据</strong>
|
||||
<p>列表将直接展示后端返回的个人报销单据。</p>
|
||||
<TableLoadingState
|
||||
title="真实报销数据同步中"
|
||||
message="正在加载后端返回的个人报销单据"
|
||||
icon="mdi mdi-file-document-outline"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="table-state error">
|
||||
|
||||
@@ -511,18 +511,23 @@
|
||||
<div v-if="composerDateMode === 'single'" class="composer-date-fields">
|
||||
<label class="composer-date-field">
|
||||
<span>日期</span>
|
||||
<input v-model="composerSingleDate" type="date" />
|
||||
<input v-model="composerSingleDate" type="date" @change="handleComposerDateInputChange" />
|
||||
</label>
|
||||
</div>
|
||||
<div v-else class="composer-date-fields composer-date-fields-range">
|
||||
<label class="composer-date-field">
|
||||
<span>开始</span>
|
||||
<input v-model="composerRangeStartDate" type="date" />
|
||||
<input v-model="composerRangeStartDate" type="date" @change="handleComposerDateInputChange" />
|
||||
</label>
|
||||
<span class="composer-date-range-sep">至</span>
|
||||
<label class="composer-date-field">
|
||||
<span>结束</span>
|
||||
<input v-model="composerRangeEndDate" type="date" :min="composerRangeStartDate" />
|
||||
<input
|
||||
v-model="composerRangeEndDate"
|
||||
type="date"
|
||||
:min="composerRangeStartDate"
|
||||
@change="handleComposerDateInputChange"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="composerDateMode === 'range' && !composerCanApplyDateSelection" class="composer-date-hint">
|
||||
@@ -1078,9 +1083,28 @@
|
||||
</span>
|
||||
</div>
|
||||
<p class="review-side-risk-summary">{{ reviewRiskSummary }}</p>
|
||||
<ul v-if="reviewRiskItems.length" class="review-side-risk-list">
|
||||
<li v-for="item in reviewRiskItems" :key="item">{{ item }}</li>
|
||||
</ul>
|
||||
<div v-if="reviewRiskItems.length" class="review-side-risk-list">
|
||||
<button
|
||||
v-for="item in reviewRiskItems"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
class="review-side-risk-item"
|
||||
:class="item.level"
|
||||
@click="openReviewRiskDetail(item)"
|
||||
>
|
||||
<span class="review-side-risk-icon">
|
||||
<i :class="item.icon"></i>
|
||||
</span>
|
||||
<span class="review-side-risk-copy">
|
||||
<strong>{{ item.title }}</strong>
|
||||
<p>{{ item.summary }}</p>
|
||||
</span>
|
||||
<span class="review-side-risk-meta">
|
||||
{{ item.levelLabel }}
|
||||
<i class="mdi mdi-chevron-right"></i>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else-if="reviewRiskEmpty" class="review-side-empty">
|
||||
<span class="review-side-empty-icon">
|
||||
<i class="mdi mdi-shield-check-outline"></i>
|
||||
@@ -1088,16 +1112,6 @@
|
||||
<strong>暂无风险评分</strong>
|
||||
<p>当前版本还没有返回结构化风险评分结果,这里先不展示虚拟分数。</p>
|
||||
</div>
|
||||
<button
|
||||
v-if="reviewRiskActionAvailable"
|
||||
type="button"
|
||||
class="review-side-link"
|
||||
:disabled="submitting || reviewActionBusy"
|
||||
@click="explainCurrentReviewRisk"
|
||||
>
|
||||
查看全部风险项
|
||||
<i class="mdi mdi-chevron-right"></i>
|
||||
</button>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1192,6 +1206,41 @@
|
||||
@confirm="confirmCancelReview"
|
||||
/>
|
||||
|
||||
<Transition name="assistant-modal">
|
||||
<div v-if="reviewRiskDetailDialog.open" class="assistant-overlay review-overlay">
|
||||
<section class="review-risk-detail-modal">
|
||||
<header class="review-risk-detail-head">
|
||||
<div>
|
||||
<span class="assistant-badge warning">{{ reviewRiskDetailDialog.item?.sourceLabel || 'AI预审' }}</span>
|
||||
<h3>{{ reviewRiskDetailDialog.item?.title || '风险提示' }}</h3>
|
||||
</div>
|
||||
<button class="close-btn" type="button" aria-label="关闭风险说明" @click="closeReviewRiskDetail">
|
||||
<i class="mdi mdi-close"></i>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="review-risk-detail-body">
|
||||
<div class="review-risk-detail-level" :class="reviewRiskDetailDialog.item?.level">
|
||||
<i :class="reviewRiskDetailDialog.item?.icon || 'mdi mdi-information-outline'"></i>
|
||||
<span>{{ reviewRiskDetailDialog.item?.levelLabel || '提示' }}</span>
|
||||
</div>
|
||||
<article class="review-risk-detail-section">
|
||||
<strong>提示情况</strong>
|
||||
<p>{{ reviewRiskDetailDialog.item?.summary }}</p>
|
||||
</article>
|
||||
<article class="review-risk-detail-section">
|
||||
<strong>详细解释</strong>
|
||||
<p>{{ reviewRiskDetailDialog.item?.detail }}</p>
|
||||
</article>
|
||||
<article class="review-risk-detail-section">
|
||||
<strong>处理建议</strong>
|
||||
<p>{{ reviewRiskDetailDialog.item?.suggestion }}</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<Transition name="assistant-modal">
|
||||
<div v-if="uploadDecisionDialogOpen" class="assistant-overlay review-overlay">
|
||||
<section class="review-confirm-modal review-upload-decision-modal">
|
||||
|
||||
@@ -394,7 +394,7 @@
|
||||
<span>返回报销列表</span>
|
||||
</button>
|
||||
<div v-if="isDraftRequest" class="approval-action-group" aria-label="申请操作">
|
||||
<button class="reject-action" type="button" :disabled="actionBusy" @click="handleDeleteDraft">
|
||||
<button class="reject-action" type="button" :disabled="actionBusy" @click="handleDeleteRequest">
|
||||
<i class="mdi mdi-trash-can-outline"></i>
|
||||
{{ deleteBusy ? '删除中' : '删除草稿' }}
|
||||
</button>
|
||||
@@ -403,6 +403,22 @@
|
||||
{{ submitBusy ? '提交中' : '提交审批' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-else-if="canManageCurrentClaim" class="approval-action-group" aria-label="单据管理操作">
|
||||
<button
|
||||
v-if="canReturnRequest"
|
||||
class="return-action"
|
||||
type="button"
|
||||
:disabled="actionBusy"
|
||||
@click="handleReturnRequest"
|
||||
>
|
||||
<i class="mdi mdi-undo"></i>
|
||||
{{ returnBusy ? '退回中' : '退回单据' }}
|
||||
</button>
|
||||
<button class="reject-action" type="button" :disabled="actionBusy" @click="handleDeleteRequest">
|
||||
<i class="mdi mdi-trash-can-outline"></i>
|
||||
{{ deleteBusy ? '删除中' : '删除单据' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-else class="detail-action-hint">当前单据已进入流程,详情页仅展示状态与费用明细。</p>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -465,10 +481,10 @@
|
||||
|
||||
<ConfirmDialog
|
||||
:open="deleteDialogOpen"
|
||||
badge="删除草稿"
|
||||
:badge="deleteActionLabel"
|
||||
badge-tone="danger"
|
||||
:title="`确认删除草稿 ${request.id} 吗?`"
|
||||
description="删除后该草稿及其当前费用明细将不可恢复,请确认本次操作。"
|
||||
:title="deleteDialogTitle"
|
||||
:description="deleteDialogDescription"
|
||||
cancel-text="取消"
|
||||
confirm-text="确认删除"
|
||||
busy-text="删除中..."
|
||||
@@ -476,7 +492,23 @@
|
||||
confirm-icon="mdi mdi-trash-can-outline"
|
||||
:busy="deleteBusy"
|
||||
@close="closeDeleteDialog"
|
||||
@confirm="confirmDeleteDraft"
|
||||
@confirm="confirmDeleteRequest"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="returnDialogOpen"
|
||||
badge="退回单据"
|
||||
badge-tone="warning"
|
||||
:title="`确认退回 ${request.id} 吗?`"
|
||||
description="退回后该单据会进入待补充状态,申请人需要补充后重新提交。"
|
||||
cancel-text="取消"
|
||||
confirm-text="确认退回"
|
||||
busy-text="退回中..."
|
||||
confirm-tone="primary"
|
||||
confirm-icon="mdi mdi-undo"
|
||||
:busy="returnBusy"
|
||||
@close="closeReturnDialog"
|
||||
@confirm="confirmReturnRequest"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
||||
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
|
||||
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
|
||||
import { mapExpenseClaimToRequest } from '../../composables/useRequests.js'
|
||||
import { useSystemState } from '../../composables/useSystemState.js'
|
||||
import { fetchExpenseClaims } from '../../services/reimbursements.js'
|
||||
import { useToast } from '../../composables/useToast.js'
|
||||
import { deleteExpenseClaim, fetchExpenseClaims, returnExpenseClaim } from '../../services/reimbursements.js'
|
||||
import { canManageExpenseClaims } from '../../utils/accessControl.js'
|
||||
|
||||
const DEFAULT_SLA_HOURS = 24
|
||||
const tabs = ['全部待审', '高风险', '即将超时', '已处理']
|
||||
@@ -195,7 +199,6 @@ function buildFlowItems(request) {
|
||||
|
||||
function canCurrentUserProcessRequest(request, currentUser) {
|
||||
const node = String(request?.workflowNode || '').trim()
|
||||
const roleCodes = Array.isArray(currentUser?.roleCodes) ? currentUser.roleCodes.filter(Boolean) : []
|
||||
const currentName = String(currentUser?.name || '').trim()
|
||||
const applicantName = String(request?.person || request?.employeeName || '').trim()
|
||||
|
||||
@@ -203,8 +206,8 @@ function canCurrentUserProcessRequest(request, currentUser) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (currentUser?.isAdmin || roleCodes.includes('finance')) {
|
||||
return node.includes('财务')
|
||||
if (canManageExpenseClaims(currentUser)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -251,10 +254,13 @@ function buildApprovalRow(request) {
|
||||
export default {
|
||||
name: 'ApprovalCenterView',
|
||||
components: {
|
||||
ConfirmDialog,
|
||||
TableLoadingState,
|
||||
TableEmptyState
|
||||
},
|
||||
setup() {
|
||||
const { currentUser } = useSystemState()
|
||||
const { toast } = useToast()
|
||||
const activeTab = ref('全部待审')
|
||||
const selectedClaimId = ref('')
|
||||
const expandedExpenseId = ref(null)
|
||||
@@ -262,6 +268,9 @@ export default {
|
||||
const rows = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const actionBusy = ref(false)
|
||||
const returnDialogOpen = ref(false)
|
||||
const deleteDialogOpen = ref(false)
|
||||
|
||||
const selectedRow = computed({
|
||||
get() {
|
||||
@@ -303,6 +312,7 @@ export default {
|
||||
})
|
||||
const showTable = computed(() => !loading.value && !error.value && visibleRows.value.length > 0)
|
||||
const showEmpty = computed(() => !loading.value && !error.value && visibleRows.value.length === 0)
|
||||
const canManageClaims = computed(() => canManageExpenseClaims(currentUser.value))
|
||||
const approvalEmptyState = computed(() => {
|
||||
if (!rows.value.length) {
|
||||
return {
|
||||
@@ -381,6 +391,76 @@ export default {
|
||||
activeTab.value = '全部待审'
|
||||
}
|
||||
|
||||
function handleReturnSelected() {
|
||||
if (!selectedRow.value?.claimId || !canManageClaims.value || actionBusy.value) {
|
||||
return
|
||||
}
|
||||
|
||||
returnDialogOpen.value = true
|
||||
}
|
||||
|
||||
function handleDeleteSelected() {
|
||||
if (!selectedRow.value?.claimId || !canManageClaims.value || actionBusy.value) {
|
||||
return
|
||||
}
|
||||
|
||||
deleteDialogOpen.value = true
|
||||
}
|
||||
|
||||
function closeReturnDialog() {
|
||||
if (!actionBusy.value) {
|
||||
returnDialogOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function closeDeleteDialog() {
|
||||
if (!actionBusy.value) {
|
||||
deleteDialogOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmReturnSelected() {
|
||||
const row = selectedRow.value
|
||||
if (!row?.claimId || actionBusy.value) {
|
||||
return
|
||||
}
|
||||
|
||||
actionBusy.value = true
|
||||
try {
|
||||
await returnExpenseClaim(row.claimId, {
|
||||
reason: '审批中心退回,请申请人补充后重新提交。'
|
||||
})
|
||||
toast(`${row.id} 已退回待补充。`)
|
||||
returnDialogOpen.value = false
|
||||
selectedClaimId.value = ''
|
||||
await reload()
|
||||
} catch (nextError) {
|
||||
toast(nextError?.message || '退回单据失败,请稍后重试。')
|
||||
} finally {
|
||||
actionBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDeleteSelected() {
|
||||
const row = selectedRow.value
|
||||
if (!row?.claimId || actionBusy.value) {
|
||||
return
|
||||
}
|
||||
|
||||
actionBusy.value = true
|
||||
try {
|
||||
const payload = await deleteExpenseClaim(row.claimId)
|
||||
toast(payload?.message || `${row.id} 报销单已删除。`)
|
||||
deleteDialogOpen.value = false
|
||||
selectedClaimId.value = ''
|
||||
await reload()
|
||||
} catch (nextError) {
|
||||
toast(nextError?.message || '删除单据失败,请稍后重试。')
|
||||
} finally {
|
||||
actionBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
@@ -420,8 +500,15 @@ export default {
|
||||
visibleRows,
|
||||
showTable,
|
||||
showEmpty,
|
||||
actionBusy,
|
||||
approvalEmptyState,
|
||||
approvalSteps,
|
||||
canManageClaims,
|
||||
closeDeleteDialog,
|
||||
closeReturnDialog,
|
||||
confirmDeleteSelected,
|
||||
confirmReturnSelected,
|
||||
deleteDialogOpen,
|
||||
summaryItems,
|
||||
heroSummaryItems,
|
||||
currentProgressRingMotion,
|
||||
@@ -434,8 +521,11 @@ export default {
|
||||
riskItems,
|
||||
flowItems,
|
||||
handleEmptyAction,
|
||||
handleDeleteSelected,
|
||||
handleReturnSelected,
|
||||
loading,
|
||||
error,
|
||||
returnDialogOpen,
|
||||
reload
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
||||
import { fetchEmployees } from '../../services/employees.js'
|
||||
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
|
||||
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
|
||||
import { useSystemState } from '../../composables/useSystemState.js'
|
||||
import { useToast } from '../../composables/useToast.js'
|
||||
@@ -1130,7 +1131,6 @@ function buildListItem(asset) {
|
||||
changeCount,
|
||||
updatedAt: formatDateTime(asset.updated_at),
|
||||
badgeTone: tabMeta.badgeTone,
|
||||
spotlight: asset.status === 'active',
|
||||
domainValue: asset.domain
|
||||
}
|
||||
}
|
||||
@@ -1653,6 +1653,7 @@ export default {
|
||||
name: 'AuditView',
|
||||
components: {
|
||||
ConfirmDialog,
|
||||
TableLoadingState,
|
||||
TableEmptyState
|
||||
},
|
||||
emits: ['detail-open-change'],
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
||||
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
|
||||
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
|
||||
import { useToast } from '../../composables/useToast.js'
|
||||
import {
|
||||
disableEmployee,
|
||||
downloadEmployeeImportTemplate,
|
||||
enableEmployee,
|
||||
exportEmployees,
|
||||
fetchEmployeeDetail,
|
||||
fetchEmployeeMeta,
|
||||
fetchEmployees,
|
||||
importEmployees,
|
||||
updateEmployee
|
||||
} from '../../services/employees.js'
|
||||
|
||||
@@ -56,6 +61,7 @@ function createEmployeeForm() {
|
||||
name: '',
|
||||
employeeNo: '',
|
||||
gender: '',
|
||||
age: '',
|
||||
birthDate: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
@@ -64,7 +70,9 @@ function createEmployeeForm() {
|
||||
position: '',
|
||||
grade: '',
|
||||
department: '',
|
||||
organizationUnitCode: '',
|
||||
manager: '',
|
||||
managerEmployeeNo: '',
|
||||
financeOwner: '',
|
||||
costCenter: '',
|
||||
roleCodes: [],
|
||||
@@ -72,24 +80,120 @@ function createEmployeeForm() {
|
||||
}
|
||||
}
|
||||
|
||||
function buildEmployeeForm(employee) {
|
||||
function isPlaceholderManagerName(name) {
|
||||
const normalized = normalizeText(name)
|
||||
return !normalized || normalized === 'CEO' || normalized === '无'
|
||||
}
|
||||
|
||||
function resolveManagerEmployeeNo(employee, roster = []) {
|
||||
const fromApi = normalizeText(employee?.managerEmployeeNo)
|
||||
if (fromApi) {
|
||||
return fromApi
|
||||
}
|
||||
|
||||
const managerName = normalizeText(employee?.manager)
|
||||
if (isPlaceholderManagerName(managerName)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const matches = roster.filter((item) => normalizeText(item.name) === managerName)
|
||||
if (matches.length === 1) {
|
||||
return matches[0].employeeNo
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function enrichEmployeeRecord(employee, roster = []) {
|
||||
if (!employee) {
|
||||
return employee
|
||||
}
|
||||
|
||||
const managerEmployeeNo = resolveManagerEmployeeNo(employee, roster)
|
||||
if (!managerEmployeeNo || managerEmployeeNo === employee.managerEmployeeNo) {
|
||||
return employee
|
||||
}
|
||||
|
||||
return {
|
||||
...employee,
|
||||
managerEmployeeNo
|
||||
}
|
||||
}
|
||||
|
||||
function mergeEmployeeRecords(listItem, detailItem, roster = []) {
|
||||
if (!listItem && !detailItem) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!listItem) {
|
||||
return enrichEmployeeRecord(detailItem, roster)
|
||||
}
|
||||
|
||||
if (!detailItem) {
|
||||
return enrichEmployeeRecord(listItem, roster)
|
||||
}
|
||||
|
||||
const managerEmployeeNo =
|
||||
normalizeText(detailItem.managerEmployeeNo) ||
|
||||
normalizeText(listItem.managerEmployeeNo) ||
|
||||
resolveManagerEmployeeNo(detailItem, roster) ||
|
||||
resolveManagerEmployeeNo(listItem, roster)
|
||||
|
||||
const history =
|
||||
Array.isArray(detailItem.history) && detailItem.history.length
|
||||
? detailItem.history
|
||||
: listItem.history || []
|
||||
|
||||
const permissions =
|
||||
Array.isArray(detailItem.permissions) && detailItem.permissions.length
|
||||
? detailItem.permissions
|
||||
: listItem.permissions || []
|
||||
|
||||
return enrichEmployeeRecord(
|
||||
{
|
||||
...listItem,
|
||||
...detailItem,
|
||||
manager: detailItem.manager || listItem.manager,
|
||||
managerEmployeeNo: managerEmployeeNo || null,
|
||||
history,
|
||||
permissions,
|
||||
roleCodes: detailItem.roleCodes?.length ? detailItem.roleCodes : listItem.roleCodes,
|
||||
roles: detailItem.roles?.length ? detailItem.roles : listItem.roles,
|
||||
organization: detailItem.organization || listItem.organization,
|
||||
department: detailItem.department || listItem.department
|
||||
},
|
||||
roster
|
||||
)
|
||||
}
|
||||
|
||||
function buildEmployeeForm(employee, roster = []) {
|
||||
if (!employee) {
|
||||
return createEmployeeForm()
|
||||
}
|
||||
|
||||
const birthDate = employee.birthDate || ''
|
||||
const managerName = employee.manager || ''
|
||||
const managerEmployeeNo = resolveManagerEmployeeNo(employee, roster)
|
||||
|
||||
return {
|
||||
name: employee.name || '',
|
||||
employeeNo: employee.employeeNo || '',
|
||||
gender: employee.gender || '',
|
||||
birthDate: employee.birthDate || '',
|
||||
age:
|
||||
employee.age !== null && employee.age !== undefined && employee.age !== ''
|
||||
? String(employee.age)
|
||||
: calculateAgeFromDate(birthDate),
|
||||
birthDate,
|
||||
phone: employee.phone || '',
|
||||
email: employee.email || '',
|
||||
joinDate: employee.joinDate || '',
|
||||
location: employee.location || '',
|
||||
position: employee.position || '',
|
||||
grade: employee.grade || '',
|
||||
department: employee.department || '',
|
||||
manager: employee.manager || '',
|
||||
department: resolveOrganizationUnitName(employee),
|
||||
organizationUnitCode: resolveOrganizationUnitCode(employee),
|
||||
manager: managerName,
|
||||
managerEmployeeNo,
|
||||
financeOwner: employee.financeOwner || '',
|
||||
costCenter: employee.costCenter || '',
|
||||
roleCodes: [...(employee.roleCodes || [])],
|
||||
@@ -154,6 +258,60 @@ function sameValues(left, right) {
|
||||
return left.every((value, index) => value === right[index])
|
||||
}
|
||||
|
||||
function formatEmployeeHistoryTime(value) {
|
||||
const raw = normalizeText(value)
|
||||
if (!raw) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const matched = raw.match(
|
||||
/^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?$/
|
||||
)
|
||||
if (!matched) {
|
||||
return raw
|
||||
}
|
||||
|
||||
const year = Number.parseInt(matched[1], 10)
|
||||
const month = Number.parseInt(matched[2], 10)
|
||||
const day = Number.parseInt(matched[3], 10)
|
||||
const hour = Number.parseInt(matched[4] || '0', 10)
|
||||
const minute = Number.parseInt(matched[5] || '0', 10)
|
||||
const second = Number.parseInt(matched[6] || '0', 10)
|
||||
|
||||
return `${year}年${month}月${day}日${hour}时${minute}分${second}秒`
|
||||
}
|
||||
|
||||
function resolveOrganizationUnitCode(employee) {
|
||||
return normalizeText(employee?.organization?.code)
|
||||
}
|
||||
|
||||
function resolveOrganizationUnitName(employee) {
|
||||
return normalizeText(employee?.department) || normalizeText(employee?.organization?.name)
|
||||
}
|
||||
|
||||
function captureEmployeeDetailSnapshot(form) {
|
||||
return {
|
||||
roleCodes: [...(form.roleCodes || [])].sort(),
|
||||
organizationUnitCode: normalizeText(form.organizationUnitCode) || ''
|
||||
}
|
||||
}
|
||||
|
||||
function resolveOrganizationOptions(metaOrganizations) {
|
||||
if (!Array.isArray(metaOrganizations) || !metaOrganizations.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
return metaOrganizations
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
code: item.code,
|
||||
name: item.name,
|
||||
unitType: item.unitType,
|
||||
label: `${item.name}(${item.code})`
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'))
|
||||
}
|
||||
|
||||
function calculateAgeFromDate(dateString) {
|
||||
if (!dateString) {
|
||||
return ''
|
||||
@@ -177,6 +335,33 @@ function calculateAgeFromDate(dateString) {
|
||||
return age >= 0 ? String(age) : ''
|
||||
}
|
||||
|
||||
function calculateBirthDateFromAge(ageValue, existingBirthDate = '') {
|
||||
const age = Number.parseInt(String(ageValue ?? '').trim(), 10)
|
||||
if (Number.isNaN(age) || age < 0 || age > 120) {
|
||||
return existingBirthDate || ''
|
||||
}
|
||||
|
||||
const today = new Date()
|
||||
let month = '01'
|
||||
let day = '01'
|
||||
|
||||
if (existingBirthDate && isValidIsoDate(existingBirthDate)) {
|
||||
const [, monthText, dayText] = existingBirthDate.split('-')
|
||||
month = monthText
|
||||
day = dayText
|
||||
}
|
||||
|
||||
let birthYear = today.getFullYear() - age
|
||||
let candidate = `${birthYear}-${month}-${day}`
|
||||
|
||||
if (Number(calculateAgeFromDate(candidate)) > age) {
|
||||
birthYear -= 1
|
||||
candidate = `${birthYear}-${month}-${day}`
|
||||
}
|
||||
|
||||
return candidate
|
||||
}
|
||||
|
||||
function matchKeyword(employee, keyword) {
|
||||
if (!keyword) {
|
||||
return true
|
||||
@@ -249,6 +434,7 @@ export default {
|
||||
name: 'EmployeeManagementView',
|
||||
components: {
|
||||
ConfirmDialog,
|
||||
TableLoadingState,
|
||||
TableEmptyState
|
||||
},
|
||||
emits: ['overview-change'],
|
||||
@@ -272,17 +458,37 @@ export default {
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const disableDialogOpen = ref(false)
|
||||
const importFileInput = ref(null)
|
||||
const pendingImportFile = ref(null)
|
||||
const importConfirmDialogOpen = ref(false)
|
||||
const importErrorDialogOpen = ref(false)
|
||||
const importErrors = ref([])
|
||||
const importResultMessage = ref('')
|
||||
const managerPickerOpen = ref(false)
|
||||
const managerSearchKeyword = ref('')
|
||||
const departmentPickerOpen = ref(false)
|
||||
const departmentSearchKeyword = ref('')
|
||||
const organizationUnitOptions = ref([])
|
||||
const employeeDetailSnapshot = ref(null)
|
||||
|
||||
const tabs = computed(() => buildStatusTabs(employees.value))
|
||||
const employeeSummary = computed(() => buildEmployeeSummary(employees.value))
|
||||
const detailAge = computed(() => calculateAgeFromDate(employeeForm.value.birthDate))
|
||||
const roleCount = computed(() => employeeForm.value.roleCodes.length)
|
||||
const selectedRoleLabels = computed(() =>
|
||||
roleOptions.value
|
||||
.filter((role) => employeeForm.value.roleCodes.includes(role.code))
|
||||
.map((role) => role.label)
|
||||
)
|
||||
const actionBusy = computed(() => actionState.value === 'save' || actionState.value === 'disable')
|
||||
const actionBusy = computed(
|
||||
() =>
|
||||
actionState.value === 'save' ||
|
||||
actionState.value === 'disable' ||
|
||||
actionState.value === 'import' ||
|
||||
actionState.value === 'export'
|
||||
)
|
||||
const importExportBusy = computed(
|
||||
() => actionState.value === 'import' || actionState.value === 'export'
|
||||
)
|
||||
const disableActionDisabled = computed(() => actionBusy.value || !selectedEmployee.value)
|
||||
const selectedEmployeeDisabled = computed(() => selectedEmployee.value?.status === '停用')
|
||||
const statusActionCopy = computed(() => {
|
||||
@@ -333,6 +539,94 @@ export default {
|
||||
)
|
||||
)
|
||||
|
||||
const managerOptions = computed(() => {
|
||||
const currentId = selectedEmployee.value?.id
|
||||
return employees.value.filter((item) => item.id !== currentId)
|
||||
})
|
||||
|
||||
const filteredManagerOptions = computed(() => {
|
||||
const keyword = managerSearchKeyword.value.trim().toLowerCase()
|
||||
if (!keyword) {
|
||||
return managerOptions.value.slice(0, 20)
|
||||
}
|
||||
|
||||
return managerOptions.value
|
||||
.filter((item) => {
|
||||
const haystack = [
|
||||
item.name,
|
||||
item.employeeNo,
|
||||
item.department,
|
||||
item.position,
|
||||
item.email
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
|
||||
return haystack.includes(keyword)
|
||||
})
|
||||
.slice(0, 20)
|
||||
})
|
||||
|
||||
const managerDisplayLabel = computed(() => {
|
||||
const managerNo = normalizeText(employeeForm.value.managerEmployeeNo)
|
||||
const managerName = normalizeText(employeeForm.value.manager)
|
||||
|
||||
if (managerNo) {
|
||||
const matched =
|
||||
managerOptions.value.find((item) => item.employeeNo === managerNo) ||
|
||||
employees.value.find((item) => item.employeeNo === managerNo)
|
||||
|
||||
if (matched) {
|
||||
return `${matched.name}(${matched.employeeNo})`
|
||||
}
|
||||
|
||||
return managerName ? `${managerName}(${managerNo})` : managerNo
|
||||
}
|
||||
|
||||
if (!isPlaceholderManagerName(managerName)) {
|
||||
return managerName
|
||||
}
|
||||
|
||||
return '未设置直属上级'
|
||||
})
|
||||
|
||||
const filteredDepartmentOptions = computed(() => {
|
||||
const keyword = departmentSearchKeyword.value.trim().toLowerCase()
|
||||
const options = organizationUnitOptions.value
|
||||
|
||||
if (!keyword) {
|
||||
return options.slice(0, 20)
|
||||
}
|
||||
|
||||
return options
|
||||
.filter((item) => {
|
||||
const haystack = [item.name, item.code, item.unitType, item.label]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
|
||||
return haystack.includes(keyword)
|
||||
})
|
||||
.slice(0, 20)
|
||||
})
|
||||
|
||||
const departmentDisplayLabel = computed(() => {
|
||||
const code = normalizeText(employeeForm.value.organizationUnitCode)
|
||||
const name = normalizeText(employeeForm.value.department)
|
||||
|
||||
if (code) {
|
||||
const matched = organizationUnitOptions.value.find((item) => item.code === code)
|
||||
if (matched) {
|
||||
return matched.label
|
||||
}
|
||||
|
||||
return name ? `${name}(${code})` : code
|
||||
}
|
||||
|
||||
return name || '请选择所属部门'
|
||||
})
|
||||
|
||||
const filteredEmployees = computed(() => {
|
||||
const keyword = searchKeyword.value.trim().toLowerCase()
|
||||
|
||||
@@ -431,14 +725,66 @@ export default {
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function syncFormFromEmployee(employee) {
|
||||
if (!employee) {
|
||||
employeeForm.value = createEmployeeForm()
|
||||
employeeDetailSnapshot.value = null
|
||||
return
|
||||
}
|
||||
|
||||
const preservedPassword = employeeForm.value.password
|
||||
employeeForm.value = buildEmployeeForm(employee, employees.value)
|
||||
employeeForm.value.password = preservedPassword
|
||||
employeeDetailSnapshot.value = captureEmployeeDetailSnapshot(employeeForm.value)
|
||||
}
|
||||
|
||||
watch(
|
||||
selectedEmployee,
|
||||
(employee) => {
|
||||
employeeForm.value = buildEmployeeForm(employee)
|
||||
() => selectedEmployee.value?.id ?? null,
|
||||
(employeeId, previousId) => {
|
||||
if (!employeeId) {
|
||||
syncFormFromEmployee(null)
|
||||
return
|
||||
}
|
||||
|
||||
if (employeeId === previousId) {
|
||||
return
|
||||
}
|
||||
|
||||
syncFormFromEmployee(selectedEmployee.value)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(employees, () => {
|
||||
if (!selectedEmployee.value?.id) {
|
||||
return
|
||||
}
|
||||
|
||||
const preserved = selectedEmployee.value
|
||||
const fromList = employees.value.find((item) => item.id === preserved.id)
|
||||
if (!fromList) {
|
||||
return
|
||||
}
|
||||
|
||||
selectedEmployee.value = mergeEmployeeRecords(fromList, preserved, employees.value)
|
||||
})
|
||||
|
||||
const hasManagerAssignment = computed(() => {
|
||||
return (
|
||||
Boolean(normalizeText(employeeForm.value.managerEmployeeNo)) ||
|
||||
!isPlaceholderManagerName(employeeForm.value.manager)
|
||||
)
|
||||
})
|
||||
|
||||
const recentEmployeeHistory = computed(() => {
|
||||
const history = selectedEmployee.value?.history
|
||||
if (!Array.isArray(history)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return history.slice(0, 5)
|
||||
})
|
||||
|
||||
watch(filteredEmployees, () => {
|
||||
currentPage.value = 1
|
||||
pageSizeOpen.value = false
|
||||
@@ -510,15 +856,105 @@ export default {
|
||||
closeFilterPopover()
|
||||
}
|
||||
|
||||
if (!target.closest('.manager-picker')) {
|
||||
closeManagerPicker()
|
||||
}
|
||||
|
||||
if (!target.closest('.department-picker')) {
|
||||
closeDepartmentPicker()
|
||||
}
|
||||
|
||||
if (!target.closest('.page-size-wrap')) {
|
||||
pageSizeOpen.value = false
|
||||
}
|
||||
|
||||
if (target.closest('.picker-filter') || target.closest('.page-size-wrap')) {
|
||||
if (
|
||||
target.closest('.picker-filter') ||
|
||||
target.closest('.page-size-wrap') ||
|
||||
target.closest('.manager-picker') ||
|
||||
target.closest('.department-picker')
|
||||
) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDepartmentPicker() {
|
||||
departmentPickerOpen.value = !departmentPickerOpen.value
|
||||
if (!departmentPickerOpen.value) {
|
||||
departmentSearchKeyword.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function closeDepartmentPicker() {
|
||||
departmentPickerOpen.value = false
|
||||
departmentSearchKeyword.value = ''
|
||||
}
|
||||
|
||||
function selectDepartment(option) {
|
||||
if (!option) {
|
||||
return
|
||||
}
|
||||
|
||||
employeeForm.value.organizationUnitCode = option.code
|
||||
employeeForm.value.department = option.name
|
||||
closeDepartmentPicker()
|
||||
}
|
||||
|
||||
function resolveDepartmentSelectionFromKeyword() {
|
||||
const keyword = normalizeText(departmentSearchKeyword.value)
|
||||
if (!keyword || normalizeText(employeeForm.value.organizationUnitCode)) {
|
||||
return
|
||||
}
|
||||
|
||||
const exactMatches = organizationUnitOptions.value.filter(
|
||||
(item) => item.code === keyword || item.name === keyword
|
||||
)
|
||||
|
||||
if (exactMatches.length === 1) {
|
||||
selectDepartment(exactMatches[0])
|
||||
}
|
||||
}
|
||||
|
||||
function toggleManagerPicker() {
|
||||
managerPickerOpen.value = !managerPickerOpen.value
|
||||
if (!managerPickerOpen.value) {
|
||||
managerSearchKeyword.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function closeManagerPicker() {
|
||||
managerPickerOpen.value = false
|
||||
managerSearchKeyword.value = ''
|
||||
}
|
||||
|
||||
function selectManager(option) {
|
||||
if (!option) {
|
||||
employeeForm.value.managerEmployeeNo = ''
|
||||
employeeForm.value.manager = ''
|
||||
closeManagerPicker()
|
||||
return
|
||||
}
|
||||
|
||||
employeeForm.value.managerEmployeeNo = option.employeeNo
|
||||
employeeForm.value.manager = option.name
|
||||
closeManagerPicker()
|
||||
}
|
||||
|
||||
function resolveManagerSelectionFromKeyword() {
|
||||
const keyword = normalizeText(managerSearchKeyword.value)
|
||||
if (!keyword || normalizeText(employeeForm.value.managerEmployeeNo)) {
|
||||
return
|
||||
}
|
||||
|
||||
const exactMatches = managerOptions.value.filter(
|
||||
(item) => item.employeeNo === keyword || item.name === keyword
|
||||
)
|
||||
|
||||
if (exactMatches.length === 1) {
|
||||
selectManager(exactMatches[0])
|
||||
}
|
||||
}
|
||||
|
||||
function openEmployeeDetail(employee) {
|
||||
selectedEmployee.value = employee
|
||||
}
|
||||
@@ -527,6 +963,24 @@ export default {
|
||||
selectedEmployee.value = null
|
||||
employeeForm.value = createEmployeeForm()
|
||||
actionState.value = ''
|
||||
closeManagerPicker()
|
||||
closeDepartmentPicker()
|
||||
}
|
||||
|
||||
function syncAgeFromBirthDate() {
|
||||
employeeForm.value.age = calculateAgeFromDate(employeeForm.value.birthDate)
|
||||
}
|
||||
|
||||
function syncBirthDateFromAge() {
|
||||
const ageText = normalizeText(employeeForm.value.age)
|
||||
if (!ageText) {
|
||||
return
|
||||
}
|
||||
|
||||
employeeForm.value.birthDate = calculateBirthDateFromAge(
|
||||
ageText,
|
||||
employeeForm.value.birthDate
|
||||
)
|
||||
}
|
||||
|
||||
function buildUpdatePayload() {
|
||||
@@ -583,6 +1037,15 @@ export default {
|
||||
payload.grade = nextGrade
|
||||
}
|
||||
|
||||
const nextOrganizationCode = normalizeText(form.organizationUnitCode)
|
||||
const currentOrganizationCode =
|
||||
normalizeText(employeeDetailSnapshot.value?.organizationUnitCode) ||
|
||||
resolveOrganizationUnitCode(current) ||
|
||||
''
|
||||
if (nextOrganizationCode !== currentOrganizationCode) {
|
||||
payload.organization_unit_code = nextOrganizationCode
|
||||
}
|
||||
|
||||
const nextFinanceOwner = normalizeNullableText(form.financeOwner)
|
||||
if (nextFinanceOwner !== (current.financeOwner || null)) {
|
||||
payload.finance_owner_name = nextFinanceOwner
|
||||
@@ -593,10 +1056,19 @@ export default {
|
||||
payload.cost_center = nextCostCenter
|
||||
}
|
||||
|
||||
const nextManagerEmployeeNo = normalizeNullableText(form.managerEmployeeNo)
|
||||
const currentManagerEmployeeNo =
|
||||
normalizeNullableText(current.managerEmployeeNo) ||
|
||||
resolveManagerEmployeeNo(current, employees.value) ||
|
||||
null
|
||||
if (nextManagerEmployeeNo !== currentManagerEmployeeNo) {
|
||||
payload.manager_employee_no = nextManagerEmployeeNo || ''
|
||||
}
|
||||
|
||||
const nextRoleCodes = [...form.roleCodes].sort()
|
||||
const currentRoleCodes = [...(current.roleCodes || [])].sort()
|
||||
const currentRoleCodes = [...(employeeDetailSnapshot.value?.roleCodes || current.roleCodes || [])].sort()
|
||||
if (!sameValues(nextRoleCodes, currentRoleCodes)) {
|
||||
payload.role_codes = form.roleCodes
|
||||
payload.role_codes = [...form.roleCodes]
|
||||
}
|
||||
|
||||
const nextPassword = normalizeText(form.password)
|
||||
@@ -637,6 +1109,16 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
const ageText = normalizeText(employeeForm.value.age)
|
||||
if (ageText) {
|
||||
const age = Number.parseInt(ageText, 10)
|
||||
if (Number.isNaN(age) || age < 0 || age > 120) {
|
||||
toast('年龄请输入 0 到 120 之间的整数。')
|
||||
return
|
||||
}
|
||||
syncBirthDateFromAge()
|
||||
}
|
||||
|
||||
const birthDate = normalizeNullableText(employeeForm.value.birthDate)
|
||||
if (birthDate && !isValidIsoDate(birthDate)) {
|
||||
toast('出生日期格式不正确,请使用 YYYY-MM-DD。')
|
||||
@@ -654,18 +1136,56 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
resolveManagerSelectionFromKeyword()
|
||||
resolveDepartmentSelectionFromKeyword()
|
||||
|
||||
if (!normalizeText(employeeForm.value.organizationUnitCode)) {
|
||||
toast('请选择所属部门。')
|
||||
return
|
||||
}
|
||||
|
||||
const payload = buildUpdatePayload()
|
||||
if (!Object.keys(payload).length) {
|
||||
toast('未检测到需要保存的变更。')
|
||||
return
|
||||
}
|
||||
|
||||
if (normalizeText(employeeForm.value.managerEmployeeNo) === selectedEmployee.value.employeeNo) {
|
||||
toast('直属上级不能设置为员工本人。')
|
||||
return
|
||||
}
|
||||
|
||||
actionState.value = 'save'
|
||||
|
||||
try {
|
||||
const updated = await updateEmployee(selectedEmployee.value.id, payload)
|
||||
const employeeId = selectedEmployee.value.id
|
||||
const updated = await updateEmployee(employeeId, payload)
|
||||
selectedEmployee.value = updated
|
||||
await loadEmployees()
|
||||
|
||||
let refreshed = updated
|
||||
try {
|
||||
refreshed = await fetchEmployeeDetail(employeeId)
|
||||
} catch {
|
||||
refreshed = updated
|
||||
}
|
||||
|
||||
const fromList = employees.value.find((item) => item.id === employeeId)
|
||||
const merged = mergeEmployeeRecords(fromList, refreshed, employees.value)
|
||||
selectedEmployee.value = merged
|
||||
|
||||
const listIndex = employees.value.findIndex((item) => item.id === employeeId)
|
||||
if (listIndex >= 0) {
|
||||
employees.value[listIndex] = {
|
||||
...employees.value[listIndex],
|
||||
...merged
|
||||
}
|
||||
}
|
||||
|
||||
closeManagerPicker()
|
||||
closeDepartmentPicker()
|
||||
syncFormFromEmployee(selectedEmployee.value)
|
||||
employeeDetailSnapshot.value = captureEmployeeDetailSnapshot(employeeForm.value)
|
||||
toast('员工信息已保存并生效。')
|
||||
} catch (error) {
|
||||
toast(error?.message || '员工信息保存失败,请稍后重试。')
|
||||
@@ -723,6 +1243,95 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
function openImportFilePicker() {
|
||||
importFileInput.value?.click()
|
||||
}
|
||||
|
||||
function handleImportFileChange(event) {
|
||||
const file = event.target.files?.[0]
|
||||
event.target.value = ''
|
||||
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
|
||||
pendingImportFile.value = file
|
||||
importConfirmDialogOpen.value = true
|
||||
}
|
||||
|
||||
function closeImportConfirmDialog() {
|
||||
if (actionState.value === 'import') {
|
||||
return
|
||||
}
|
||||
|
||||
importConfirmDialogOpen.value = false
|
||||
pendingImportFile.value = null
|
||||
}
|
||||
|
||||
function closeImportErrorDialog() {
|
||||
importErrorDialogOpen.value = false
|
||||
importErrors.value = []
|
||||
importResultMessage.value = ''
|
||||
}
|
||||
|
||||
async function handleDownloadTemplate() {
|
||||
try {
|
||||
await downloadEmployeeImportTemplate()
|
||||
toast('员工导入模板已开始下载。')
|
||||
} catch (error) {
|
||||
toast(error?.message || '模板下载失败,请稍后重试。')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExportEmployees() {
|
||||
actionState.value = 'export'
|
||||
|
||||
try {
|
||||
await exportEmployees({
|
||||
status: activeTab.value,
|
||||
keyword: searchKeyword.value.trim()
|
||||
})
|
||||
toast('员工目录已开始导出。')
|
||||
} catch (error) {
|
||||
toast(error?.message || '员工导出失败,请稍后重试。')
|
||||
} finally {
|
||||
actionState.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmImportEmployees() {
|
||||
const file = pendingImportFile.value
|
||||
if (!file) {
|
||||
closeImportConfirmDialog()
|
||||
return
|
||||
}
|
||||
|
||||
actionState.value = 'import'
|
||||
|
||||
try {
|
||||
const result = await importEmployees(file)
|
||||
|
||||
if (!result?.success) {
|
||||
importErrors.value = Array.isArray(result?.errors) ? result.errors : []
|
||||
importResultMessage.value =
|
||||
result?.message || '导入未执行,请根据下方错误提示修正 Excel 后重试。'
|
||||
importConfirmDialogOpen.value = false
|
||||
importErrorDialogOpen.value = true
|
||||
pendingImportFile.value = null
|
||||
return
|
||||
}
|
||||
|
||||
importConfirmDialogOpen.value = false
|
||||
pendingImportFile.value = null
|
||||
await loadEmployees()
|
||||
toast(result.message || '员工导入成功。')
|
||||
} catch (error) {
|
||||
toast(error?.message || '员工导入失败,请稍后重试。')
|
||||
} finally {
|
||||
actionState.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEmployees() {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
@@ -735,6 +1344,7 @@ export default {
|
||||
if (employeesResult.status !== 'fulfilled') {
|
||||
employees.value = []
|
||||
roleOptions.value = [...FALLBACK_ROLE_OPTIONS]
|
||||
organizationUnitOptions.value = []
|
||||
selectedEmployee.value = null
|
||||
errorMessage.value =
|
||||
employeesResult.reason?.message || '员工数据加载失败,请稍后重试。'
|
||||
@@ -742,12 +1352,17 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
employees.value = Array.isArray(employeesResult.value) ? employeesResult.value : []
|
||||
const roster = Array.isArray(employeesResult.value) ? employeesResult.value : []
|
||||
employees.value = roster.map((item) => enrichEmployeeRecord(item, roster))
|
||||
|
||||
if (metaResult.status === 'fulfilled') {
|
||||
roleOptions.value = resolveRoleOptions(metaResult.value?.roleOptions, employees.value)
|
||||
organizationUnitOptions.value = resolveOrganizationOptions(
|
||||
metaResult.value?.organizationOptions
|
||||
)
|
||||
} else {
|
||||
roleOptions.value = resolveRoleOptions([], employees.value)
|
||||
organizationUnitOptions.value = []
|
||||
}
|
||||
|
||||
if (!DEFAULT_STATUS_TABS.includes(activeTab.value)) {
|
||||
@@ -755,8 +1370,9 @@ export default {
|
||||
}
|
||||
|
||||
if (selectedEmployee.value) {
|
||||
selectedEmployee.value =
|
||||
employees.value.find((item) => item.id === selectedEmployee.value.id) || null
|
||||
const preserved = selectedEmployee.value
|
||||
const fromList = employees.value.find((item) => item.id === preserved.id) || null
|
||||
selectedEmployee.value = mergeEmployeeRecords(fromList, preserved, employees.value)
|
||||
}
|
||||
|
||||
loading.value = false
|
||||
@@ -767,6 +1383,7 @@ export default {
|
||||
loadEmployees().catch((error) => {
|
||||
employees.value = []
|
||||
roleOptions.value = [...FALLBACK_ROLE_OPTIONS]
|
||||
organizationUnitOptions.value = []
|
||||
selectedEmployee.value = null
|
||||
errorMessage.value = error?.message || '员工数据加载失败,请稍后重试。'
|
||||
loading.value = false
|
||||
@@ -781,13 +1398,27 @@ export default {
|
||||
tabs,
|
||||
activeTab,
|
||||
employeeForm,
|
||||
detailAge,
|
||||
roleCount,
|
||||
syncAgeFromBirthDate,
|
||||
syncBirthDateFromAge,
|
||||
selectedRoleLabels,
|
||||
selectedEmployeeDisabled,
|
||||
statusActionCopy,
|
||||
actionState,
|
||||
actionBusy,
|
||||
importExportBusy,
|
||||
importFileInput,
|
||||
importConfirmDialogOpen,
|
||||
importErrorDialogOpen,
|
||||
importErrors,
|
||||
importResultMessage,
|
||||
openImportFilePicker,
|
||||
handleImportFileChange,
|
||||
closeImportConfirmDialog,
|
||||
closeImportErrorDialog,
|
||||
handleDownloadTemplate,
|
||||
handleExportEmployees,
|
||||
confirmImportEmployees,
|
||||
disableActionDisabled,
|
||||
selectedEmployee,
|
||||
roleOptions,
|
||||
@@ -806,6 +1437,25 @@ export default {
|
||||
departmentOptions,
|
||||
gradeOptions,
|
||||
roleFilterOptions,
|
||||
managerPickerOpen,
|
||||
managerSearchKeyword,
|
||||
managerDisplayLabel,
|
||||
hasManagerAssignment,
|
||||
departmentPickerOpen,
|
||||
departmentSearchKeyword,
|
||||
departmentDisplayLabel,
|
||||
filteredDepartmentOptions,
|
||||
toggleDepartmentPicker,
|
||||
closeDepartmentPicker,
|
||||
selectDepartment,
|
||||
resolveDepartmentSelectionFromKeyword,
|
||||
recentEmployeeHistory,
|
||||
formatEmployeeHistoryTime,
|
||||
filteredManagerOptions,
|
||||
toggleManagerPicker,
|
||||
closeManagerPicker,
|
||||
selectManager,
|
||||
resolveManagerSelectionFromKeyword,
|
||||
activeFilterTokens,
|
||||
hasActiveFilters,
|
||||
hasEmployeeFilters,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useRouter } from 'vue-router'
|
||||
|
||||
import LogTrendChart from '../../components/charts/LogTrendChart.vue'
|
||||
import DonutChart from '../../components/charts/DonutChart.vue'
|
||||
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
|
||||
import { fetchAgentRuns } from '../../services/agentAssets.js'
|
||||
import { fetchSystemLogEntries } from '../../services/systemLogs.js'
|
||||
import { useSystemState } from '../../composables/useSystemState.js'
|
||||
@@ -217,10 +218,11 @@ function buildTrendSeries(runs) {
|
||||
|
||||
export default {
|
||||
name: 'LogsView',
|
||||
components: {
|
||||
LogTrendChart,
|
||||
DonutChart
|
||||
},
|
||||
components: {
|
||||
LogTrendChart,
|
||||
DonutChart,
|
||||
TableLoadingState
|
||||
},
|
||||
emits: ['summary-change'],
|
||||
setup(_, { emit }) {
|
||||
const router = useRouter()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
||||
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
|
||||
import { useSystemState } from '../../composables/useSystemState.js'
|
||||
import { useToast } from '../../composables/useToast.js'
|
||||
import {
|
||||
@@ -79,10 +80,11 @@ function setBodyScrollLocked(isLocked) {
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'PoliciesView',
|
||||
components: {
|
||||
ConfirmDialog
|
||||
},
|
||||
name: 'PoliciesView',
|
||||
components: {
|
||||
ConfirmDialog,
|
||||
TableLoadingState
|
||||
},
|
||||
emits: ['summary-change'],
|
||||
setup(_, { emit }) {
|
||||
const { currentUser } = useSystemState()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
|
||||
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
|
||||
import { normalizeRequestForUi } from '../../utils/requestViewModel.js'
|
||||
|
||||
@@ -11,6 +12,7 @@ function extractRowDate(value) {
|
||||
export default {
|
||||
name: 'RequestsView',
|
||||
components: {
|
||||
TableLoadingState,
|
||||
TableEmptyState
|
||||
},
|
||||
props: {
|
||||
|
||||
@@ -43,6 +43,24 @@ const INTENT_LABELS = {
|
||||
operate: '动作请求'
|
||||
}
|
||||
|
||||
const REVIEW_RISK_LEVEL_META = {
|
||||
high: {
|
||||
label: '高风险',
|
||||
icon: 'mdi mdi-alert-octagon-outline',
|
||||
suggestion: '建议先处理该项,再提交审批;如果属于合理业务场景,请补充说明或附件。'
|
||||
},
|
||||
warning: {
|
||||
label: '需关注',
|
||||
icon: 'mdi mdi-alert-circle-outline',
|
||||
suggestion: '提交前建议核对业务真实性、附件完整性和规则口径。'
|
||||
},
|
||||
info: {
|
||||
label: '提示',
|
||||
icon: 'mdi mdi-information-outline',
|
||||
suggestion: '该项主要用于辅助判断,可结合当前单据情况继续核对。'
|
||||
}
|
||||
}
|
||||
|
||||
const DOCUMENT_TYPE_LABELS = {
|
||||
travel_ticket: '行程单/机票/车票',
|
||||
flight_itinerary: '机票/航班行程单',
|
||||
@@ -1503,7 +1521,7 @@ function buildDraftSavedPayload({
|
||||
secondaryStatusLabel: typeCode === 'travel' ? '行程状态' : '票据状态',
|
||||
secondaryStatusValue: documents.length ? '待继续完善' : '待上传票据',
|
||||
secondaryStatusTone: documents.length ? 'warning' : 'neutral',
|
||||
riskSummary: riskItems[0] || (missingItems.length ? '仍有识别字段待补充,请继续完善。' : 'AI 已生成草稿,可继续补充后提交。'),
|
||||
riskSummary: riskItems[0]?.summary || (missingItems.length ? '仍有识别字段待补充,请继续完善。' : 'AI 已生成草稿,可继续补充后提交。'),
|
||||
attachmentSummary,
|
||||
expenseTableSummary: documents.length
|
||||
? `已关联 ${documents.length} 份票据,请继续在报销页补充和确认`
|
||||
@@ -2451,16 +2469,43 @@ function buildReviewRiskSummary(reviewPayload) {
|
||||
return '当前版本暂未生成风险评分结果。'
|
||||
}
|
||||
|
||||
function normalizeReviewRiskLevel(level) {
|
||||
const normalized = String(level || '').trim().toLowerCase()
|
||||
if (normalized === 'danger' || normalized === 'error' || normalized === 'critical') return 'high'
|
||||
if (normalized === 'warn' || normalized === 'medium') return 'warning'
|
||||
if (normalized === 'high' || normalized === 'warning' || normalized === 'info') return normalized
|
||||
return 'info'
|
||||
}
|
||||
|
||||
function buildReviewRiskItems(reviewPayload) {
|
||||
return resolveReviewRiskBriefs(reviewPayload)
|
||||
.map((brief) => {
|
||||
.map((brief, index) => {
|
||||
const title = String(brief?.title || '').trim()
|
||||
const content = String(brief?.content || '').trim()
|
||||
if (title && content) return `${title}:${content}`
|
||||
return content || title
|
||||
const detail = String(brief?.detail || '').trim()
|
||||
const suggestion = String(brief?.suggestion || '').trim()
|
||||
const level = normalizeReviewRiskLevel(brief?.level)
|
||||
const meta = REVIEW_RISK_LEVEL_META[level] || REVIEW_RISK_LEVEL_META.info
|
||||
const fallbackTitle = content ? `风险提示 ${index + 1}` : '风险提示'
|
||||
const normalizedTitle = title || fallbackTitle
|
||||
const summary = content || normalizedTitle
|
||||
|
||||
if (!normalizedTitle && !summary) return null
|
||||
|
||||
return {
|
||||
key: `${level}-${normalizedTitle}-${index}`,
|
||||
title: normalizedTitle,
|
||||
summary,
|
||||
detail: detail || content || '当前风险项没有返回更长解释,建议结合票据、报销事由和规则要求进行复核。',
|
||||
level,
|
||||
levelLabel: meta.label,
|
||||
icon: meta.icon,
|
||||
sourceLabel: title === '历史报销画像' ? '历史记录' : 'AI预审',
|
||||
suggestion: suggestion || meta.suggestion
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
.slice(0, 4)
|
||||
.slice(0, 6)
|
||||
}
|
||||
|
||||
function resolveInlineReviewSlotValue(slotKey, inlineState = createEmptyInlineReviewState()) {
|
||||
@@ -2904,6 +2949,7 @@ export default {
|
||||
const composerRangeStartDate = ref(formatDateInputValue())
|
||||
const composerRangeEndDate = ref(formatDateInputValue())
|
||||
const composerBusinessTimeTags = ref([])
|
||||
const composerBusinessTimeDraftTouched = ref(false)
|
||||
const attachedFiles = ref([])
|
||||
const composerFilesExpanded = ref(false)
|
||||
const submitting = ref(false)
|
||||
@@ -2947,6 +2993,10 @@ export default {
|
||||
const activeReviewDocumentIndex = ref(0)
|
||||
const reviewDrawerMode = ref(REVIEW_DRAWER_MODE_REVIEW)
|
||||
const insightPanelCollapsed = ref(false)
|
||||
const reviewRiskDetailDialog = ref({
|
||||
open: false,
|
||||
item: null
|
||||
})
|
||||
const documentPreviewDialog = ref({
|
||||
open: false,
|
||||
filename: '',
|
||||
@@ -3107,7 +3157,6 @@ export default {
|
||||
const reviewRiskEmpty = computed(() => reviewRiskScore.value === null && !reviewRiskItems.value.length)
|
||||
const reviewDocumentDrawerAvailable = computed(() => reviewDocumentCount.value > 0)
|
||||
const reviewRiskDrawerAvailable = computed(() => !reviewRiskEmpty.value)
|
||||
const reviewRiskActionAvailable = computed(() => reviewRiskItems.value.length > 0)
|
||||
const reviewFlowDrawerAvailable = computed(() => flowSteps.value.length > 0)
|
||||
const recognizedNarratives = computed(() => buildReviewRecognizedLines(activeReviewPayload.value))
|
||||
const reviewRecognitionNotes = computed(() => buildReviewRecognitionNotes(activeReviewPayload.value))
|
||||
@@ -3932,6 +3981,91 @@ export default {
|
||||
return `业务发生时间:${composerRangeStartDate.value} 至 ${composerRangeEndDate.value}`
|
||||
}
|
||||
|
||||
function hasComposerBusinessTimeSelection() {
|
||||
return composerBusinessTimeTags.value.length > 0 || composerBusinessTimeDraftTouched.value
|
||||
}
|
||||
|
||||
function buildComposerBusinessTimeContext() {
|
||||
if (!hasComposerBusinessTimeSelection()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const mode = composerDateMode.value === 'range' ? 'range' : 'single'
|
||||
const startDate = String(mode === 'range' ? composerRangeStartDate.value : composerSingleDate.value).trim()
|
||||
const endDate = String(mode === 'range' ? composerRangeEndDate.value : startDate).trim()
|
||||
if (!isValidIsoDateString(startDate) || !isValidIsoDateString(endDate) || startDate > endDate) {
|
||||
return null
|
||||
}
|
||||
|
||||
const displayValue = mode === 'range' && startDate !== endDate
|
||||
? `${startDate} 至 ${endDate}`
|
||||
: startDate
|
||||
return {
|
||||
mode,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
occurred_date: startDate,
|
||||
time_range: displayValue,
|
||||
business_time: displayValue,
|
||||
time_range_raw: buildComposerBusinessTimeLabel()
|
||||
}
|
||||
}
|
||||
|
||||
function mergeBusinessTimeIntoExtraContext(extraContext, businessTimeContext) {
|
||||
if (!businessTimeContext) {
|
||||
return extraContext
|
||||
}
|
||||
|
||||
const baseReviewFormValues =
|
||||
extraContext.review_form_values && typeof extraContext.review_form_values === 'object'
|
||||
? extraContext.review_form_values
|
||||
: {}
|
||||
|
||||
return {
|
||||
...extraContext,
|
||||
occurred_date: businessTimeContext.occurred_date,
|
||||
business_time: businessTimeContext.business_time,
|
||||
business_time_context: {
|
||||
mode: businessTimeContext.mode,
|
||||
start_date: businessTimeContext.start_date,
|
||||
end_date: businessTimeContext.end_date,
|
||||
display_value: businessTimeContext.business_time
|
||||
},
|
||||
review_form_values: {
|
||||
...baseReviewFormValues,
|
||||
occurred_date: businessTimeContext.occurred_date,
|
||||
time_range: businessTimeContext.time_range,
|
||||
business_time: businessTimeContext.business_time,
|
||||
time_range_raw: businessTimeContext.time_range_raw
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function syncComposerBusinessTimeToReviewCard(businessTimeContext) {
|
||||
if (!businessTimeContext || !activeReviewPayload.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextInlineState = {
|
||||
...reviewInlineForm.value,
|
||||
occurred_date: businessTimeContext.occurred_date
|
||||
}
|
||||
const nextReviewPayload = buildLocallySyncedReviewPayload(activeReviewPayload.value, nextInlineState)
|
||||
reviewInlineForm.value = nextInlineState
|
||||
if (latestReviewMessage.value) {
|
||||
latestReviewMessage.value.reviewPayload = nextReviewPayload
|
||||
}
|
||||
if (currentInsight.value?.agent) {
|
||||
currentInsight.value = {
|
||||
...currentInsight.value,
|
||||
agent: {
|
||||
...currentInsight.value.agent,
|
||||
reviewPayload: nextReviewPayload
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveComposerSubmitText(explicitRawText) {
|
||||
const draftPart = String(explicitRawText ?? composerDraft.value).trim()
|
||||
const tagPart = composerBusinessTimeTags.value.map((item) => item.label).join(',')
|
||||
@@ -3956,8 +4090,16 @@ export default {
|
||||
composerDateMode.value = mode === 'range' ? 'range' : 'single'
|
||||
}
|
||||
|
||||
function handleComposerDateInputChange() {
|
||||
composerBusinessTimeDraftTouched.value = true
|
||||
syncComposerBusinessTimeToReviewCard(buildComposerBusinessTimeContext())
|
||||
}
|
||||
|
||||
function removeComposerBusinessTimeTag(tagId) {
|
||||
composerBusinessTimeTags.value = composerBusinessTimeTags.value.filter((item) => item.id !== tagId)
|
||||
if (!composerBusinessTimeTags.value.length) {
|
||||
composerBusinessTimeDraftTouched.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleComposerDatePickerOutside(event) {
|
||||
@@ -3975,12 +4117,14 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
composerBusinessTimeDraftTouched.value = true
|
||||
composerBusinessTimeTags.value = [
|
||||
{
|
||||
id: `biz-time-${Date.now()}`,
|
||||
label: buildComposerBusinessTimeLabel()
|
||||
}
|
||||
]
|
||||
syncComposerBusinessTimeToReviewCard(buildComposerBusinessTimeContext())
|
||||
composerDatePickerOpen.value = false
|
||||
await nextTick()
|
||||
adjustComposerTextareaHeight()
|
||||
@@ -4432,13 +4576,19 @@ export default {
|
||||
})
|
||||
}
|
||||
|
||||
function explainCurrentReviewRisk() {
|
||||
if (!activeReviewPayload.value || submitting.value || reviewActionBusy.value) return
|
||||
submitComposer({
|
||||
rawText: '请解释一下当前这笔报销的合规风险和待补充项。',
|
||||
userText: '查看全部风险项',
|
||||
systemGenerated: true
|
||||
})
|
||||
function openReviewRiskDetail(item) {
|
||||
if (!item) return
|
||||
reviewRiskDetailDialog.value = {
|
||||
open: true,
|
||||
item
|
||||
}
|
||||
}
|
||||
|
||||
function closeReviewRiskDetail() {
|
||||
reviewRiskDetailDialog.value = {
|
||||
...reviewRiskDetailDialog.value,
|
||||
open: false
|
||||
}
|
||||
}
|
||||
|
||||
function goReviewDocument(direction) {
|
||||
@@ -4642,9 +4792,13 @@ export default {
|
||||
}
|
||||
if (!rawText && !files.length) return
|
||||
|
||||
const extraContext = options.extraContext && typeof options.extraContext === 'object'
|
||||
const initialExtraContext = options.extraContext && typeof options.extraContext === 'object'
|
||||
? { ...options.extraContext }
|
||||
: {}
|
||||
const selectedBusinessTimeContext = isKnowledgeSession.value ? null : buildComposerBusinessTimeContext()
|
||||
const extraContext = isKnowledgeSession.value
|
||||
? initialExtraContext
|
||||
: mergeBusinessTimeIntoExtraContext(initialExtraContext, selectedBusinessTimeContext)
|
||||
const reviewAction = String(extraContext.review_action || '').trim()
|
||||
const reviewAttachmentNames = extractReviewAttachmentNames(activeReviewPayload.value)
|
||||
const hasExistingDocumentEvent =
|
||||
@@ -4699,6 +4853,7 @@ export default {
|
||||
|
||||
composerDraft.value = ''
|
||||
composerBusinessTimeTags.value = []
|
||||
composerBusinessTimeDraftTouched.value = false
|
||||
clearAttachedFiles()
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.value = ''
|
||||
@@ -4769,6 +4924,12 @@ export default {
|
||||
department_name: user.department || user.departmentName || '',
|
||||
position: user.position || '',
|
||||
grade: user.grade || '',
|
||||
employee_no: user.employeeNo || user.employee_no || '',
|
||||
manager_name: user.managerName || user.manager_name || '',
|
||||
employee_location: user.location || '',
|
||||
cost_center: user.costCenter || user.cost_center || '',
|
||||
finance_owner_name: user.financeOwnerName || user.finance_owner_name || '',
|
||||
employee_risk_profile: user.riskProfile && typeof user.riskProfile === 'object' ? user.riskProfile : {},
|
||||
...buildClientTimeContext(),
|
||||
session_type: activeSessionType.value,
|
||||
entry_source: props.entrySource,
|
||||
@@ -4802,16 +4963,6 @@ export default {
|
||||
? ''
|
||||
: String(payload?.result?.draft_payload?.claim_id || '').trim() || draftClaimId.value
|
||||
|
||||
const resolvedDraftClaimId = String(payload?.result?.draft_payload?.claim_id || draftClaimId.value || '').trim()
|
||||
if (!isKnowledgeSession.value && resolvedDraftClaimId && files.length) {
|
||||
try {
|
||||
await syncComposerFilesToDraft(resolvedDraftClaimId, files)
|
||||
} catch (error) {
|
||||
console.warn('Failed to persist composer attachments to draft claim:', error)
|
||||
toast(error?.message || '票据已识别,但附件持久化失败,请重试上传。')
|
||||
}
|
||||
}
|
||||
|
||||
replaceMessage(
|
||||
pendingMessage.id,
|
||||
createMessage('assistant', payload?.result?.answer || payload?.result?.message || '智能体已完成处理。', [], {
|
||||
@@ -4832,6 +4983,14 @@ export default {
|
||||
mergeFilePreviews(filePreviews, ocrFilePreviews)
|
||||
)
|
||||
completeFlowResult(payload, flowRunDetail)
|
||||
|
||||
const resolvedDraftClaimId = String(payload?.result?.draft_payload?.claim_id || draftClaimId.value || '').trim()
|
||||
if (!isKnowledgeSession.value && resolvedDraftClaimId && files.length) {
|
||||
syncComposerFilesToDraft(resolvedDraftClaimId, files).catch((error) => {
|
||||
console.warn('Failed to persist composer attachments to draft claim:', error)
|
||||
toast(error?.message || '票据已识别,但附件原件保存失败,请重试上传。')
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
clearFlowSimulationTimers()
|
||||
failCurrentFlowStep(error)
|
||||
@@ -5144,6 +5303,7 @@ export default {
|
||||
toggleComposerDatePicker,
|
||||
closeComposerDatePicker,
|
||||
setComposerDateMode,
|
||||
handleComposerDateInputChange,
|
||||
removeComposerBusinessTimeTag,
|
||||
flowSteps,
|
||||
flowRunId,
|
||||
@@ -5213,7 +5373,7 @@ export default {
|
||||
reviewRiskSummary,
|
||||
reviewRiskItems,
|
||||
reviewRiskEmpty,
|
||||
reviewRiskActionAvailable,
|
||||
reviewRiskDetailDialog,
|
||||
recognizedNarratives,
|
||||
reviewRecognitionNotes,
|
||||
reviewDocumentSummaries,
|
||||
@@ -5298,7 +5458,8 @@ export default {
|
||||
selectReviewCategory,
|
||||
selectReviewOtherCategory,
|
||||
queryDraftByClaimNo,
|
||||
explainCurrentReviewRisk,
|
||||
openReviewRiskDetail,
|
||||
closeReviewRiskDetail,
|
||||
goReviewDocument,
|
||||
openActiveReviewDocumentPreview,
|
||||
closeDocumentPreview,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
|
||||
import { useSystemState } from '../../composables/useSystemState.js'
|
||||
import { useToast } from '../../composables/useToast.js'
|
||||
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
||||
import {
|
||||
@@ -9,10 +10,12 @@ import {
|
||||
deleteExpenseClaim,
|
||||
fetchExpenseClaimItemAttachmentMeta,
|
||||
fetchExpenseClaimItemAttachmentPreview,
|
||||
returnExpenseClaim,
|
||||
submitExpenseClaim,
|
||||
uploadExpenseClaimItemAttachment,
|
||||
updateExpenseClaimItem
|
||||
} from '../../services/reimbursements.js'
|
||||
import { canManageExpenseClaims } from '../../utils/accessControl.js'
|
||||
import { normalizeRequestForUi } from '../../utils/requestViewModel.js'
|
||||
|
||||
const EXPENSE_TYPE_OPTIONS = [
|
||||
@@ -380,6 +383,7 @@ export default {
|
||||
emits: ['backToRequests', 'openAssistant', 'request-updated', 'request-deleted'],
|
||||
setup(props, { emit }) {
|
||||
const { toast } = useToast()
|
||||
const { currentUser } = useSystemState()
|
||||
const editingExpenseId = ref('')
|
||||
const savingExpenseId = ref('')
|
||||
const creatingExpense = ref(false)
|
||||
@@ -390,6 +394,8 @@ export default {
|
||||
const submitBusy = ref(false)
|
||||
const deleteBusy = ref(false)
|
||||
const deleteDialogOpen = ref(false)
|
||||
const returnBusy = ref(false)
|
||||
const returnDialogOpen = ref(false)
|
||||
const expenseUploadInput = ref(null)
|
||||
const expenseAttachmentMeta = reactive({})
|
||||
const attachmentPreviewOpen = ref(false)
|
||||
@@ -448,10 +454,25 @@ export default {
|
||||
|
||||
const isTravelRequest = computed(() => request.value.detailVariant === 'travel')
|
||||
const isDraftRequest = computed(() => request.value.approvalKey === 'draft')
|
||||
const canManageCurrentClaim = computed(() => canManageExpenseClaims(currentUser.value))
|
||||
const canDeleteRequest = computed(() => isDraftRequest.value || canManageCurrentClaim.value)
|
||||
const canReturnRequest = computed(() =>
|
||||
canManageCurrentClaim.value
|
||||
&& request.value.approvalKey === 'in_progress'
|
||||
&& Boolean(request.value.claimId)
|
||||
)
|
||||
const deleteActionLabel = computed(() => (isDraftRequest.value ? '删除草稿' : '删除单据'))
|
||||
const deleteDialogTitle = computed(() => `确认${deleteActionLabel.value} ${request.value.id} 吗?`)
|
||||
const deleteDialogDescription = computed(() =>
|
||||
isDraftRequest.value
|
||||
? '删除后该草稿及其当前费用明细将不可恢复,请确认本次操作。'
|
||||
: '删除后该报销单及费用明细将不可恢复,请确认本次操作。'
|
||||
)
|
||||
const actionBusy = computed(() =>
|
||||
Boolean(savingExpenseId.value)
|
||||
|| submitBusy.value
|
||||
|| deleteBusy.value
|
||||
|| returnBusy.value
|
||||
|| creatingExpense.value
|
||||
|| Boolean(uploadingExpenseId.value)
|
||||
|| Boolean(deletingAttachmentId.value)
|
||||
@@ -1105,9 +1126,14 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteDraft() {
|
||||
async function handleDeleteRequest() {
|
||||
if (!request.value.claimId) {
|
||||
toast('当前草稿缺少 claimId,暂时无法删除。')
|
||||
toast('当前单据缺少 claimId,暂时无法删除。')
|
||||
return
|
||||
}
|
||||
|
||||
if (!canDeleteRequest.value) {
|
||||
toast('当前单据已进入流程,只有财务人员或高级管理人员可以删除。')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1122,9 +1148,9 @@ export default {
|
||||
deleteDialogOpen.value = false
|
||||
}
|
||||
|
||||
async function confirmDeleteDraft() {
|
||||
async function confirmDeleteRequest() {
|
||||
if (!request.value.claimId) {
|
||||
toast('当前草稿缺少 claimId,暂时无法删除。')
|
||||
toast('当前单据缺少 claimId,暂时无法删除。')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1132,15 +1158,58 @@ export default {
|
||||
try {
|
||||
const payload = await deleteExpenseClaim(request.value.claimId)
|
||||
deleteDialogOpen.value = false
|
||||
toast(payload?.message || `${request.value.id} 草稿已删除。`)
|
||||
toast(payload?.message || `${request.value.id} 报销单已删除。`)
|
||||
emit('request-deleted', { claimId: request.value.claimId })
|
||||
} catch (error) {
|
||||
toast(error?.message || '删除草稿失败,请稍后重试。')
|
||||
toast(error?.message || '删除单据失败,请稍后重试。')
|
||||
} finally {
|
||||
deleteBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleReturnRequest() {
|
||||
if (!request.value.claimId) {
|
||||
toast('当前单据缺少 claimId,暂时无法退回。')
|
||||
return
|
||||
}
|
||||
|
||||
if (!canReturnRequest.value) {
|
||||
toast('当前状态不支持退回。')
|
||||
return
|
||||
}
|
||||
|
||||
returnDialogOpen.value = true
|
||||
}
|
||||
|
||||
function closeReturnDialog() {
|
||||
if (returnBusy.value) {
|
||||
return
|
||||
}
|
||||
|
||||
returnDialogOpen.value = false
|
||||
}
|
||||
|
||||
async function confirmReturnRequest() {
|
||||
if (!request.value.claimId) {
|
||||
toast('当前单据缺少 claimId,暂时无法退回。')
|
||||
return
|
||||
}
|
||||
|
||||
returnBusy.value = true
|
||||
try {
|
||||
await returnExpenseClaim(request.value.claimId, {
|
||||
reason: '详情页退回,请申请人补充后重新提交。'
|
||||
})
|
||||
returnDialogOpen.value = false
|
||||
toast(`${request.value.id} 已退回待补充。`)
|
||||
emit('request-updated', { claimId: request.value.claimId })
|
||||
} catch (error) {
|
||||
toast(error?.message || '退回单据失败,请稍后重试。')
|
||||
} finally {
|
||||
returnBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openAiEntry() {
|
||||
emit('openAssistant', {
|
||||
source: 'detail',
|
||||
@@ -1164,14 +1233,22 @@ export default {
|
||||
attachmentPreviewName,
|
||||
attachmentPreviewOpen,
|
||||
attachmentPreviewUrl,
|
||||
canDeleteRequest,
|
||||
canManageCurrentClaim,
|
||||
canReturnRequest,
|
||||
canSubmit,
|
||||
canPreviewAttachment,
|
||||
closeDeleteDialog,
|
||||
closeAttachmentPreview,
|
||||
confirmDeleteDraft,
|
||||
closeReturnDialog,
|
||||
confirmDeleteRequest,
|
||||
confirmReturnRequest,
|
||||
currentProgressRingMotion,
|
||||
deleteActionLabel,
|
||||
deleteBusy,
|
||||
deleteDialogDescription,
|
||||
deleteDialogOpen,
|
||||
deleteDialogTitle,
|
||||
deletingAttachmentId,
|
||||
deletingExpenseId,
|
||||
detailNote,
|
||||
@@ -1186,8 +1263,9 @@ export default {
|
||||
expenseUploadInput,
|
||||
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
|
||||
handleAddExpenseItem,
|
||||
handleDeleteDraft,
|
||||
handleDeleteRequest,
|
||||
handleExpenseFileChange,
|
||||
handleReturnRequest,
|
||||
handleSubmit,
|
||||
hasExpenseRiskColumn,
|
||||
heroFactItems,
|
||||
@@ -1205,6 +1283,8 @@ export default {
|
||||
resolveAttachmentRecognition,
|
||||
resolveExpenseRiskState,
|
||||
resolveExpenseIssues,
|
||||
returnBusy,
|
||||
returnDialogOpen,
|
||||
savingExpenseId,
|
||||
showExpenseRisk,
|
||||
startExpenseEdit,
|
||||
|
||||
Reference in New Issue
Block a user