Files
X-Financial/web/src/views/scripts/TravelRequestDetailView.js
caoxiaozhu f8b25a7ccc fix: 修复员工服务、报销单审批及前端交互细节
- 修复员工创建时组织架构关联与邮箱校验逻辑
- 修复报销单API端点参数及预审流程调用
- 优化审批中心、差旅详情等前端页面交互
- 更新侧边栏导航与请求视图模型
- 补充员工服务与报销单相关测试用例
2026-05-20 14:32:35 +08:00

1302 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js'
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
import {
createExpenseClaimItem,
deleteExpenseClaimItem,
deleteExpenseClaimItemAttachment,
deleteExpenseClaim,
fetchExpenseClaimItemAttachmentMeta,
fetchExpenseClaimItemAttachmentPreview,
returnExpenseClaim,
submitExpenseClaim,
uploadExpenseClaimItemAttachment,
updateExpenseClaimItem
} from '../../services/reimbursements.js'
import { canManageExpenseClaims } from '../../utils/accessControl.js'
import { normalizeRequestForUi } from '../../utils/requestViewModel.js'
const EXPENSE_TYPE_OPTIONS = [
{ value: 'travel', label: '差旅费' },
{ value: 'entertainment', label: '业务招待费' },
{ value: 'office', label: '办公费' },
{ value: 'meeting', label: '会务费' },
{ value: 'training', label: '培训费' },
{ value: 'hotel', label: '住宿费' },
{ value: 'transport', label: '交通费' },
{ value: 'meal', label: '餐费' },
{ value: 'other', label: '其他费用' }
]
const DOCUMENT_TYPE_LABELS = {
flight_itinerary: '机票/航班行程单',
train_ticket: '火车/高铁票',
hotel_invoice: '酒店住宿票据',
taxi_receipt: '出租车/网约车票据',
parking_toll_receipt: '停车/通行费票据',
meal_receipt: '餐饮票据',
office_invoice: '办公用品票据',
meeting_invoice: '会议/会务票据',
training_invoice: '培训票据',
vat_invoice: '增值税发票',
receipt: '一般收据/凭证',
other: '其他单据'
}
const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
'travel',
'meeting',
'entertainment'
])
function parseCurrency(value) {
return Number.parseFloat(String(value).replace(/[^\d.]/g, '')) || 0
}
function formatCurrency(value) {
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY',
minimumFractionDigits: 0,
maximumFractionDigits: Number.isInteger(value) ? 0 : 2
}).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 resolveDocumentTypeLabel(value) {
return DOCUMENT_TYPE_LABELS[String(value || '').trim()] || DOCUMENT_TYPE_LABELS.other
}
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 },
{ index: 2, label: '待提交', time: '进行中', active: true, current: true },
{ index: 3, label: 'AI预审', time: '待处理' },
{ index: 4, label: '直属领导审批', time: '待处理' },
{ index: 5, label: '财务审批', time: '待处理' },
{ index: 6, label: '归档入账', time: '待处理' }
]
}
function buildFallbackExpenseItems(request) {
return [
buildExpenseItemViewModel({
id: 'fallback-1',
itemDate: '',
itemType: request.typeCode || 'other',
itemReason: request.reason,
itemLocation: request.sceneTarget,
itemAmount: parseCurrency(request.amountDisplay),
invoiceId: '',
time: '待补充',
dayLabel: request.detailVariant === 'travel' ? '出行日' : '业务发生日',
name: request.typeLabel,
category: request.typeLabel,
desc: request.reason,
detail: resolveLocationDisplay(request.sceneTarget, request.typeCode),
amount: request.amountDisplay,
status: '待补充',
tone: 'bad',
attachmentStatus: '待上传',
attachmentHint: '请在此单据中继续补充附件',
attachmentTone: 'missing',
attachments: [],
riskLabel: '待补材料',
riskText: request.riskSummary,
riskTone: 'medium'
}, 0, request)
]
}
function isPlaceholderValue(value) {
const text = String(value || '').trim()
if (!text) {
return true
}
return ['待补充', '暂无', '无', '未知', '处理中'].includes(text.replace(/\s+/g, ''))
}
function isValidIsoDate(value) {
const normalized = String(value || '').trim()
if (!/^\d{4}-\d{2}-\d{2}$/.test(normalized)) {
return false
}
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('缺少日期')
}
if (isPlaceholderValue(item.itemType)) {
issues.push('缺少费用项目')
}
if (isPlaceholderValue(item.itemReason)) {
issues.push('缺少说明')
}
if (locationRequired && isPlaceholderValue(item.itemLocation)) {
issues.push('缺少地点')
}
if (!Number.isFinite(Number(item.itemAmount)) || Number(item.itemAmount) <= 0) {
issues.push('缺少金额')
}
if (isPlaceholderValue(item.invoiceId)) {
issues.push('缺少票据标识')
}
return issues
}
function buildDraftBlockingIssues(request, expenseItems) {
const issues = []
const locationRequired = isLocationRequiredExpenseType(request.typeCode)
if (isPlaceholderValue(request.profileName)) {
issues.push('申请人未完善')
}
if (isPlaceholderValue(request.typeLabel)) {
issues.push('报销类型未完善')
}
if (isPlaceholderValue(request.reason)) {
issues.push('报销事由未完善')
}
if (locationRequired && isPlaceholderValue(request.location)) {
issues.push('业务地点未完善')
}
if (isPlaceholderValue(request.occurredDisplay)) {
issues.push('发生时间未完善')
}
if (!Number.isFinite(Number(request.amountValue)) || Number(request.amountValue) <= 0) {
issues.push('报销金额未完善')
}
if (!expenseItems.length) {
issues.push('费用明细不能为空')
}
expenseItems.forEach((item, index) => {
buildExpenseDraftIssues(item).forEach((issue) => {
issues.push(`费用明细第 ${index + 1}${issue}`)
})
})
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: {
ConfirmDialog
},
props: {
request: {
type: Object,
default: () => ({})
}
},
emits: ['backToRequests', 'openAssistant', 'request-updated', 'request-deleted'],
setup(props, { emit }) {
const { toast } = useToast()
const { currentUser } = useSystemState()
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 returnBusy = ref(false)
const returnDialogOpen = 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',
itemReason: '',
itemLocation: '',
itemAmount: '',
invoiceId: ''
})
const request = computed(() => {
const normalized = normalizeRequestForUi(props.request)
return (
normalized || {
id: 'EXP-202605-000',
claimId: '',
reason: '待补充报销事由',
typeLabel: '其他费用',
typeCode: 'other',
detailVariant: 'general',
sceneTarget: '待补充',
location: '待补充',
occurredDisplay: '待补充',
applyTime: '待补充',
amountDisplay: '¥0',
amountValue: 0,
node: '待提交',
approval: '草稿',
approvalKey: 'draft',
approvalTone: 'draft',
secondaryStatusLabel: '票据状态',
secondaryStatusValue: '待补充',
secondaryStatusTone: 'warning',
relatedCustomer: '待补充',
attachmentSummary: '待补充',
riskSummary: '待补充',
note: '',
profileIdentity: '员工',
profilePosition: '待补充',
profileGrade: '待补充',
profileManager: '待补充',
profileName: '当前申请人',
profileDepartment: '待补充部门',
profileAvatar: '申'
}
)
})
const isTravelRequest = computed(() => request.value.detailVariant === 'travel')
const isDraftRequest = computed(() => request.value.approvalKey === 'draft')
const isEditableRequest = computed(() => ['draft', 'supplement'].includes(request.value.approvalKey))
const canManageCurrentClaim = computed(() => canManageExpenseClaims(currentUser.value))
const canDeleteRequest = computed(() => isEditableRequest.value || canManageCurrentClaim.value)
const canReturnRequest = computed(() =>
canManageCurrentClaim.value
&& request.value.approvalKey === 'in_progress'
&& Boolean(request.value.claimId)
)
const deleteActionLabel = computed(() => (isDraftRequest.value ? '删除草稿' : '删除单据'))
const deleteDialogTitle = computed(() => `确认${deleteActionLabel.value} ${request.value.id} 吗?`)
const deleteDialogDescription = computed(() =>
isDraftRequest.value
? '删除后该草稿及其当前费用明细将不可恢复,请确认本次操作。'
: '删除后该报销单及费用明细将不可恢复,请确认本次操作。'
)
const actionBusy = computed(() =>
Boolean(savingExpenseId.value)
|| submitBusy.value
|| deleteBusy.value
|| returnBusy.value
|| creatingExpense.value
|| Boolean(uploadingExpenseId.value)
|| Boolean(deletingAttachmentId.value)
|| Boolean(deletingExpenseId.value)
)
const profile = computed(() => ({
name: request.value.profileName,
identity: request.value.profileIdentity,
position: request.value.profilePosition,
department: request.value.profileDepartment,
grade: request.value.profileGrade,
manager: request.value.profileManager,
avatar: request.value.profileAvatar
}))
const expenseItems = ref([])
watch(
request,
(nextRequest, previousRequest) => {
expenseItems.value =
Array.isArray(nextRequest.expenseItems)
? rebuildExpenseItems(nextRequest.expenseItems, nextRequest)
: buildFallbackExpenseItems(nextRequest)
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 heroFactItems = computed(() => [
{
key: 'document',
label: '报销单号',
value: request.value.documentNo || request.value.id,
icon: 'mdi mdi-camera-outline',
valueClass: ''
},
{
key: 'date',
label: '日期',
value: request.value.applyTime || request.value.occurredDisplay,
icon: 'mdi mdi-calendar-month-outline',
valueClass: ''
},
{
key: 'amount',
label: '报销金额',
value: request.value.amountDisplay,
icon: '',
valueClass: 'amount'
},
{
key: 'type',
label: isTravelRequest.value ? '差旅类型' : '报销类型',
value: request.value.typeLabel,
icon: '',
valueClass: ''
},
{
key: 'status',
label: '当前状态',
value: request.value.node,
icon: '',
valueClass: 'status'
}
])
const progressSteps = computed(() =>
Array.isArray(request.value.progressSteps) && request.value.progressSteps.length
? request.value.progressSteps
: buildFallbackProgressSteps()
)
const currentProgressRingMotion = {
initial: {
scale: 1,
opacity: 0.34
},
enter: {
scale: [1, 1.42, 1.78],
opacity: [0.34, 0.16, 0],
transition: {
duration: 3.2,
repeat: Infinity,
repeatType: 'loop',
repeatDelay: 0.85,
ease: 'easeOut',
times: [0, 0.5, 1]
}
}
}
const expenseTotal = computed(() => {
const total = expenseItems.value.reduce((sum, item) => sum + parseCurrency(item.amount), 0)
return formatCurrency(total)
})
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) + (isEditableRequest.value ? 1 : 0)
)
const expenseSummaryText = computed(
() => request.value.expenseTableSummary || '请继续补充票据、说明和系统校验结果。'
)
const detailNote = computed(
() =>
request.value.note
|| '暂无附加说明。可在这里补充特殊背景、例外原因、补件计划或其他需要财务和审批人重点关注的信息。'
)
const draftBlockingIssues = computed(() =>
isEditableRequest.value ? buildDraftBlockingIssues(request.value, expenseItems.value) : []
)
const canSubmit = computed(() => isEditableRequest.value && draftBlockingIssues.value.length === 0 && !actionBusy.value)
const locationInputPlaceholder = computed(() => resolveLocationInputPlaceholder(expenseEditor.itemType))
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 resolveAttachmentRecognition(item) {
const metadata = resolveAttachmentMeta(item)
const documentInfo = metadata?.document_info
const requirementCheck = metadata?.requirement_check
if (!documentInfo && !requirementCheck) {
return null
}
const fields = Array.isArray(documentInfo?.fields)
? documentInfo.fields
.map((field) => ({
label: String(field?.label || '').trim(),
value: String(field?.value || '').trim()
}))
.filter((field) => field.label && field.value)
: []
return {
documentTypeLabel:
String(documentInfo?.document_type_label || '').trim()
|| resolveDocumentTypeLabel(documentInfo?.document_type),
requirementLabel: requirementCheck
? (requirementCheck.matches ? '符合当前费用类型' : '不符合当前费用类型')
: '待校验附件类型',
requirementTone: requirementCheck
? (requirementCheck.matches ? 'pass' : 'high')
: 'medium',
message: String(requirementCheck?.message || '').trim(),
fields: fields.slice(0, 4).map((field) => `${field.label}${field.value}`)
}
}
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 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 (!isEditableRequest.value || actionBusy.value) {
return
}
editingExpenseId.value = item.id
expenseEditor.itemDate = item.itemDate || ''
expenseEditor.itemType = item.itemType || 'other'
expenseEditor.itemReason = item.itemReason || (item.desc === '待补充' ? '' : item.desc)
expenseEditor.itemLocation =
item.itemLocation || (['待补充', '非必填'].includes(item.detail) ? '' : item.detail)
expenseEditor.itemAmount = item.itemAmount ? String(item.itemAmount) : ''
expenseEditor.invoiceId = item.invoiceId || ''
}
function cancelExpenseEdit() {
editingExpenseId.value = ''
}
function validateExpenseEditor() {
if (!isValidIsoDate(expenseEditor.itemDate)) {
return '请输入正确的费用日期,格式为 YYYY-MM-DD。'
}
if (isPlaceholderValue(expenseEditor.itemType)) {
return '请选择费用项目。'
}
if (isPlaceholderValue(expenseEditor.itemReason)) {
return '请输入费用说明。'
}
if (
isLocationRequiredExpenseType(expenseEditor.itemType)
&& isPlaceholderValue(expenseEditor.itemLocation)
) {
return '请输入业务地点。'
}
const amount = Number(expenseEditor.itemAmount)
if (!Number.isFinite(amount) || amount <= 0) {
return '请输入大于 0 的费用金额。'
}
return ''
}
async function handleAddExpenseItem() {
if (!isEditableRequest.value || actionBusy.value) {
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 (!isEditableRequest.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)
const metadata = resolveAttachmentMeta(item)
attachmentPreviewMediaType.value =
String(metadata?.preview_kind || '').trim() === 'image'
? 'image/png'
: String(metadata?.media_type || '').trim()
try {
const blob = await fetchExpenseClaimItemAttachmentPreview(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) {
if (!request.value.claimId) {
toast('当前草稿缺少 claimId暂时无法保存费用明细。')
return
}
const validationError = validateExpenseEditor()
if (validationError) {
toast(validationError)
return
}
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: 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(riskNotice || '费用明细已保存。')
emit('request-updated', { claimId: request.value.claimId })
} catch (error) {
toast(error?.message || '费用明细保存失败,请稍后重试。')
} finally {
savingExpenseId.value = ''
}
}
async function handleSubmit() {
if (!request.value.claimId) {
toast('当前草稿缺少 claimId暂时无法提交。')
return
}
if (!canSubmit.value) {
toast(draftBlockingIssues.value[0] || '请先补全草稿信息,再提交审批。')
return
}
submitBusy.value = true
try {
const payload = await submitExpenseClaim(request.value.claimId)
const claimStatus = String(payload?.status || '').trim().toLowerCase()
const approvalStage = String(payload?.approval_stage || payload?.approvalStage || '').trim()
if (claimStatus === 'submitted') {
toast(`${request.value.id} 已完成 AI预审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}`)
} else if (claimStatus === 'supplement') {
toast(`${request.value.id} AI预审未通过已转待补充。`)
} else {
toast(`${request.value.id} 提交结果已更新。`)
}
emit('request-updated', { claimId: request.value.claimId })
} catch (error) {
toast(error?.message || '提交审批失败,请稍后重试。')
} finally {
submitBusy.value = false
}
}
async function handleDeleteRequest() {
if (!request.value.claimId) {
toast('当前单据缺少 claimId暂时无法删除。')
return
}
if (!canDeleteRequest.value) {
toast('当前单据已进入流程,只有财务人员或高级管理人员可以删除。')
return
}
deleteDialogOpen.value = true
}
function closeDeleteDialog() {
if (deleteBusy.value) {
return
}
deleteDialogOpen.value = false
}
async function confirmDeleteRequest() {
if (!request.value.claimId) {
toast('当前单据缺少 claimId暂时无法删除。')
return
}
deleteBusy.value = true
try {
const payload = await deleteExpenseClaim(request.value.claimId)
deleteDialogOpen.value = false
toast(payload?.message || `${request.value.id} 报销单已删除。`)
emit('request-deleted', { claimId: request.value.claimId })
} catch (error) {
toast(error?.message || '删除单据失败,请稍后重试。')
} finally {
deleteBusy.value = false
}
}
function handleReturnRequest() {
if (!request.value.claimId) {
toast('当前单据缺少 claimId暂时无法退回。')
return
}
if (!canReturnRequest.value) {
toast('当前状态不支持退回。')
return
}
returnDialogOpen.value = true
}
function closeReturnDialog() {
if (returnBusy.value) {
return
}
returnDialogOpen.value = false
}
async function confirmReturnRequest() {
if (!request.value.claimId) {
toast('当前单据缺少 claimId暂时无法退回。')
return
}
returnBusy.value = true
try {
await returnExpenseClaim(request.value.claimId, {
reason: '详情页退回,请申请人调整后重新提交。'
})
returnDialogOpen.value = false
toast(`${request.value.id} 已退回待提交。`)
emit('request-updated', { claimId: request.value.claimId })
} catch (error) {
toast(error?.message || '退回单据失败,请稍后重试。')
} finally {
returnBusy.value = false
}
}
function openAiEntry() {
emit('openAssistant', {
source: 'detail',
prompt: '',
request: request.value,
restoreLatestConversation: true
})
}
onBeforeUnmount(() => {
closeAttachmentPreview()
})
return {
emit,
actionBusy,
aiAdvice,
attachmentPreviewError,
attachmentPreviewLoading,
attachmentPreviewMediaType,
attachmentPreviewName,
attachmentPreviewOpen,
attachmentPreviewUrl,
canDeleteRequest,
canManageCurrentClaim,
canReturnRequest,
canSubmit,
canPreviewAttachment,
closeDeleteDialog,
closeAttachmentPreview,
closeReturnDialog,
confirmDeleteRequest,
confirmReturnRequest,
currentProgressRingMotion,
deleteActionLabel,
deleteBusy,
deleteDialogDescription,
deleteDialogOpen,
deleteDialogTitle,
deletingAttachmentId,
deletingExpenseId,
detailNote,
draftBlockingIssues,
editingExpenseId,
creatingExpense,
expenseEditor,
expenseItems,
expenseSummaryText,
expenseTableColumnCount,
expenseTotal,
expenseUploadInput,
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
handleAddExpenseItem,
handleDeleteRequest,
handleExpenseFileChange,
handleReturnRequest,
handleSubmit,
hasExpenseRiskColumn,
heroFactItems,
isDraftRequest,
isEditableRequest,
isTravelRequest,
locationInputPlaceholder,
openAiEntry,
openAttachmentPreview,
profile,
progressSteps,
removeExpenseItem,
request,
removeExpenseAttachment,
resolveAttachmentDisplayName,
resolveAttachmentRecognition,
resolveExpenseRiskState,
resolveExpenseIssues,
returnBusy,
returnDialogOpen,
savingExpenseId,
showExpenseRisk,
startExpenseEdit,
submitBusy,
triggerExpenseUpload,
uploadedExpenseCount,
uploadingExpenseId,
cancelExpenseEdit,
saveExpenseEdit
}
}
}