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

@@ -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,