From 151787ada2c8a6a5abca91dc8db4c4e49361c06b Mon Sep 17 00:00:00 2001 From: caoxiaozhu Date: Wed, 13 May 2026 06:52:30 +0000 Subject: [PATCH] refactor(web): update view scripts - AuditView.js: update audit view logic - EmployeeManagementView.js: update employee management logic - RequestsView.js: update requests view logic - TravelRequestDetailView.js: update travel detail view logic --- web/src/views/scripts/AuditView.js | 48 +- .../views/scripts/EmployeeManagementView.js | 50 +- web/src/views/scripts/RequestsView.js | 63 +- .../views/scripts/TravelRequestDetailView.js | 742 ++++++++++++++++-- 4 files changed, 825 insertions(+), 78 deletions(-) diff --git a/web/src/views/scripts/AuditView.js b/web/src/views/scripts/AuditView.js index 0481b7c..e8450fa 100644 --- a/web/src/views/scripts/AuditView.js +++ b/web/src/views/scripts/AuditView.js @@ -1,6 +1,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import ConfirmDialog from '../../components/shared/ConfirmDialog.vue' +import TableEmptyState from '../../components/shared/TableEmptyState.vue' import { useSystemState } from '../../composables/useSystemState.js' import { useToast } from '../../composables/useToast.js' import { @@ -849,7 +850,8 @@ function buildReviewNote(status) { export default { name: 'AuditView', components: { - ConfirmDialog + ConfirmDialog, + TableEmptyState }, emits: ['detail-open-change'], setup(_, { emit }) { @@ -946,6 +948,39 @@ export default { return tokens }) + const auditEmptyState = computed(() => { + const hasFilters = activeFilterTokens.value.length > 0 + + if (!currentAssets.value.length) { + return { + eyebrow: `${activeTabLabel.value}资产`, + title: `${activeTabLabel.value}列表暂时还是空的`, + desc: `当前环境里还没有可展示的${activeTabLabel.value}资产。完成接入或同步后,会统一展示在这里。`, + icon: 'mdi mdi-database-search-outline', + actionLabel: '重新加载', + actionIcon: 'mdi mdi-refresh', + tone: 'amber', + artLabel: 'ASSET', + tips: ['切换页签可查看其他资产类型', '支持按业务域、负责人和状态做过滤'] + } + } + + return { + eyebrow: '筛选结果为空', + title: `没有找到匹配的${activeTabLabel.value}`, + desc: hasFilters + ? '试试清空业务域、负责人、状态或关键词筛选,再重新查看。' + : `当前列表中还没有满足展示条件的${activeTabLabel.value}资产。`, + icon: hasFilters ? 'mdi mdi-tune-variant' : 'mdi mdi-view-grid-outline', + actionLabel: hasFilters ? '清空筛选' : '重新加载', + actionIcon: hasFilters ? 'mdi mdi-filter-remove-outline' : 'mdi mdi-refresh', + tone: hasFilters ? 'emerald' : 'slate', + artLabel: hasFilters ? 'FILTER' : 'QUEUE', + tips: hasFilters + ? ['业务域、负责人、状态与关键词会叠加过滤', '可以换个编码、名称或负责人关键词继续搜索'] + : ['列表展示来自真实资产 API', '切换资产类型后会自动重新拉取数据'] + } + }) const canActivateSelected = computed(() => { if (!selectedSkillIsRule.value || !canManageSelected.value || detailBusy.value) { return false @@ -1010,6 +1045,15 @@ export default { activeFilterPopover.value = '' } + function handleAuditEmptyAction() { + if (!currentAssets.value.length || !activeFilterTokens.value.length) { + loadAssets({ force: true }).catch(() => {}) + return + } + + resetFilters() + } + function toggleFilterPopover(name) { activeFilterPopover.value = activeFilterPopover.value === name ? '' : name } @@ -1261,6 +1305,7 @@ export default { tableColumns, showMetricColumn, visibleSkills, + auditEmptyState, loading, errorMessage, detailLoading, @@ -1287,6 +1332,7 @@ export default { openAssetDetail, closeDetail, resetFilters, + handleAuditEmptyAction, toggleFilterPopover, selectFilter, closeFilterPopover, diff --git a/web/src/views/scripts/EmployeeManagementView.js b/web/src/views/scripts/EmployeeManagementView.js index 369b09d..ffe21a0 100644 --- a/web/src/views/scripts/EmployeeManagementView.js +++ b/web/src/views/scripts/EmployeeManagementView.js @@ -1,6 +1,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import ConfirmDialog from '../../components/shared/ConfirmDialog.vue' +import TableEmptyState from '../../components/shared/TableEmptyState.vue' import { useToast } from '../../composables/useToast.js' import { disableEmployee, fetchEmployeeMeta, fetchEmployees, updateEmployee } from '../../services/employees.js' @@ -201,7 +202,8 @@ function buildEmployeeSummary(employees) { export default { name: 'EmployeeManagementView', components: { - ConfirmDialog + ConfirmDialog, + TableEmptyState }, emits: ['overview-change'], setup(_, { emit }) { @@ -311,6 +313,40 @@ export default { }) const hasActiveFilters = computed(() => activeFilterTokens.value.length > 0) + const hasEmployeeFilters = computed(() => { + return activeTab.value !== DEFAULT_STATUS_TABS[0] || hasActiveFilters.value + }) + const employeeEmptyState = computed(() => { + if (!employees.value.length) { + return { + eyebrow: '员工台账', + title: '员工目录暂时还是空的', + desc: '当前环境还没有同步任何员工档案。完成目录接入后,这里会展示员工基础信息、角色和状态。', + icon: 'mdi mdi-account-group-outline', + actionLabel: '重新加载', + actionIcon: 'mdi mdi-refresh', + tone: 'sky', + artLabel: 'PEOPLE', + tips: ['支持按部门、职级和角色统一维护', '点击列表行即可进入档案和权限详情'] + } + } + + return { + eyebrow: hasEmployeeFilters.value ? '筛选结果为空' : '员工状态为空', + title: hasEmployeeFilters.value ? '当前条件下没有匹配员工' : `“${activeTab.value}”里暂时没有员工`, + desc: hasEmployeeFilters.value + ? '可以切回“全部员工”,或者清空关键词、部门、职级和角色条件后再试。' + : '这个状态标签下目前还没有记录,你可以切换到其他状态继续查看。', + icon: hasEmployeeFilters.value ? 'mdi mdi-account-search-outline' : 'mdi mdi-badge-account-horizontal-outline', + actionLabel: hasEmployeeFilters.value ? '清空筛选' : '查看全部员工', + actionIcon: hasEmployeeFilters.value ? 'mdi mdi-filter-remove-outline' : 'mdi mdi-format-list-bulleted', + tone: hasEmployeeFilters.value ? 'emerald' : 'slate', + artLabel: hasEmployeeFilters.value ? 'FILTER' : 'STATUS', + tips: hasEmployeeFilters.value + ? ['关键词、部门、职级和角色条件会叠加生效', '也可以直接搜索姓名、工号或岗位'] + : ['员工状态统计会按真实目录数据自动更新', '停用员工仍会保留在台账中便于追溯'] + } + }) watch( employeeSummary, @@ -343,6 +379,15 @@ export default { pageSizeOpen.value = false } + function handleEmployeeEmptyAction() { + if (!employees.value.length) { + loadEmployees().catch(() => {}) + return + } + + resetFilters() + } + function changePageSize(size) { pageSize.value = size pageSizeOpen.value = false @@ -641,6 +686,7 @@ export default { roleOptions, employees, visibleEmployees, + employeeEmptyState, searchKeyword, selectedDepartment, selectedGrade, @@ -655,9 +701,11 @@ export default { roleFilterOptions, activeFilterTokens, hasActiveFilters, + hasEmployeeFilters, totalCount, totalPages, resetFilters, + handleEmployeeEmptyAction, openEmployeeDetail, closeEmployeeDetail, closeDisableDialog, diff --git a/web/src/views/scripts/RequestsView.js b/web/src/views/scripts/RequestsView.js index 64092ac..11a0e22 100644 --- a/web/src/views/scripts/RequestsView.js +++ b/web/src/views/scripts/RequestsView.js @@ -1,5 +1,6 @@ import { computed, ref, watch } from 'vue' +import TableEmptyState from '../../components/shared/TableEmptyState.vue' import { normalizeRequestForUi } from '../../utils/requestViewModel.js' function extractRowDate(value) { @@ -9,6 +10,9 @@ function extractRowDate(value) { export default { name: 'RequestsView', + components: { + TableEmptyState + }, props: { filteredRequests: { type: Array, required: true }, hasData: { type: Boolean, default: false }, @@ -108,20 +112,67 @@ export default { }) const showTable = computed(() => !props.loading && !props.error && visibleRows.value.length > 0) const showEmpty = computed(() => !props.loading && !props.error && visibleRows.value.length === 0) + const hasListFilters = computed(() => { + return Boolean( + activeTab.value !== '全部' + || listKeyword.value.trim() + || appliedStart.value + || appliedEnd.value + ) + }) const emptyState = computed(() => { if (!props.hasData) { return { - title: '暂无真实报销单据', - desc: '数据库里还没有可见的个人报销数据。保存草稿或提交报销后,会显示在这里。' + eyebrow: '个人报销', + title: '还没有任何报销单据', + desc: '首张草稿或已提交的报销单会自动出现在这里,后续可以继续补充、提交和跟踪进度。', + icon: 'mdi mdi-receipt-text-plus-outline', + actionLabel: '发起报销', + actionIcon: 'mdi mdi-plus-circle-outline', + tone: 'emerald', + artLabel: 'CLAIM', + tips: ['保存草稿后会自动回到这里', '支持草稿、待提交、审批中和已完成全流程管理'] } } return { - title: '没有匹配结果', - desc: '当前筛选条件下没有可展示的报销单据。' + eyebrow: hasListFilters.value ? '筛选结果为空' : '状态列表为空', + title: hasListFilters.value ? '当前条件下没有匹配单据' : `“${activeTab.value}”里暂时没有单据`, + desc: hasListFilters.value + ? '可以清空关键词、时间段或状态筛选后再看看。' + : '当前状态下还没有可展示的报销记录,可以先发起一笔报销或切换到其他状态。', + icon: hasListFilters.value ? 'mdi mdi-magnify-scan' : 'mdi mdi-clipboard-text-clock-outline', + actionLabel: hasListFilters.value ? '清空筛选' : '', + actionIcon: hasListFilters.value ? 'mdi mdi-filter-remove-outline' : '', + tone: hasListFilters.value ? 'sky' : 'slate', + artLabel: hasListFilters.value ? 'FILTER' : 'QUEUE', + tips: hasListFilters.value + ? ['关键词、时间段和状态会叠加生效', '可尝试搜索单号、事由或报销类型'] + : ['已完成单据会保留在列表中便于追踪', '草稿、审批中和待补充会按真实状态实时归类'] } }) + function resetFilters() { + activeTab.value = '全部' + listKeyword.value = '' + datePopover.value = false + rangeStart.value = '' + rangeEnd.value = '' + appliedStart.value = '' + appliedEnd.value = '' + pageSizeOpen.value = false + currentPage.value = 1 + } + + function handleEmptyAction() { + if (!props.hasData) { + emit('create-request') + return + } + + resetFilters() + } + watch([activeTab, rows, listKeyword, appliedStart, appliedEnd], () => { currentPage.value = 1 }) @@ -151,7 +202,9 @@ export default { visibleRows, showTable, showEmpty, - emptyState + emptyState, + resetFilters, + handleEmptyAction } } } diff --git a/web/src/views/scripts/TravelRequestDetailView.js b/web/src/views/scripts/TravelRequestDetailView.js index 80579b0..543b868 100644 --- a/web/src/views/scripts/TravelRequestDetailView.js +++ b/web/src/views/scripts/TravelRequestDetailView.js @@ -1,8 +1,18 @@ -import { computed, reactive, ref, watch } from 'vue' +import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue' import { useToast } from '../../composables/useToast.js' import ConfirmDialog from '../../components/shared/ConfirmDialog.vue' -import { deleteExpenseClaim, submitExpenseClaim, updateExpenseClaimItem } from '../../services/reimbursements.js' +import { + createExpenseClaimItem, + deleteExpenseClaimItem, + deleteExpenseClaimItemAttachment, + deleteExpenseClaim, + fetchExpenseClaimItemAttachment, + fetchExpenseClaimItemAttachmentMeta, + submitExpenseClaim, + uploadExpenseClaimItemAttachment, + updateExpenseClaimItem +} from '../../services/reimbursements.js' import { normalizeRequestForUi } from '../../utils/requestViewModel.js' const EXPENSE_TYPE_OPTIONS = [ @@ -17,6 +27,15 @@ const EXPENSE_TYPE_OPTIONS = [ { value: 'other', label: '其他费用' } ] +const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([ + 'travel', + 'hotel', + 'transport', + 'meal', + 'meeting', + 'entertainment' +]) + function parseCurrency(value) { return Number.parseFloat(String(value).replace(/[^\d.]/g, '')) || 0 } @@ -30,6 +49,34 @@ function formatCurrency(value) { }).format(value) } +function normalizeExpenseType(value) { + return String(value || '').trim() || 'other' +} + +function resolveExpenseTypeLabel(value) { + return EXPENSE_TYPE_OPTIONS.find((option) => option.value === normalizeExpenseType(value))?.label || '其他费用' +} + +function isLocationRequiredExpenseType(value) { + return LOCATION_REQUIRED_EXPENSE_TYPES.has(normalizeExpenseType(value)) +} + +function resolveLocationInputPlaceholder(value) { + return isLocationRequiredExpenseType(value) ? '输入业务地点' : '输入采购/收货地点(可选)' +} + +function resolveLocationSummaryLabel(value) { + return isLocationRequiredExpenseType(value) ? '业务地点' : '采购/收货地点' +} + +function resolveLocationDisplay(value, expenseType) { + if (!isLocationRequiredExpenseType(expenseType) && isPlaceholderValue(value)) { + return '非必填' + } + + return isPlaceholderValue(value) ? '待补充' : value +} + function buildFallbackProgressSteps() { return [ { index: 1, label: '保存草稿', time: '已完成', done: true, active: true }, @@ -43,7 +90,7 @@ function buildFallbackProgressSteps() { function buildFallbackExpenseItems(request) { return [ - { + buildExpenseItemViewModel({ id: 'fallback-1', itemDate: '', itemType: request.typeCode || 'other', @@ -56,7 +103,7 @@ function buildFallbackExpenseItems(request) { name: request.typeLabel, category: request.typeLabel, desc: request.reason, - detail: request.sceneTarget, + detail: resolveLocationDisplay(request.sceneTarget, request.typeCode), amount: request.amountDisplay, status: '待补充', tone: 'bad', @@ -67,7 +114,7 @@ function buildFallbackExpenseItems(request) { riskLabel: '待补材料', riskText: request.riskSummary, riskTone: 'medium' - } + }, 0, request) ] } @@ -81,16 +128,110 @@ function isPlaceholderValue(value) { } function isValidIsoDate(value) { - if (!/^\d{4}-\d{2}-\d{2}$/.test(String(value || '').trim())) { + const normalized = String(value || '').trim() + if (!/^\d{4}-\d{2}-\d{2}$/.test(normalized)) { return false } - const nextDate = new Date(`${value}T00:00:00`) - return !Number.isNaN(nextDate.getTime()) && nextDate.toISOString().slice(0, 10) === value + const [yearText, monthText, dayText] = normalized.split('-') + const year = Number(yearText) + const month = Number(monthText) + const day = Number(dayText) + + if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) { + return false + } + + const candidate = new Date(Date.UTC(year, month - 1, day)) + return ( + candidate.getUTCFullYear() === year && + candidate.getUTCMonth() === month - 1 && + candidate.getUTCDate() === day + ) +} + +function normalizeIsoDateValue(value) { + const normalized = String(value || '').trim() + if (isValidIsoDate(normalized)) { + return normalized + } + + const match = normalized.match(/^(\d{4}-\d{2}-\d{2})/) + if (match && isValidIsoDate(match[1])) { + return match[1] + } + + const candidate = value instanceof Date ? value : new Date(normalized) + if (Number.isNaN(candidate.getTime())) { + return '' + } + + const year = candidate.getFullYear() + const month = String(candidate.getMonth() + 1).padStart(2, '0') + const day = String(candidate.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +function resolveExpenseUploadHint(value) { + const normalized = String(value || '').trim() + return normalized || '支持上传 JPG、PNG、PDF,未上传也可先保存草稿' +} + +function extractAttachmentDisplayName(value) { + const normalized = String(value || '').trim() + if (!normalized) { + return '' + } + + return normalized.split('/').filter(Boolean).pop() || normalized +} + +function buildExpenseItemViewModel(source, index, requestModel) { + const itemType = normalizeExpenseType(source?.itemType || source?.item_type || requestModel?.typeCode || 'other') + const itemReason = String(source?.itemReason ?? source?.item_reason ?? '').trim() + const itemLocation = String(source?.itemLocation ?? source?.item_location ?? '').trim() + const itemDate = normalizeIsoDateValue(source?.itemDate ?? source?.item_date) + const itemAmount = parseCurrency(source?.itemAmount ?? source?.item_amount) + const invoiceId = String(source?.invoiceId ?? source?.invoice_id ?? '').trim() + const attachmentName = String(source?.attachmentName || source?.attachment_name || extractAttachmentDisplayName(invoiceId)).trim() + const attachments = invoiceId ? [attachmentName || invoiceId] : [] + const amountDisplay = itemAmount > 0 ? formatCurrency(itemAmount) : '待补充' + const riskText = String(source?.riskText || '').trim() + + return { + id: String(source?.id || `${requestModel?.claimId || requestModel?.id || 'claim'}-item-${index}`), + itemDate, + itemType, + itemReason, + itemLocation, + itemAmount, + invoiceId, + time: itemDate || '待补充', + dayLabel: requestModel?.detailVariant === 'travel' ? `第 ${index + 1} 项` : '业务发生项', + name: resolveExpenseTypeLabel(itemType), + category: resolveExpenseTypeLabel(itemType), + desc: itemReason || '待补充', + detail: resolveLocationDisplay(itemLocation, itemType), + amount: amountDisplay, + status: attachments.length ? '已识别' : '待补充', + tone: attachments.length ? 'ok' : 'bad', + attachmentStatus: attachments.length ? `${attachments.length} 份附件` : '未上传', + attachmentHint: attachments.length ? attachmentName || attachments[0] : resolveExpenseUploadHint(), + attachmentTone: attachments.length ? 'ok' : 'missing', + attachments, + riskLabel: String(source?.riskLabel || '').trim() || '无', + riskText, + riskTone: String(source?.riskTone || '').trim() || 'low' + } +} + +function rebuildExpenseItems(items, requestModel) { + return items.map((item, index) => buildExpenseItemViewModel(item, index, requestModel)) } function buildExpenseDraftIssues(item) { const issues = [] + const locationRequired = isLocationRequiredExpenseType(item.itemType) if (!isValidIsoDate(item.itemDate)) { issues.push('缺少日期') @@ -101,7 +242,7 @@ function buildExpenseDraftIssues(item) { if (isPlaceholderValue(item.itemReason)) { issues.push('缺少说明') } - if (isPlaceholderValue(item.itemLocation)) { + if (locationRequired && isPlaceholderValue(item.itemLocation)) { issues.push('缺少地点') } if (!Number.isFinite(Number(item.itemAmount)) || Number(item.itemAmount) <= 0) { @@ -116,6 +257,7 @@ function buildExpenseDraftIssues(item) { function buildDraftBlockingIssues(request, expenseItems) { const issues = [] + const locationRequired = isLocationRequiredExpenseType(request.typeCode) if (isPlaceholderValue(request.profileName)) { issues.push('申请人未完善') @@ -129,7 +271,7 @@ function buildDraftBlockingIssues(request, expenseItems) { if (isPlaceholderValue(request.reason)) { issues.push('报销事由未完善') } - if (isPlaceholderValue(request.location)) { + if (locationRequired && isPlaceholderValue(request.location)) { issues.push('业务地点未完善') } if (isPlaceholderValue(request.occurredDisplay)) { @@ -151,6 +293,66 @@ function buildDraftBlockingIssues(request, expenseItems) { return [...new Set(issues)] } +function mapIssueToAdvice(issue) { + const text = String(issue || '').trim() + if (!text) { + return '' + } + + if (text === '费用明细不能为空') { + return '先新增至少 1 条费用明细,再补充金额、用途和附件。' + } + if (text === '申请人未完善') { + return '补充申请人信息,确保审批单据归属明确。' + } + if (text === '所属部门未完善') { + return '补充所属部门,便于财务和审批人识别成本归属。' + } + if (text === '报销类型未完善') { + return '选择报销类型,明确本次费用归类。' + } + if (text === '报销事由未完善') { + return '补充报销事由,说明本次费用用途。' + } + if (text === '业务地点未完善') { + return '补充业务地点,方便审核业务发生场景。' + } + if (text === '发生时间未完善') { + return '补充费用发生时间,确保单据时间完整。' + } + if (text === '报销金额未完善') { + return '补充报销金额,并与费用明细金额保持一致。' + } + + const itemMatch = text.match(/^费用明细第\s*(\d+)\s*条(.+)$/) + if (!itemMatch) { + return text + } + + const [, indexText, fieldText] = itemMatch + const labelPrefix = `完善第 ${indexText} 条费用明细` + if (fieldText === '缺少日期') { + return `${labelPrefix}的发生日期。` + } + if (fieldText === '缺少费用项目') { + return `${labelPrefix}的费用项目。` + } + if (fieldText === '缺少说明') { + return `${labelPrefix}的用途说明。` + } + if (fieldText === '缺少地点') { + return `${labelPrefix}的业务地点。` + } + if (fieldText === '缺少金额') { + return `${labelPrefix}的金额。` + } + if (fieldText === '缺少票据标识') { + return `为第 ${indexText} 条费用明细上传或关联票据附件。` + } + + return `${labelPrefix}。` +} + export default { name: 'TravelRequestDetailView', components: { @@ -165,12 +367,24 @@ export default { emits: ['backToRequests', 'openAssistant', 'request-updated', 'request-deleted'], setup(props, { emit }) { const { toast } = useToast() - const expandedExpenseId = ref(null) const editingExpenseId = ref('') const savingExpenseId = ref('') + const creatingExpense = ref(false) + const uploadingExpenseId = ref('') + const deletingAttachmentId = ref('') + const deletingExpenseId = ref('') + const pendingUploadExpenseId = ref('') const submitBusy = ref(false) const deleteBusy = ref(false) const deleteDialogOpen = ref(false) + const expenseUploadInput = ref(null) + const expenseAttachmentMeta = reactive({}) + const attachmentPreviewOpen = ref(false) + const attachmentPreviewLoading = ref(false) + const attachmentPreviewError = ref('') + const attachmentPreviewUrl = ref('') + const attachmentPreviewName = ref('') + const attachmentPreviewMediaType = ref('') const expenseEditor = reactive({ itemDate: '', itemType: 'other', @@ -217,7 +431,15 @@ export default { const isTravelRequest = computed(() => request.value.detailVariant === 'travel') const isDraftRequest = computed(() => request.value.approvalKey === 'draft') - const actionBusy = computed(() => Boolean(savingExpenseId.value) || submitBusy.value || deleteBusy.value) + const actionBusy = computed(() => + Boolean(savingExpenseId.value) + || submitBusy.value + || deleteBusy.value + || creatingExpense.value + || Boolean(uploadingExpenseId.value) + || Boolean(deletingAttachmentId.value) + || Boolean(deletingExpenseId.value) + ) const profile = computed(() => ({ name: request.value.profileName, @@ -229,22 +451,32 @@ export default { watch( request, - (nextRequest) => { + (nextRequest, previousRequest) => { expenseItems.value = - Array.isArray(nextRequest.expenseItems) && nextRequest.expenseItems.length - ? nextRequest.expenseItems + Array.isArray(nextRequest.expenseItems) + ? rebuildExpenseItems(nextRequest.expenseItems, nextRequest) : buildFallbackExpenseItems(nextRequest) - expandedExpenseId.value = null + if (nextRequest.claimId !== previousRequest?.claimId) { + Object.keys(expenseAttachmentMeta).forEach((key) => { + delete expenseAttachmentMeta[key] + }) + closeAttachmentPreview() + } + pendingUploadExpenseId.value = '' + uploadingExpenseId.value = '' + deletingExpenseId.value = '' editingExpenseId.value = '' + void syncExpenseAttachmentMeta() }, { immediate: true } ) const heroStats = computed(() => [ { - label: '金额', + label: '报销金额', value: request.value.amountDisplay, - kind: 'text' + kind: 'text', + emphasis: true }, { label: '当前节点', @@ -259,40 +491,15 @@ export default { kind: 'pill', className: 'approval-pill', tone: request.value.approvalTone - }, - { - label: request.value.secondaryStatusLabel, - value: request.value.secondaryStatusValue, - kind: 'pill', - className: 'risk-pill', - tone: request.value.secondaryStatusTone } ]) const heroSummaryItems = computed(() => { - const commonItems = [ - { label: '单号', value: request.value.id, icon: 'mdi mdi-pound-box-outline' }, - { label: '报销类型', value: request.value.typeLabel, icon: 'mdi mdi-tag-multiple' } - ] - - if (isTravelRequest.value) { - return [ - ...commonItems, - { label: '出行路线', value: request.value.sceneTarget, icon: 'mdi mdi-map-marker-path' }, - { label: '出差区间', value: request.value.occurredDisplay, icon: 'mdi mdi-clock-outline' }, - { label: '关联客户', value: request.value.relatedCustomer, icon: 'mdi mdi-domain' }, - { label: '票据关联', value: request.value.attachmentSummary, icon: 'mdi mdi-file-document-multiple-outline' }, - { label: '出差事由', value: request.value.reason, icon: 'mdi mdi-briefcase-outline' } - ] - } - return [ - ...commonItems, - { label: '业务地点', value: request.value.sceneTarget, icon: 'mdi mdi-map-marker-outline' }, + { label: '单号', value: request.value.id, icon: 'mdi mdi-pound-box-outline' }, { label: '发生时间', value: request.value.occurredDisplay, icon: 'mdi mdi-calendar-month-outline' }, - { label: '关联客户', value: request.value.relatedCustomer, icon: 'mdi mdi-domain' }, - { label: '票据关联', value: request.value.attachmentSummary, icon: 'mdi mdi-paperclip' }, - { label: '风险提示', value: request.value.riskSummary, icon: 'mdi mdi-shield-alert-outline' } + { label: '费用明细', value: `${expenseItems.value.length} 条`, icon: 'mdi mdi-format-list-bulleted-square' }, + { label: '申请时间', value: request.value.applyTime, icon: 'mdi mdi-timer-sand' } ] }) @@ -327,39 +534,198 @@ export default { }) const uploadedExpenseCount = computed(() => expenseItems.value.filter((item) => item.attachments.length).length) + const hasExpenseRiskColumn = computed(() => expenseItems.value.some((item) => item.attachments.length)) + const expenseTableColumnCount = computed( + () => 5 + (hasExpenseRiskColumn.value ? 1 : 0) + (isDraftRequest.value ? 1 : 0) + ) const expenseSummaryText = computed( () => request.value.expenseTableSummary || '请继续补充票据、说明和系统校验结果。' ) const detailNote = computed( () => request.value.note - || (isTravelRequest.value - ? '该差旅报销单尚未补充完整说明,请继续完善后再提交审批。' - : '该报销单尚未补充完整说明,请继续完善后再提交审批。') + || '暂无附加说明。可在这里补充特殊背景、例外原因、补件计划或其他需要财务和审批人重点关注的信息。' ) const draftBlockingIssues = computed(() => isDraftRequest.value ? buildDraftBlockingIssues(request.value, expenseItems.value) : [] ) const canSubmit = computed(() => isDraftRequest.value && draftBlockingIssues.value.length === 0 && !actionBusy.value) - const validationTone = computed(() => (canSubmit.value ? 'ready' : 'pending')) - const validationSummary = computed(() => - canSubmit.value - ? '当前草稿信息完整,可以提交审批。' - : '当前草稿仍有未完善字段,提交按钮会保持禁用。' - ) + const locationInputPlaceholder = computed(() => resolveLocationInputPlaceholder(expenseEditor.itemType)) - function toggleExpenseAttachments(id) { - expandedExpenseId.value = expandedExpenseId.value === id ? null : id + function applyLocalExpenseItemPatch(itemId, patch) { + expenseItems.value = rebuildExpenseItems( + expenseItems.value.map((item) => (item.id === itemId ? { ...item, ...patch } : item)), + request.value + ) + } + + function resolveAttachmentMeta(item) { + return expenseAttachmentMeta[item.id] || null + } + + function resolveAttachmentDisplayName(item) { + const metadata = resolveAttachmentMeta(item) + return String(metadata?.file_name || item.attachmentHint || '').trim() + } + + function buildAttachmentRiskNotice(attachment) { + const analysis = attachment?.analysis + const severity = String(analysis?.severity || '').trim() + + if (!analysis || severity === 'pass') { + return '' + } + + const label = + String(analysis?.label || '').trim() + || (severity === 'high' ? '高风险' : severity === 'medium' ? '中风险' : '低风险') + const summary = String(analysis?.summary || analysis?.headline || '').trim() || '附件存在待核对风险。' + return `${label}:${summary}` + } + + async function refreshExpenseAttachmentMeta(itemId) { + if (!request.value.claimId || !itemId) { + return null + } + + const payload = await fetchExpenseClaimItemAttachmentMeta(request.value.claimId, itemId) + expenseAttachmentMeta[itemId] = payload + return payload + } + + function canPreviewAttachment(item) { + const metadata = resolveAttachmentMeta(item) + return Boolean(item.invoiceId && metadata?.previewable) + } + + function revokeAttachmentPreviewUrl() { + if (attachmentPreviewUrl.value && attachmentPreviewUrl.value.startsWith('blob:')) { + URL.revokeObjectURL(attachmentPreviewUrl.value) + } + attachmentPreviewUrl.value = '' + } + + function closeAttachmentPreview() { + attachmentPreviewOpen.value = false + attachmentPreviewLoading.value = false + attachmentPreviewError.value = '' + attachmentPreviewName.value = '' + attachmentPreviewMediaType.value = '' + revokeAttachmentPreviewUrl() + } + + async function syncExpenseAttachmentMeta() { + if (!request.value.claimId) { + return + } + + const tasks = expenseItems.value + .filter((item) => item.invoiceId) + .map(async (item) => { + try { + const payload = await fetchExpenseClaimItemAttachmentMeta(request.value.claimId, item.id) + expenseAttachmentMeta[item.id] = payload + } catch { + delete expenseAttachmentMeta[item.id] + } + }) + + Object.keys(expenseAttachmentMeta).forEach((itemId) => { + if (!expenseItems.value.some((item) => item.id === itemId && item.invoiceId)) { + delete expenseAttachmentMeta[itemId] + } + }) + + await Promise.allSettled(tasks) } function resolveExpenseIssues(item) { return buildExpenseDraftIssues(item) } - function showExpenseRisk(item) { - return Boolean(resolveExpenseIssues(item).length || item.riskText) + function resolveExpenseRiskState(item) { + if (!item.invoiceId) { + return null + } + + if (uploadingExpenseId.value === item.id) { + return { + label: 'AI识别中', + tone: 'medium', + headline: 'AI提示:正在分析附件内容', + summary: '附件已上传,系统正在识别票据内容与风险点,请稍候。', + points: [], + suggestion: '' + } + } + + const metadata = resolveAttachmentMeta(item) + const analysis = metadata?.analysis + if (analysis) { + return { + label: analysis.label || '已上传', + tone: analysis.severity === 'pass' ? 'pass' : analysis.severity || 'low', + headline: analysis.headline || 'AI提示', + summary: analysis.summary || '', + points: Array.isArray(analysis.points) ? analysis.points : [], + suggestion: analysis.suggestion || '' + } + } + + return { + label: '已上传', + tone: 'low', + headline: 'AI提示:附件已上传', + summary: '附件已成功保存,当前可继续查看原图并人工核对票据内容。', + points: [], + suggestion: '' + } } + function showExpenseRisk(item) { + return Boolean(resolveExpenseRiskState(item)) + } + + const aiAdvice = computed(() => { + const completionItems = draftBlockingIssues.value.map(mapIssueToAdvice).filter(Boolean) + const riskItems = expenseItems.value + .map((item, index) => { + const state = resolveExpenseRiskState(item) + if (!state || !['medium', 'high'].includes(state.tone)) { + return '' + } + + const adviceText = String(state.suggestion || state.summary || '').trim() + const prefix = state.tone === 'high' ? '优先整改' : '继续核对' + return `第 ${index + 1} 条附件需${prefix}:${adviceText || '请根据系统提示补充或更换附件。'}` + }) + .filter(Boolean) + + if (!completionItems.length && !riskItems.length) { + return { + tone: 'ready', + badge: '可直接提交', + summary: 'AI判断当前草稿已具备提交条件,可以直接发起审批。', + items: [ + '点击右下角“提交审批”进入流程。', + '提交前再核对一次合计金额与各条费用明细金额是否一致。', + '如有特殊业务背景或例外情况,可在下方附加说明中补充。' + ] + } + } + + const hasHighRisk = expenseItems.value.some((item) => resolveExpenseRiskState(item)?.tone === 'high') + + return { + tone: hasHighRisk ? 'warning' : 'pending', + badge: hasHighRisk ? '优先整改' : '待补信息', + summary: completionItems.length + ? '建议先补齐必填信息,再处理附件核验项,完成后即可提交审批。' + : '草稿信息已基本齐全,建议先处理附件风险后再提交审批。', + items: [...completionItems, ...riskItems] + } + }) + function startExpenseEdit(item) { if (!isDraftRequest.value || actionBusy.value) { return @@ -369,10 +735,10 @@ export default { expenseEditor.itemDate = item.itemDate || '' expenseEditor.itemType = item.itemType || 'other' expenseEditor.itemReason = item.itemReason || (item.desc === '待补充' ? '' : item.desc) - expenseEditor.itemLocation = item.itemLocation || (item.detail === '待补充' ? '' : item.detail) + expenseEditor.itemLocation = + item.itemLocation || (['待补充', '非必填'].includes(item.detail) ? '' : item.detail) expenseEditor.itemAmount = item.itemAmount ? String(item.itemAmount) : '' expenseEditor.invoiceId = item.invoiceId || '' - expandedExpenseId.value = null } function cancelExpenseEdit() { @@ -389,7 +755,10 @@ export default { if (isPlaceholderValue(expenseEditor.itemReason)) { return '请输入费用说明。' } - if (isPlaceholderValue(expenseEditor.itemLocation)) { + if ( + isLocationRequiredExpenseType(expenseEditor.itemType) + && isPlaceholderValue(expenseEditor.itemLocation) + ) { return '请输入业务地点。' } @@ -397,11 +766,197 @@ export default { if (!Number.isFinite(amount) || amount <= 0) { return '请输入大于 0 的费用金额。' } - if (isPlaceholderValue(expenseEditor.invoiceId)) { - return '请输入票据标识或附件名称。' + return '' + } + + async function handleAddExpenseItem() { + if (!isDraftRequest.value || actionBusy.value) { + return } - return '' + if (!request.value.claimId) { + toast('当前草稿缺少 claimId,暂时无法新增费用明细。') + return + } + + creatingExpense.value = true + try { + const existingIds = new Set(expenseItems.value.map((item) => item.id)) + const claim = await createExpenseClaimItem(request.value.claimId, {}) + const createdItem = Array.isArray(claim?.items) + ? claim.items.find((entry) => !existingIds.has(String(entry?.id || ''))) + : null + + if (!createdItem) { + throw new Error('新增费用明细失败,请稍后重试。') + } + + const nextItem = buildExpenseItemViewModel(createdItem, expenseItems.value.length, request.value) + expenseItems.value = rebuildExpenseItems([...expenseItems.value, nextItem], request.value) + creatingExpense.value = false + startExpenseEdit(nextItem) + toast('已新增一条费用明细,请继续填写。') + } catch (error) { + toast(error?.message || '新增费用明细失败,请稍后重试。') + } finally { + creatingExpense.value = false + } + } + + function triggerExpenseUpload(item) { + if (!isDraftRequest.value || actionBusy.value) { + return + } + + if (!request.value.claimId) { + toast('当前草稿缺少 claimId,暂时无法上传附件。') + return + } + + pendingUploadExpenseId.value = item.id + if (expenseUploadInput.value) { + expenseUploadInput.value.value = '' + expenseUploadInput.value.click() + } + } + + async function openAttachmentPreview(item) { + if (!request.value.claimId || !canPreviewAttachment(item)) { + return + } + + closeAttachmentPreview() + attachmentPreviewOpen.value = true + attachmentPreviewLoading.value = true + attachmentPreviewName.value = resolveAttachmentDisplayName(item) + attachmentPreviewMediaType.value = String(resolveAttachmentMeta(item)?.media_type || '').trim() + + try { + const blob = await fetchExpenseClaimItemAttachment(request.value.claimId, item.id) + revokeAttachmentPreviewUrl() + attachmentPreviewUrl.value = URL.createObjectURL(blob) + attachmentPreviewMediaType.value = blob.type || attachmentPreviewMediaType.value + } catch (error) { + attachmentPreviewError.value = error?.message || '附件预览失败,请稍后重试。' + } finally { + attachmentPreviewLoading.value = false + } + } + + async function uploadExpenseFile(item, file) { + if (!item || !file) { + return + } + + uploadingExpenseId.value = item.id + + try { + const payload = await uploadExpenseClaimItemAttachment(request.value.claimId, item.id, file) + expenseAttachmentMeta[item.id] = payload?.attachment || null + applyLocalExpenseItemPatch(item.id, { + invoiceId: String(payload?.invoice_id || '').trim(), + attachmentHint: String(payload?.attachment?.file_name || file.name || '').trim() + }) + if (editingExpenseId.value === item.id) { + expenseEditor.invoiceId = String(payload?.invoice_id || '').trim() + } + + emit('request-updated', { claimId: request.value.claimId }) + const riskNotice = buildAttachmentRiskNotice(payload?.attachment) + toast(riskNotice || payload?.message || `${file.name} 已关联到当前费用明细。`) + } catch (error) { + toast(error?.message || '附件上传失败,请稍后重试。') + } finally { + uploadingExpenseId.value = '' + } + } + + async function removeExpenseAttachment(item) { + if (!request.value.claimId || !item?.invoiceId || actionBusy.value) { + return + } + + deletingAttachmentId.value = item.id + try { + const payload = await deleteExpenseClaimItemAttachment(request.value.claimId, item.id) + delete expenseAttachmentMeta[item.id] + applyLocalExpenseItemPatch(item.id, { + invoiceId: '', + attachmentHint: resolveExpenseUploadHint() + }) + if (editingExpenseId.value === item.id) { + expenseEditor.invoiceId = '' + } + if (attachmentPreviewOpen.value) { + closeAttachmentPreview() + } + emit('request-updated', { claimId: request.value.claimId }) + toast(payload?.message || '附件已删除。') + } catch (error) { + toast(error?.message || '附件删除失败,请稍后重试。') + } finally { + deletingAttachmentId.value = '' + } + } + + async function handleExpenseFileChange(event) { + const target = event?.target + const file = target?.files?.[0] + const itemId = pendingUploadExpenseId.value + pendingUploadExpenseId.value = '' + + if (target) { + target.value = '' + } + + if (!file || !itemId) { + return + } + + const item = expenseItems.value.find((entry) => entry.id === itemId) + if (!item) { + toast('未找到对应的费用明细,请刷新后重试。') + return + } + + await uploadExpenseFile(item, file) + } + + async function removeExpenseItem(item) { + if (!request.value.claimId || !item?.id || actionBusy.value) { + return + } + + deletingExpenseId.value = item.id + try { + const payload = await deleteExpenseClaimItem(request.value.claimId, item.id) + delete expenseAttachmentMeta[item.id] + expenseItems.value = rebuildExpenseItems( + expenseItems.value.filter((entry) => entry.id !== item.id), + request.value + ) + if (editingExpenseId.value === item.id) { + editingExpenseId.value = '' + expenseEditor.itemDate = '' + expenseEditor.itemType = 'other' + expenseEditor.itemReason = '' + expenseEditor.itemLocation = '' + expenseEditor.itemAmount = '' + expenseEditor.invoiceId = '' + } + if (pendingUploadExpenseId.value === item.id) { + pendingUploadExpenseId.value = '' + } + if (attachmentPreviewOpen.value) { + closeAttachmentPreview() + } + emit('request-updated', { claimId: request.value.claimId }) + toast(payload?.message || '费用明细已删除。') + } catch (error) { + toast(error?.message || '费用明细删除失败,请稍后重试。') + } finally { + deletingExpenseId.value = '' + } } async function saveExpenseEdit(item) { @@ -418,16 +973,36 @@ export default { savingExpenseId.value = item.id try { + const nextInvoiceId = expenseEditor.invoiceId.trim() await updateExpenseClaimItem(request.value.claimId, item.id, { item_date: expenseEditor.itemDate, item_type: expenseEditor.itemType, item_reason: expenseEditor.itemReason.trim(), item_location: expenseEditor.itemLocation.trim(), item_amount: Number(expenseEditor.itemAmount), - invoice_id: expenseEditor.invoiceId.trim() + invoice_id: nextInvoiceId }) + applyLocalExpenseItemPatch(item.id, { + itemDate: expenseEditor.itemDate, + itemType: expenseEditor.itemType, + itemReason: expenseEditor.itemReason.trim(), + itemLocation: expenseEditor.itemLocation.trim(), + itemAmount: Number(expenseEditor.itemAmount), + invoiceId: nextInvoiceId + }) + let riskNotice = '' + if (nextInvoiceId) { + try { + const attachment = await refreshExpenseAttachmentMeta(item.id) + riskNotice = buildAttachmentRiskNotice(attachment) + } catch { + delete expenseAttachmentMeta[item.id] + } + } else { + delete expenseAttachmentMeta[item.id] + } editingExpenseId.value = '' - toast('费用明细已保存。') + toast(riskNotice || '费用明细已保存。') emit('request-updated', { claimId: request.value.claimId }) } catch (error) { toast(error?.message || '费用明细保存失败,请稍后重试。') @@ -503,43 +1078,68 @@ export default { }) } + onBeforeUnmount(() => { + closeAttachmentPreview() + }) + return { emit, actionBusy, + aiAdvice, + attachmentPreviewError, + attachmentPreviewLoading, + attachmentPreviewMediaType, + attachmentPreviewName, + attachmentPreviewOpen, + attachmentPreviewUrl, canSubmit, + canPreviewAttachment, closeDeleteDialog, + closeAttachmentPreview, confirmDeleteDraft, currentProgressRingMotion, deleteBusy, deleteDialogOpen, + deletingAttachmentId, + deletingExpenseId, detailNote, draftBlockingIssues, editingExpenseId, + creatingExpense, expenseEditor, expenseItems, expenseSummaryText, + expenseTableColumnCount, expenseTotal, - expandedExpenseId, + expenseUploadInput, expenseTypeOptions: EXPENSE_TYPE_OPTIONS, + handleAddExpenseItem, handleDeleteDraft, + handleExpenseFileChange, handleSubmit, + hasExpenseRiskColumn, heroStats, heroSummaryItems, isDraftRequest, isTravelRequest, + locationInputPlaceholder, openAiEntry, + openAttachmentPreview, profile, progressSteps, + removeExpenseItem, request, + removeExpenseAttachment, + resolveAttachmentDisplayName, + resolveExpenseRiskState, resolveExpenseIssues, savingExpenseId, showExpenseRisk, startExpenseEdit, submitBusy, - toggleExpenseAttachments, + triggerExpenseUpload, uploadedExpenseCount, - validationSummary, - validationTone, + uploadingExpenseId, cancelExpenseEdit, saveExpenseEdit }