feat: 新增归档中心页面并完善知识库与报销查询能力

新增前端归档中心视图及相关工具函数,扩充知识库文档分类和
提取器支持多种格式,增强编排器报销查询的多维度检索,优
化本体规则和用户代理审核消息,前端完善报销创建和审批详
情交互细节,补充单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-22 16:00:19 +08:00
parent 1f15699013
commit 88ff04bef8
120 changed files with 6236 additions and 643 deletions

View File

@@ -0,0 +1,313 @@
import { computed, onBeforeUnmount, onMounted, 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 tabs = ['全部归档', '差旅报销', '招待报销', '其他费用']
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 resolveArchiveTypeTab(request) {
const expenseType = String(request?.typeCode || request?.expenseType || '').trim().toLowerCase()
if (expenseType === 'travel') {
return '差旅报销'
}
if (expenseType === 'entertainment') {
return '招待报销'
}
return '其他费用'
}
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 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),
node: normalized.workflowNode || '归档入账',
hasRisk,
riskCount,
risk: formatArchiveRiskCountLabel(riskCount),
riskTone,
status: '已归档',
statusTone: 'archived',
archiveTab: resolveArchiveTypeTab(normalized)
}
}
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('全部归档')
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 openFilterKey = ref('')
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 filterDropdowns = 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 = '全部归档'
activeRiskFilter.value = ARCHIVE_FILTER_ALL
activeTypeFilter.value = ARCHIVE_FILTER_ALL
activeDepartmentFilter.value = ARCHIVE_FILTER_ALL
activeArchiveMonthFilter.value = ARCHIVE_FILTER_ALL
listKeyword.value = ''
openFilterKey.value = ''
}
function handleEmptyAction() {
if (!rows.value.length) {
void reload()
return
}
resetListFilters()
}
function toggleFilterDropdown(key) {
openFilterKey.value = openFilterKey.value === key ? '' : key
}
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
}
openFilterKey.value = ''
}
function handleDocumentClick(event) {
const target = event.target
if (!(target instanceof Element)) {
return
}
if (!target.closest('.archive-dropdown-filter')) {
openFilterKey.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
}
}
onMounted(() => {
document.addEventListener('click', handleDocumentClick)
})
onBeforeUnmount(() => {
document.removeEventListener('click', handleDocumentClick)
})
void reload()
return {
activeTab,
archiveEmptyState,
closeSelectedDetail,
error,
filterDropdowns,
handleEmptyAction,
listKeyword,
loading,
openFilterKey,
reload,
resetListFilters,
rows,
selectFilterValue,
selectedRow,
showEmpty,
tabs,
toggleFilterDropdown,
visibleRows
}
}
}