feat: 新增归档中心页面并完善知识库与报销查询能力
新增前端归档中心视图及相关工具函数,扩充知识库文档分类和 提取器支持多种格式,增强编排器报销查询的多维度检索,优 化本体规则和用户代理审核消息,前端完善报销创建和审批详 情交互细节,补充单元测试覆盖。
This commit is contained in:
313
web/src/views/scripts/ArchiveCenterView.js
Normal file
313
web/src/views/scripts/ArchiveCenterView.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user