Files
X-Financial/web/src/views/scripts/ArchiveCenterView.js
caoxiaozhu 8a4a777be7 feat: 新增员工行为画像算法与费用风险标签体系
后端新增员工行为画像算法模块,支持标签规则引擎和评分计算,
完善员工模型、银行信息、序列化和导入逻辑,优化报销审批流
和工作流常量,增强 Hermes 同步和知识同步能力,前端新增费
用画像详情弹窗、雷达图和风险卡片组件,完善登录页和工作台
样式,优化文档中心和归档中心交互,补充单元测试。
2026-05-28 12:09:49 +08:00

285 lines
9.8 KiB
JavaScript

import { computed, ref } from 'vue'
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
import { mapExpenseClaimToRequest } from '../../composables/useRequests.js'
import { fetchArchivedExpenseClaims } from '../../services/reimbursements.js'
import {
ARCHIVE_FILTER_ALL,
applyArchiveListFilters,
buildArchiveMonthFilterOptions,
buildDepartmentFilterOptions,
buildTypeFilterOptions,
countClaimRisks,
extractArchiveMonth,
formatArchiveMonthLabel,
formatArchiveRiskCountLabel,
hasActiveArchiveListFilters,
resolveArchiveRiskTone
} from '../../utils/archiveCenterListFilters.js'
import { normalizeRequestForUi } from '../../utils/requestViewModel.js'
import TravelRequestDetailView from '../TravelRequestDetailView.vue'
const ARCHIVE_TAB_ALL = '全部归档'
const ARCHIVE_TAB_APPLICATION = '申请归档'
const ARCHIVE_TAB_REIMBURSEMENT = '报销归档'
const ARCHIVE_TYPE_APPLICATION = '申请'
const ARCHIVE_TYPE_APPLICATION_CODE = 'application'
const ARCHIVE_TYPE_REIMBURSEMENT = '报销'
const ARCHIVE_TYPE_REIMBURSEMENT_CODE = 'reimbursement'
const tabs = [ARCHIVE_TAB_ALL, ARCHIVE_TAB_APPLICATION, ARCHIVE_TAB_REIMBURSEMENT]
const RISK_FILTER_OPTIONS = [
{ value: ARCHIVE_FILTER_ALL, label: '全部风险' },
{ value: 'has', label: '有风险' },
{ value: 'none', label: '无风险' },
{ value: 'high', label: '高风险' },
{ value: 'medium', label: '中风险' },
{ value: 'low', label: '低风险' }
]
function formatCurrency(value) {
const amount = Number(value)
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY',
minimumFractionDigits: 0,
maximumFractionDigits: Number.isInteger(amount) ? 0 : 2
}).format(Number.isFinite(amount) ? amount : 0)
}
function buildArchiveRow(request) {
const normalized = normalizeRequestForUi(request)
const riskCount = countClaimRisks(normalized.riskFlags, normalized.riskSummary)
const riskTone = riskCount > 0 ? resolveArchiveRiskTone(normalized.riskFlags, normalized.riskSummary) : 'none'
const hasRisk = riskCount > 0
const isApplicationDocument = normalized.documentTypeCode === 'application'
const archiveMonth = extractArchiveMonth(
normalized.updatedAt,
normalized.submittedAt,
normalized.createdAt,
normalized.occurredAt,
normalized.applyTime
)
return {
...normalized,
applicant: normalized.person,
avatar: String(normalized.person || '?').trim().slice(0, 1) || '?',
department: normalized.dept,
type: normalized.typeLabel,
amount: formatCurrency(normalized.amountValue),
time: normalized.applyTime,
archivedAt: normalized.updatedAt || normalized.applyTime,
archiveMonth,
archiveMonthLabel: formatArchiveMonthLabel(archiveMonth),
archiveType: isApplicationDocument ? ARCHIVE_TYPE_APPLICATION : ARCHIVE_TYPE_REIMBURSEMENT,
archiveTypeCode: isApplicationDocument ? ARCHIVE_TYPE_APPLICATION_CODE : ARCHIVE_TYPE_REIMBURSEMENT_CODE,
node: isApplicationDocument ? '申请归档' : (normalized.workflowNode || '已付款'),
hasRisk,
riskCount,
risk: formatArchiveRiskCountLabel(riskCount),
riskTone,
status: '已归档',
statusTone: 'archived',
archiveTab: isApplicationDocument ? ARCHIVE_TAB_APPLICATION : ARCHIVE_TAB_REIMBURSEMENT
}
}
function resolveFilterLabel(options, activeValue, fallbackLabel) {
return options.find((item) => item.value === activeValue)?.label || fallbackLabel
}
export default {
name: 'ArchiveCenterView',
components: {
TravelRequestDetailView,
TableLoadingState,
TableEmptyState
},
setup() {
const activeTab = ref(ARCHIVE_TAB_ALL)
const activeRiskFilter = ref(ARCHIVE_FILTER_ALL)
const activeTypeFilter = ref(ARCHIVE_FILTER_ALL)
const activeDepartmentFilter = ref(ARCHIVE_FILTER_ALL)
const activeArchiveMonthFilter = ref(ARCHIVE_FILTER_ALL)
const selectedClaimId = ref('')
const listKeyword = ref('')
const rows = ref([])
const loading = ref(false)
const error = ref('')
const typeFilterOptions = computed(() => buildTypeFilterOptions(rows.value))
const departmentFilterOptions = computed(() => buildDepartmentFilterOptions(rows.value))
const archiveMonthFilterOptions = computed(() => buildArchiveMonthFilterOptions(rows.value))
const riskFilterLabel = computed(() => resolveFilterLabel(RISK_FILTER_OPTIONS, activeRiskFilter.value, '全部风险'))
const typeFilterLabel = computed(() => resolveFilterLabel(typeFilterOptions.value, activeTypeFilter.value, '归档类型'))
const departmentFilterLabel = computed(() => resolveFilterLabel(departmentFilterOptions.value, activeDepartmentFilter.value, '所属部门'))
const archiveMonthFilterLabel = computed(() => resolveFilterLabel(archiveMonthFilterOptions.value, activeArchiveMonthFilter.value, '归档月份'))
const filterMenus = computed(() => [
{
key: 'risk',
label: riskFilterLabel.value,
options: RISK_FILTER_OPTIONS,
activeValue: activeRiskFilter.value
},
{
key: 'type',
label: typeFilterLabel.value,
options: typeFilterOptions.value,
activeValue: activeTypeFilter.value
},
{
key: 'department',
label: departmentFilterLabel.value,
options: departmentFilterOptions.value,
activeValue: activeDepartmentFilter.value
},
{
key: 'archiveMonth',
label: archiveMonthFilterLabel.value,
options: archiveMonthFilterOptions.value,
activeValue: activeArchiveMonthFilter.value
}
])
const selectedRow = computed({
get() {
return rows.value.find((row) => row.claimId === selectedClaimId.value) || null
},
set(value) {
selectedClaimId.value = value?.claimId || ''
}
})
const visibleRows = computed(() => applyArchiveListFilters(rows.value, {
tab: activeTab.value,
risk: activeRiskFilter.value,
type: activeTypeFilter.value,
department: activeDepartmentFilter.value,
archiveMonth: activeArchiveMonthFilter.value,
keyword: listKeyword.value
}))
const showEmpty = computed(() => !loading.value && !error.value && visibleRows.value.length === 0)
const archiveEmptyState = computed(() => {
if (!rows.value.length) {
return {
eyebrow: '归档中心',
title: '当前还没有已归档单据',
desc: '财务确认付款后进入「已付款」状态的报销单会自动汇总到这里,形成公司级财务归档库。',
icon: 'mdi mdi-archive-check-outline',
actionLabel: null,
actionIcon: null,
tone: 'slate',
artLabel: 'ARCHIVE',
tips: ['仅展示已付款或已归档的单据', '申请人仍可在报销中心查看自己的归档记录']
}
}
const filtersActive = hasActiveArchiveListFilters({
tab: activeTab.value,
risk: activeRiskFilter.value,
type: activeTypeFilter.value,
department: activeDepartmentFilter.value,
archiveMonth: activeArchiveMonthFilter.value,
keyword: listKeyword.value
})
return {
eyebrow: filtersActive ? '筛选结果为空' : '归档中心',
title: filtersActive ? '没有符合当前筛选条件的归档单据' : `${activeTab.value}”里暂时没有归档单据`,
desc: filtersActive
? '可以调整风险、归档类型、部门或归档月份筛选,也可以修改搜索关键词后重试。'
: '可以切换到其他归档分类查看,或调整筛选条件后重新检索。',
icon: 'mdi mdi-archive-outline',
actionLabel: null,
actionIcon: null,
tone: 'sky',
artLabel: filtersActive ? 'FILTER' : 'ARCHIVE',
tips: ['归档中心保存全公司归档数据', '非申请人无法在报销中心查看他人归档单']
}
})
function resetListFilters() {
activeTab.value = ARCHIVE_TAB_ALL
activeRiskFilter.value = ARCHIVE_FILTER_ALL
activeTypeFilter.value = ARCHIVE_FILTER_ALL
activeDepartmentFilter.value = ARCHIVE_FILTER_ALL
activeArchiveMonthFilter.value = ARCHIVE_FILTER_ALL
listKeyword.value = ''
}
function handleEmptyAction() {
if (!rows.value.length) {
void reload()
return
}
resetListFilters()
}
function selectFilterValue(key, value) {
if (key === 'risk') {
activeRiskFilter.value = value
} else if (key === 'type') {
activeTypeFilter.value = value
} else if (key === 'department') {
activeDepartmentFilter.value = value
} else if (key === 'archiveMonth') {
activeArchiveMonthFilter.value = value
}
}
function closeSelectedDetail() {
selectedClaimId.value = ''
}
async function reload() {
loading.value = true
error.value = ''
try {
const payload = await fetchArchivedExpenseClaims()
const mappedRows = (Array.isArray(payload) ? payload : [])
.map((item) => mapExpenseClaimToRequest(item))
.filter(Boolean)
.map((item) => buildArchiveRow(item))
rows.value = mappedRows
if (!mappedRows.some((item) => item.claimId === selectedClaimId.value)) {
selectedClaimId.value = ''
}
} catch (nextError) {
rows.value = []
selectedClaimId.value = ''
error.value = nextError instanceof Error ? nextError.message : '归档中心加载失败。'
} finally {
loading.value = false
}
}
void reload()
return {
activeTab,
archiveEmptyState,
closeSelectedDetail,
error,
filterMenus,
handleEmptyAction,
listKeyword,
loading,
reload,
resetListFilters,
rows,
selectFilterValue,
selectedRow,
showEmpty,
tabs,
visibleRows
}
}
}