diff --git a/web/src/views/scripts/AuditView.js b/web/src/views/scripts/AuditView.js index 79de2c0..0481b7c 100644 --- a/web/src/views/scripts/AuditView.js +++ b/web/src/views/scripts/AuditView.js @@ -1,5 +1,6 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' +import ConfirmDialog from '../../components/shared/ConfirmDialog.vue' import { useSystemState } from '../../composables/useSystemState.js' import { useToast } from '../../composables/useToast.js' import { @@ -847,6 +848,9 @@ function buildReviewNote(status) { export default { name: 'AuditView', + components: { + ConfirmDialog + }, emits: ['detail-open-change'], setup(_, { emit }) { const { toast } = useToast() diff --git a/web/src/views/scripts/EmployeeManagementView.js b/web/src/views/scripts/EmployeeManagementView.js index 71fd55c..369b09d 100644 --- a/web/src/views/scripts/EmployeeManagementView.js +++ b/web/src/views/scripts/EmployeeManagementView.js @@ -1,5 +1,6 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' +import ConfirmDialog from '../../components/shared/ConfirmDialog.vue' import { useToast } from '../../composables/useToast.js' import { disableEmployee, fetchEmployeeMeta, fetchEmployees, updateEmployee } from '../../services/employees.js' @@ -199,6 +200,9 @@ function buildEmployeeSummary(employees) { export default { name: 'EmployeeManagementView', + components: { + ConfirmDialog + }, emits: ['overview-change'], setup(_, { emit }) { const { toast } = useToast() @@ -219,6 +223,7 @@ export default { const actionState = ref('') const loading = ref(false) const errorMessage = ref('') + const disableDialogOpen = ref(false) const tabs = computed(() => buildStatusTabs(employees.value)) const employeeSummary = computed(() => buildEmployeeSummary(employees.value)) @@ -532,12 +537,24 @@ export default { } } - async function disableEmployeeAccount() { + function disableEmployeeAccount() { if (!selectedEmployee.value || disableActionDisabled.value) { return } - if (!window.confirm(`确认停用 ${selectedEmployee.value.name} 的账号吗?`)) { + disableDialogOpen.value = true + } + + function closeDisableDialog() { + if (actionState.value === 'disable') { + return + } + + disableDialogOpen.value = false + } + + async function confirmDisableEmployeeAccount() { + if (!selectedEmployee.value || disableActionDisabled.value) { return } @@ -545,6 +562,7 @@ export default { try { const updated = await disableEmployee(selectedEmployee.value.id) + disableDialogOpen.value = false selectedEmployee.value = updated await loadEmployees() toast('员工账号已停用。') @@ -642,7 +660,10 @@ export default { resetFilters, openEmployeeDetail, closeEmployeeDetail, + closeDisableDialog, + confirmDisableEmployeeAccount, saveEmployeeChanges, + disableDialogOpen, disableEmployeeAccount, changePageSize, togglePageSizeOpen, diff --git a/web/src/views/scripts/PoliciesView.js b/web/src/views/scripts/PoliciesView.js index 5645ec9..10149da 100644 --- a/web/src/views/scripts/PoliciesView.js +++ b/web/src/views/scripts/PoliciesView.js @@ -1,7 +1,8 @@ -import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' - -import { useSystemState } from '../../composables/useSystemState.js' -import { useToast } from '../../composables/useToast.js' +import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' + +import ConfirmDialog from '../../components/shared/ConfirmDialog.vue' +import { useSystemState } from '../../composables/useSystemState.js' +import { useToast } from '../../composables/useToast.js' import { deleteKnowledgeDocument, fetchKnowledgeDocument, @@ -73,10 +74,13 @@ function setBodyScrollLocked(isLocked) { bodyOverscrollBehaviorSnapshot = '' } -export default { - name: 'PoliciesView', - emits: ['summary-change'], - setup(_, { emit }) { +export default { + name: 'PoliciesView', + components: { + ConfirmDialog + }, + emits: ['summary-change'], + setup(_, { emit }) { const { currentUser } = useSystemState() const { toast } = useToast() @@ -91,9 +95,11 @@ export default { const pageSizes = [10, 20, 50] const loading = ref(false) const uploadInput = ref(null) - const uploading = ref(false) - const deletingId = ref('') - const previewLoading = ref(false) + const uploading = ref(false) + const deletingId = ref('') + const deleteDialogOpen = ref(false) + const deleteTargetDocument = ref(null) + const previewLoading = ref(false) const previewBlobUrl = ref('') const previewError = ref('') const onlyOfficeLoading = ref(false) @@ -365,19 +371,35 @@ export default { await uploadFiles(event.dataTransfer?.files) } - async function handleDelete(document) { - if (!isAdmin.value || deletingId.value) { - return - } - - const confirmed = window.confirm(`确认删除文件“${document.name}”吗?`) - if (!confirmed) { - return - } - - deletingId.value = document.id - try { + async function handleDelete(document) { + if (!isAdmin.value || deletingId.value) { + return + } + + deleteTargetDocument.value = document + deleteDialogOpen.value = true + } + + function closeDeleteDialog() { + if (deletingId.value) { + return + } + + deleteDialogOpen.value = false + deleteTargetDocument.value = null + } + + async function confirmDeleteDocument() { + const document = deleteTargetDocument.value + if (!document || !isAdmin.value || deletingId.value) { + return + } + + deletingId.value = document.id + try { await deleteKnowledgeDocument(document.id) + deleteDialogOpen.value = false + deleteTargetDocument.value = null if (selectedDocument.value?.id === document.id) { closePreview() } @@ -459,11 +481,15 @@ export default { activeFolder, activePreviewPage, changePageSize, - closePreview, - excelPreviewTable, - currentPage, - currentPreviewPageIndex, - deletingId, + closePreview, + closeDeleteDialog, + confirmDeleteDocument, + excelPreviewTable, + currentPage, + currentPreviewPageIndex, + deleteDialogOpen, + deleteTargetDocument, + deletingId, documentSearch, filteredFolders, handleDelete, diff --git a/web/src/views/scripts/RequestsView.js b/web/src/views/scripts/RequestsView.js index c02ed15..64092ac 100644 --- a/web/src/views/scripts/RequestsView.js +++ b/web/src/views/scripts/RequestsView.js @@ -2,16 +2,25 @@ import { computed, ref, watch } from 'vue' import { normalizeRequestForUi } from '../../utils/requestViewModel.js' +function extractRowDate(value) { + const matched = String(value || '').match(/\d{4}-\d{2}-\d{2}/) + return matched ? matched[0] : '' +} + export default { name: 'RequestsView', props: { - filteredRequests: { type: Array, required: true } + filteredRequests: { type: Array, required: true }, + hasData: { type: Boolean, default: false }, + loading: { type: Boolean, default: false }, + error: { type: String, default: '' } }, - emits: ['ask', 'approve', 'reject', 'create-request'], + emits: ['ask', 'approve', 'reject', 'create-request', 'reload'], setup(props, { emit }) { const activeTab = ref('全部') - const tabs = ['全部', '待提交', '审批中', '待出行', '已完成'] - const filters = ['报销状态', '出差城市', '费用类型'] + const tabs = ['全部', '草稿', '审批中', '待补充', '已完成'] + const filters = ['报销状态', '报销类型', '所属主体'] + const listKeyword = ref('') const datePopover = ref(false) const rangeStart = ref('') @@ -55,38 +64,65 @@ export default { } const filteredRows = computed(() => { - if (activeTab.value === '全部') { - return rows.value - } + const keyword = listKeyword.value.trim().toLowerCase() - if (activeTab.value === '待提交') { - return rows.value.filter((row) => row.approval === '待提交') - } + return rows.value.filter((row) => { + const matchesKeyword = + !keyword + || [ + row.id, + row.documentNo, + row.typeLabel, + row.reason, + row.sceneTarget, + row.relatedCustomer, + row.riskSummary + ] + .filter(Boolean) + .join('') + .toLowerCase() + .includes(keyword) - if (activeTab.value === '审批中') { - return rows.value.filter((row) => row.approval === '审批中') - } + const applyDate = extractRowDate(row.applyTime) + const matchesDateRange = + !appliedStart.value + || !appliedEnd.value + || (applyDate && applyDate >= appliedStart.value && applyDate <= appliedEnd.value) - if (activeTab.value === '待出行') { - return rows.value.filter((row) => row.travel.includes('待')) - } + const matchesTab = + activeTab.value === '全部' + || (activeTab.value === '草稿' && row.approvalKey === 'draft') + || (activeTab.value === '审批中' && row.approvalKey === 'in_progress') + || (activeTab.value === '待补充' && row.approvalKey === 'supplement') + || (activeTab.value === '已完成' && row.approvalKey === 'completed') - if (activeTab.value === '已完成') { - return rows.value.filter((row) => row.approval === '已完成') - } - - return rows.value + return matchesKeyword && matchesDateRange && matchesTab + }) }) const totalCount = computed(() => filteredRows.value.length) const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize.value))) - const visibleRows = computed(() => { const start = (currentPage.value - 1) * pageSize.value return filteredRows.value.slice(start, start + pageSize.value) }) + const showTable = computed(() => !props.loading && !props.error && visibleRows.value.length > 0) + const showEmpty = computed(() => !props.loading && !props.error && visibleRows.value.length === 0) + const emptyState = computed(() => { + if (!props.hasData) { + return { + title: '暂无真实报销单据', + desc: '数据库里还没有可见的个人报销数据。保存草稿或提交报销后,会显示在这里。' + } + } - watch([activeTab, rows], () => { + return { + title: '没有匹配结果', + desc: '当前筛选条件下没有可展示的报销单据。' + } + }) + + watch([activeTab, rows, listKeyword, appliedStart, appliedEnd], () => { currentPage.value = 1 }) @@ -95,6 +131,7 @@ export default { activeTab, tabs, filters, + listKeyword, datePopover, rangeStart, rangeEnd, @@ -111,7 +148,10 @@ export default { filteredRows, totalCount, totalPages, - visibleRows + visibleRows, + showTable, + showEmpty, + emptyState } } } diff --git a/web/src/views/scripts/TravelReimbursementCreateView.js b/web/src/views/scripts/TravelReimbursementCreateView.js index 6c4deaf..9de7353 100644 --- a/web/src/views/scripts/TravelReimbursementCreateView.js +++ b/web/src/views/scripts/TravelReimbursementCreateView.js @@ -1,5 +1,6 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' +import ConfirmDialog from '../../components/shared/ConfirmDialog.vue' import { useSystemState } from '../../composables/useSystemState.js' import { recognizeOcrFiles } from '../../services/ocr.js' import { runOrchestrator } from '../../services/orchestrator.js' @@ -47,25 +48,29 @@ const EXPENSE_TYPE_LABELS = { meal: '伙食费', meeting: '会务费', entertainment: '业务招待费', + office: '办公费', + training: '培训费', + communication: '通讯费', + welfare: '福利费', other: '其他费用' } const REVIEW_SLOT_CONFIG = { expense_type: { - title: '报销类型', - hint: '请选择本次费用类型', + title: '报销分类', + hint: '请选择本次报销分类', status: '待确认', icon: 'mdi mdi-shape-outline' }, customer_name: { - title: '客户单位名称', + title: '关联客户', hint: '请补充客户单位全称', status: '待补充', icon: 'mdi mdi-domain' }, time_range: { - title: '业务发生时间', - hint: '请确认费用发生日期', + title: '发生时间', + hint: '请按 YYYY-MM-DD 补充业务发生日期', status: '待补充', icon: 'mdi mdi-calendar-month-outline' }, @@ -82,32 +87,44 @@ const REVIEW_SLOT_CONFIG = { icon: 'mdi mdi-storefront-outline' }, amount: { - title: '报销金额', + title: '金额', hint: '请补充本次费用金额', status: '待补充', icon: 'mdi mdi-cash' }, reason: { - title: '报销事由', - hint: '请补充本次费用背景或用途', + title: '场景 / 事由', + hint: '请补充本次费用场景或事由', status: '待补充', icon: 'mdi mdi-text-box-outline' }, participants: { - title: '同行人员信息', + title: '同行人员', hint: '请至少填写 1 名同行人员', status: '待补充', icon: 'mdi mdi-account-group-outline' }, attachments: { - title: '票据附件', + title: '票据状态', hint: '请上传发票/收据等票据附件', status: '未上传', icon: 'mdi mdi-paperclip' } } -const REVIEW_FALLBACK_GROUP_CODES = ['other', 'travel', 'transport', 'hotel', 'meal', 'entertainment'] +const REVIEW_FALLBACK_GROUP_CODES = [ + 'other', + 'travel', + 'transport', + 'hotel', + 'meal', + 'meeting', + 'entertainment', + 'office', + 'training', + 'communication', + 'welfare' +] const REVIEW_CATEGORY_PRESET_OPTIONS = [ { key: 'travel', label: '差旅费' }, @@ -128,6 +145,19 @@ const REVIEW_OTHER_CATEGORY_OPTIONS = [ ] const REVIEW_SCENE_OPTIONS = ['请客户吃饭', '出差行程', '住宿报销', '交通出行', '会务活动', '其他场景'] +const DATE_INPUT_FORMAT = 'YYYY-MM-DD' +const CATEGORY_CONFIDENCE_KEYWORDS = { + travel: [/出差|差旅|行程|机票|火车|高铁|航班/], + hotel: [/住宿|酒店|宾馆|民宿/], + transport: [/交通|打车|网约车|出租车|车费|地铁|公交|停车|过路费/], + meal: [/餐费|用餐|午餐|晚餐|早餐|伙食|餐饮/], + meeting: [/会务|会议|论坛|展会|参会|会场/], + entertainment: [/招待|宴请|请客|请客户|客户.*吃饭|商务用餐|陪同/], + office: [/办公|工位|耗材|白板|键盘|鼠标|打印|文具|采购/], + training: [/培训|授课|讲师|课程|签到|讲义/], + communication: [/通讯|电话|流量|话费|宽带|网络/], + welfare: [/福利|体检|团建|节日|慰问|关怀/] +} let messageSeed = 0 @@ -179,7 +209,9 @@ function sanitizeRequest(request) { const normalized = { id: String(request.id || '').trim(), + typeLabel: String(request.typeLabel || request.category || '').trim(), reason: String(request.reason || request.title || '').trim(), + entity: String(request.entity || '').trim(), city: String(request.city || request.location || '').trim(), period: String(request.period || '').trim(), applyTime: String(request.applyTime || request.occurredAt || '').trim(), @@ -424,6 +456,9 @@ function createEmptyInlineReviewState() { scene_label: '', reason_value: '', customer_name: '', + location: '', + merchant_name: '', + participants: '', attachment_names: '', attachment_count: 0, expense_type: '' @@ -444,6 +479,71 @@ function buildClientTimeContext() { } } +function formatDraftApplyTime(date = new Date()) { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + const hours = String(date.getHours()).padStart(2, '0') + const minutes = String(date.getMinutes()).padStart(2, '0') + return `${year}-${month}-${day} ${hours}:${minutes}` +} + +function buildDraftSavedPayload({ + draftPayload, + reviewPayload, + inlineState, + linkedRequest, + currentUser +}) { + const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] + const riskItems = buildReviewRiskItems(reviewPayload) + const missingItems = resolveReviewMissingSlotCards(reviewPayload) + const typeCode = resolveExpenseTypeCode(inlineState?.expense_type) + const amountNumber = parseAmountNumber(inlineState?.amount) + const location = String(inlineState?.location || linkedRequest?.city || '').trim() + const customerName = String(inlineState?.customer_name || '').trim() + const typeLabel = String(inlineState?.expense_type || linkedRequest?.typeLabel || resolveExpenseTypeLabel(typeCode)).trim() + const title = + String(inlineState?.reason_value || '').trim() + || String(inlineState?.scene_label || '').trim() + || String(draftPayload?.title || '').trim() + || `${typeLabel}报销草稿` + const sceneLabel = String(inlineState?.scene_label || summarizeReviewScene(title, typeLabel)).trim() || typeLabel + const attachmentSummary = documents.length + ? `${documents.length} 条识别票据 / ${documents.length} 份材料` + : String(inlineState?.attachment_names || '').trim() + ? '1 条识别票据 / 1 份材料' + : '待上传票据' + + return { + claimId: String(draftPayload?.claim_id || '').trim(), + claimNo: String(draftPayload?.claim_no || '').trim(), + person: String(currentUser?.name || '').trim() || '当前用户', + dept: String(currentUser?.role || '').trim() || '待补充部门', + entity: String(linkedRequest?.entity || '').trim() || 'Northstar China Ltd.', + typeCode, + typeLabel, + detailVariant: typeCode === 'travel' ? 'travel' : 'general', + title, + sceneLabel, + sceneTarget: location || customerName || '待补充', + location, + relatedCustomer: customerName, + occurredDisplay: String(inlineState?.occurred_date || '').trim() || '待补充', + applyTime: formatDraftApplyTime(), + amount: amountNumber === null ? 0 : amountNumber, + secondaryStatusLabel: typeCode === 'travel' ? '行程状态' : '票据状态', + secondaryStatusValue: documents.length ? '待继续完善' : '待上传票据', + secondaryStatusTone: documents.length ? 'warning' : 'neutral', + riskSummary: riskItems[0] || (missingItems.length ? '仍有识别字段待补充,请继续完善。' : 'AI 已生成草稿,可继续补充后提交。'), + attachmentSummary, + expenseTableSummary: documents.length + ? `已关联 ${documents.length} 份票据,请继续在报销页补充和确认` + : '当前尚未上传票据,请在报销页继续补充附件', + note: '该草稿由 AI 工作台根据当前识别结果生成,可在个人报销页面继续补充明细、票据与说明。' + } +} + function resolveReviewRecognizedSlotCards(reviewPayload) { return Array.isArray(reviewPayload?.slot_cards) ? reviewPayload.slot_cards.filter((item) => item.status !== 'missing') @@ -495,16 +595,74 @@ function resolveExpenseTypeCode(value) { return matched?.[0] || 'other' } -function formatAmountDisplay(value) { +function isValidIsoDateString(value) { const normalized = String(value || '').trim() - const match = normalized.match(/^(\d+(?:\.\d+)?)元$/) - if (!match) return normalized + if (!/^\d{4}-\d{2}-\d{2}$/.test(normalized)) { + return false + } - const amount = Number(match[1]) - if (!Number.isFinite(amount)) return normalized + 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 parseAmountNumber(value) { + const normalized = String(value || '') + .replace(/[,,\s]/g, '') + .replace(/[¥¥]/g, '') + .replace(/元/g, '') + .trim() + + if (!normalized || !/^\d+(?:\.\d+)?$/.test(normalized)) { + return null + } + + const amount = Number(normalized) + return Number.isFinite(amount) ? amount : null +} + +function normalizeAmountValue(value) { + const amount = parseAmountNumber(value) + if (amount === null) { + return '' + } return Number.isInteger(amount) ? `${amount}元` : `${amount.toFixed(2).replace(/\.?0+$/, '')}元` } +function extractAmountInputValue(value) { + const amount = parseAmountNumber(value) + if (amount === null) { + return String(value || '').trim() + } + return Number.isInteger(amount) ? String(amount) : amount.toFixed(2).replace(/\.?0+$/, '') +} + +function formatAmountDisplay(value) { + const amount = parseAmountNumber(value) + if (amount === null) { + return String(value || '').trim() + } + + return new Intl.NumberFormat('zh-CN', { + style: 'currency', + currency: 'CNY', + minimumFractionDigits: Number.isInteger(amount) ? 0 : 2, + maximumFractionDigits: Number.isInteger(amount) ? 0 : 2 + }).format(amount) +} + function buildReviewHeadline(reviewPayload, draftPayload) { const claimNo = String(draftPayload?.claim_no || '').trim() if (claimNo) { @@ -539,13 +697,13 @@ function buildReviewStateTone(reviewPayload, draftPayload) { function buildReviewAlertLabel(slotKey, expenseTypeLabel = '') { if (slotKey === 'customer_name') { - return expenseTypeLabel === '业务招待费' ? '业务招待费需补充客户单位名称' : '缺少客户单位名称' + return expenseTypeLabel === '业务招待费' ? '业务招待费需补充关联客户' : '缺少关联客户' } if (slotKey === 'participants') return '缺少同行人员' - if (slotKey === 'attachments') return '票据附件未上传' - if (slotKey === 'amount') return '报销金额待确认' - if (slotKey === 'time_range') return '业务发生时间待确认' - if (slotKey === 'reason') return '事由说明待补充' + if (slotKey === 'attachments') return '票据状态待补充' + if (slotKey === 'amount') return '金额待确认' + if (slotKey === 'time_range') return '发生时间待确认' + if (slotKey === 'reason') return '场景 / 事由待补充' if (slotKey === 'expense_type') return '报销类型待确认' if (slotKey === 'location') return '业务地点待补充' if (slotKey === 'merchant_name') return '酒店/商户待补充' @@ -709,12 +867,21 @@ function buildInlineReviewState(reviewPayload) { occurred_date: String( editFieldMap.occurred_date?.value || slotMap.time_range?.normalized_value || slotMap.time_range?.value || '' ).trim(), - amount: String( - editFieldMap.amount?.value || slotMap.amount?.normalized_value || slotMap.amount?.value || '' - ).trim(), + amount: normalizeAmountValue( + String(editFieldMap.amount?.value || slotMap.amount?.normalized_value || slotMap.amount?.value || '').trim() + ), scene_label: summarizeReviewScene(reasonValue, expenseType), reason_value: reasonValue, customer_name: String(editFieldMap.customer_name?.value || slotMap.customer_name?.value || '').trim(), + location: String( + editFieldMap.business_location?.value || + editFieldMap.location?.value || + slotMap.location?.normalized_value || + slotMap.location?.value || + '' + ).trim(), + merchant_name: String(editFieldMap.merchant_name?.value || slotMap.merchant_name?.value || '').trim(), + participants: String(editFieldMap.participants?.value || slotMap.participants?.value || '').trim(), attachment_names: attachmentNames, attachment_count: attachmentCount, expense_type: expenseType @@ -727,78 +894,213 @@ function buildReviewAttachmentStatus(reviewPayload) { return documents.length === 1 ? '已上传 1 份' : `已上传 ${documents.length} 份` } +function shouldShowReviewFactCard(reviewPayload, slotKey, value = '') { + const slotMap = buildReviewSlotMap(reviewPayload) + const slot = slotMap[slotKey] + return Boolean(String(value || slot?.normalized_value || slot?.value || '').trim()) || slot?.status === 'missing' +} + function buildReviewFactCards(reviewPayload, inlineState = createEmptyInlineReviewState()) { const attachmentStatus = inlineState.attachment_count > 0 ? `待保存 ${inlineState.attachment_count} 份` : buildReviewAttachmentStatus(reviewPayload) - - return [ + const cards = [ { key: 'occurred_date', label: '发生时间', value: String(inlineState.occurred_date || '').trim() || '待补充', icon: 'mdi mdi-calendar-month-outline', - editor: 'date' + editor: 'date', + modelKey: 'occurred_date', + placeholder: `例如 ${DATE_INPUT_FORMAT}` }, { key: 'amount', label: '金额', value: formatAmountDisplay(inlineState.amount) || '待补充', icon: 'mdi mdi-cash', - editor: 'text' + editor: 'amount', + modelKey: 'amount', + placeholder: '例如 200.00' }, { key: 'scene', - label: '场景', + label: '场景 / 事由', value: String(inlineState.scene_label || '').trim() || '待补充', icon: 'mdi mdi-silverware-fork-knife', - editor: 'select' + editor: 'select', + modelKey: 'scene_label', + placeholder: '请选择场景' }, { key: 'customer_name', label: '关联客户', value: String(inlineState.customer_name || '').trim() || '待补充', icon: 'mdi mdi-domain', - editor: 'text' + editor: 'text', + modelKey: 'customer_name', + placeholder: '请输入客户名称' }, { key: 'attachments', label: '票据状态', value: attachmentStatus, icon: 'mdi mdi-file-document-outline', - editor: 'upload' + editor: 'upload', + modelKey: 'attachment_names', + placeholder: '' } ] + + if (shouldShowReviewFactCard(reviewPayload, 'location', inlineState.location)) { + cards.splice(4, 0, { + key: 'location', + label: '业务地点', + value: String(inlineState.location || '').trim() || '待补充', + icon: 'mdi mdi-map-marker-outline', + editor: 'text', + modelKey: 'location', + placeholder: '请输入业务地点' + }) + } + + if (shouldShowReviewFactCard(reviewPayload, 'merchant_name', inlineState.merchant_name)) { + cards.splice(cards.length - 1, 0, { + key: 'merchant_name', + label: '酒店/商户', + value: String(inlineState.merchant_name || '').trim() || '待补充', + icon: 'mdi mdi-storefront-outline', + editor: 'text', + modelKey: 'merchant_name', + placeholder: '请输入酒店或商户名称' + }) + } + + if (shouldShowReviewFactCard(reviewPayload, 'participants', inlineState.participants)) { + cards.splice(cards.length - 1, 0, { + key: 'participants', + label: '同行人员', + value: String(inlineState.participants || '').trim() || '待补充', + icon: 'mdi mdi-account-group-outline', + editor: 'text', + modelKey: 'participants', + placeholder: '例如 客户 2 人,我方 1 人' + }) + } + + return cards } -function buildReviewCategoryOptions(selectedLabel = '') { +function buildReviewEvidenceText(reviewPayload, inlineState = createEmptyInlineReviewState()) { + const slotMap = buildReviewSlotMap(reviewPayload) + const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] + + return [ + String(inlineState.reason_value || '').trim(), + String(inlineState.scene_label || '').trim(), + String(slotMap.reason?.value || slotMap.reason?.raw_value || '').trim(), + ...documents.map((item) => + [item.scene_label, item.summary, item.filename, ...(Array.isArray(item.warnings) ? item.warnings : [])] + .filter(Boolean) + .join(' ') + ) + ] + .filter(Boolean) + .join(' ') + .toLowerCase() +} + +function resolveReviewCategoryTextScore(text, categoryCode) { + const patterns = CATEGORY_CONFIDENCE_KEYWORDS[categoryCode] + if (!patterns?.length || !text) { + return 0 + } + return patterns.some((pattern) => pattern.test(text)) + ? { + travel: 0.84, + hotel: 0.82, + transport: 0.8, + meal: 0.76, + meeting: 0.78, + entertainment: 0.88, + office: 0.74, + training: 0.77, + communication: 0.7, + welfare: 0.72 + }[categoryCode] || 0 + : 0 +} + +function resolveReviewCategoryDocumentScore(reviewPayload, categoryCode) { + const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] + const matchedScores = documents + .filter((item) => resolveExpenseTypeCode(item?.suggested_expense_type) === categoryCode) + .map((item) => Number(item?.avg_score || 0)) + .filter((score) => Number.isFinite(score) && score > 0) + + if (!matchedScores.length) { + return 0 + } + + return matchedScores.reduce((sum, score) => sum + score, 0) / matchedScores.length +} + +function resolveReviewCategoryConfidenceScore(reviewPayload, selectedLabel = '', inlineState = createEmptyInlineReviewState()) { + const normalizedLabel = String(selectedLabel || '').trim() + if (!normalizedLabel) { + return 0 + } + + const selectedCode = resolveExpenseTypeCode(normalizedLabel) + const slotMap = buildReviewSlotMap(reviewPayload) + const expenseSlot = slotMap.expense_type + const recognizedCode = resolveExpenseTypeCode(expenseSlot?.normalized_value || expenseSlot?.value || '') + let score = 0 + + if (recognizedCode === selectedCode) { + score = Math.max(score, Number(expenseSlot?.confidence || 0)) + } + + score = Math.max(score, resolveReviewCategoryDocumentScore(reviewPayload, selectedCode)) + score = Math.max(score, resolveReviewCategoryTextScore(buildReviewEvidenceText(reviewPayload, inlineState), selectedCode)) + + if (!score && normalizedLabel) { + score = selectedCode === 'other' ? 0.52 : 0.58 + } + + return Math.max(0, Math.min(0.98, Number(score.toFixed(2)))) +} + +function buildReviewCategoryOptions(reviewPayload, selectedLabel = '', inlineState = createEmptyInlineReviewState()) { const presetLabels = REVIEW_CATEGORY_PRESET_OPTIONS.filter((item) => !item.is_other).map((item) => item.label) return REVIEW_CATEGORY_PRESET_OPTIONS.map((item, index) => ({ ...item, active: item.is_other ? Boolean(selectedLabel) && !presetLabels.includes(selectedLabel) : item.label === selectedLabel, - caption: index === 0 ? '常用' : index < 5 ? '常用' : '更多' + confidenceLabel: item.is_other + ? formatConfidenceLabel(resolveReviewCategoryConfidenceScore(reviewPayload, selectedLabel, inlineState)) + : formatConfidenceLabel(resolveReviewCategoryConfidenceScore(reviewPayload, item.label, inlineState)), + caption: item.is_other + ? selectedLabel && !presetLabels.includes(selectedLabel) + ? `${selectedLabel} · ${formatConfidenceLabel(resolveReviewCategoryConfidenceScore(reviewPayload, selectedLabel, inlineState))}` + : '点击选择更多类型' + : `置信度 ${formatConfidenceLabel(resolveReviewCategoryConfidenceScore(reviewPayload, item.label, inlineState))}`, + groupLabel: index === 0 ? '常用' : index < 5 ? '常用' : '更多' })) } -function buildReviewPanelConfidence(reviewPayload) { - const recognized = resolveReviewRecognizedSlotCards(reviewPayload).filter((item) => - ['expense_type', 'time_range', 'amount', 'customer_name', 'attachments'].includes(item.key) +function buildReviewPanelConfidence(reviewPayload, inlineState = createEmptyInlineReviewState()) { + return formatConfidenceLabel( + resolveReviewCategoryConfidenceScore(reviewPayload, inlineState.expense_type, inlineState) ) - if (!recognized.length) return '0%' - const average = recognized.reduce((sum, item) => sum + Number(item.confidence || 0), 0) / recognized.length - return formatConfidenceLabel(average) } function buildReviewRiskScore(reviewPayload) { - const missingCount = resolveReviewMissingSlotCards(reviewPayload).length - const riskPenalty = resolveReviewRiskBriefs(reviewPayload).reduce((sum, item) => { - if (item.level === 'high') return sum + 10 - if (item.level === 'warning') return sum + 6 - return sum + 3 - }, 0) - const score = 92 - missingCount * 9 - riskPenalty - return Math.max(28, Math.min(98, score)) + const score = Number(reviewPayload?.risk_score) + if (!Number.isFinite(score) || score <= 0) { + return null + } + return Math.max(0, Math.min(100, Math.round(score))) } function buildMissingRiskLine(slotKey, expenseTypeLabel = '') { @@ -826,30 +1128,17 @@ function buildMissingRiskLine(slotKey, expenseTypeLabel = '') { } function buildReviewRiskSummary(reviewPayload) { - if (resolveReviewMissingSlotCards(reviewPayload).length) { - return '存在一定合规风险,请尽快补充完整信息以降低风险。' - } if (resolveReviewRiskBriefs(reviewPayload).length) { - return '当前识别结果可继续处理,但提交前仍建议核对以下提醒。' + return '当前识别到了合规提醒,提交前建议逐项核对。' } - return '当前未发现明显阻断项,确认无误后可以继续下一步。' + return '当前版本暂未生成风险评分结果。' } function buildReviewRiskItems(reviewPayload) { - const slotMap = buildReviewSlotMap(reviewPayload) - const expenseTypeLabel = String(slotMap.expense_type?.value || '').trim() - const items = [] - - for (const slot of resolveReviewMissingSlotCards(reviewPayload)) { - items.push(buildMissingRiskLine(slot.key, expenseTypeLabel)) - } - - for (const brief of resolveReviewRiskBriefs(reviewPayload)) { - if (items.includes(brief.content)) continue - items.push(brief.content) - } - - return items.slice(0, 4) + return resolveReviewRiskBriefs(reviewPayload) + .map((brief) => String(brief?.content || '').trim()) + .filter(Boolean) + .slice(0, 4) } function normalizeInlineReviewComparableState(state) { @@ -860,6 +1149,9 @@ function normalizeInlineReviewComparableState(state) { scene_label: String(source.scene_label || '').trim(), reason_value: String(source.reason_value || '').trim(), customer_name: String(source.customer_name || '').trim(), + location: String(source.location || '').trim(), + merchant_name: String(source.merchant_name || '').trim(), + participants: String(source.participants || '').trim(), attachment_names: String(source.attachment_names || '').trim(), expense_type: String(source.expense_type || '').trim() } @@ -882,6 +1174,15 @@ function buildInlineReviewChangedLines(baseState, nextState, pendingFiles = []) if (base.customer_name !== next.customer_name) { lines.push(`关联客户 ${next.customer_name || '待补充'}`) } + if (base.location !== next.location) { + lines.push(`业务地点 ${next.location || '待补充'}`) + } + if (base.merchant_name !== next.merchant_name) { + lines.push(`酒店/商户 ${next.merchant_name || '待补充'}`) + } + if (base.participants !== next.participants) { + lines.push(`同行人员 ${next.participants || '待补充'}`) + } if (base.expense_type !== next.expense_type) { lines.push(`报销分类 ${next.expense_type || '待补充'}`) } @@ -907,6 +1208,9 @@ function mergeInlineReviewFields(baseFields, inlineState) { occurred_date: inlineState.occurred_date, amount: inlineState.amount, customer_name: inlineState.customer_name, + business_location: inlineState.location, + merchant_name: inlineState.merchant_name, + participants: inlineState.participants, reason: inlineState.reason_value || inlineState.scene_label, attachment_names: inlineState.attachment_names } @@ -1084,6 +1388,9 @@ function buildAgentInsight(payload, fileNames = [], filePreviews = []) { export default { name: 'TravelReimbursementCreateView', + components: { + ConfirmDialog + }, props: { initialPrompt: { type: String, @@ -1106,7 +1413,7 @@ export default { default: null } }, - emits: ['close'], + emits: ['close', 'draft-saved'], setup(props, { emit }) { const { currentUser } = useSystemState() @@ -1146,6 +1453,7 @@ export default { const reviewInlineBaseFields = ref([]) const reviewInlinePendingFiles = ref([]) const reviewInlineEditorKey = ref('') + const reviewInlineErrors = ref({}) const reviewOtherCategoryOpen = ref(false) const sourceLabel = computed(() => SOURCE_LABELS[props.entrySource] ?? '来自 AI 工作台') const canSubmit = computed( @@ -1176,7 +1484,17 @@ export default { ) const reviewIntentText = computed(() => buildReviewIntentText(activeReviewPayload.value)) const reviewFactCards = computed(() => buildReviewFactCards(activeReviewPayload.value, reviewInlineForm.value)) - const reviewCategoryOptions = computed(() => buildReviewCategoryOptions(reviewInlineForm.value.expense_type)) + const reviewCategoryOptions = computed(() => + buildReviewCategoryOptions(activeReviewPayload.value, reviewInlineForm.value.expense_type, reviewInlineForm.value) + ) + const reviewOtherCategoryOptions = computed(() => + REVIEW_OTHER_CATEGORY_OPTIONS.map((item) => ({ + ...item, + confidenceLabel: formatConfidenceLabel( + resolveReviewCategoryConfidenceScore(activeReviewPayload.value, item.label, reviewInlineForm.value) + ) + })) + ) const reviewSelectedOtherCategory = computed(() => { const presetLabels = REVIEW_CATEGORY_PRESET_OPTIONS.filter((item) => !item.is_other).map((item) => item.label) return presetLabels.includes(reviewInlineForm.value.expense_type) ? '' : reviewInlineForm.value.expense_type @@ -1189,10 +1507,12 @@ export default { reviewInlinePendingFiles.value ).length > 0 ) - const reviewPanelConfidence = computed(() => buildReviewPanelConfidence(activeReviewPayload.value)) + const reviewPanelConfidence = computed(() => buildReviewPanelConfidence(activeReviewPayload.value, reviewInlineForm.value)) const reviewRiskScore = computed(() => buildReviewRiskScore(activeReviewPayload.value)) const reviewRiskSummary = computed(() => buildReviewRiskSummary(activeReviewPayload.value)) const reviewRiskItems = computed(() => buildReviewRiskItems(activeReviewPayload.value)) + const reviewRiskEmpty = computed(() => reviewRiskScore.value === null && !reviewRiskItems.value.length) + const reviewRiskActionAvailable = computed(() => reviewRiskItems.value.length > 0) const recognizedNarratives = computed(() => buildReviewRecognizedLines(activeReviewPayload.value)) const reviewRecognitionNotes = computed(() => buildReviewRecognitionNotes(activeReviewPayload.value)) const reviewDocumentSummaries = computed(() => buildReviewDocumentSummaries(activeReviewPayload.value)) @@ -1217,6 +1537,7 @@ export default { reviewInlineBaseFields.value = cloneReviewEditFields(payload?.edit_fields) reviewInlinePendingFiles.value = [] reviewInlineEditorKey.value = '' + reviewInlineErrors.value = {} reviewOtherCategoryOpen.value = false }, { immediate: true } @@ -1269,6 +1590,7 @@ export default { attachment_names: files.map((file) => file.name).join('、'), attachment_count: files.length } + clearInlineReviewFieldError('attachments') reviewInlineEditorKey.value = '' } else { attachedFiles.value = files @@ -1285,13 +1607,48 @@ export default { submitComposer() } + function setInlineReviewFieldError(key, message) { + reviewInlineErrors.value = { + ...reviewInlineErrors.value, + [key]: String(message || '').trim() + } + } + + function clearInlineReviewFieldError(key) { + if (!reviewInlineErrors.value[key]) { + return + } + + const nextErrors = { ...reviewInlineErrors.value } + delete nextErrors[key] + reviewInlineErrors.value = nextErrors + } + function openInlineReviewEditor(key) { if (!activeReviewPayload.value || submitting.value || reviewActionBusy.value) return if (key === 'attachments') { triggerFileUpload('inline-review') return } - reviewInlineEditorKey.value = reviewInlineEditorKey.value === key ? '' : key + + if (reviewInlineEditorKey.value && reviewInlineEditorKey.value !== key && !commitInlineReviewEditor()) { + return + } + + if (reviewInlineEditorKey.value === key) { + commitInlineReviewEditor() + return + } + + if (key === 'amount') { + reviewInlineForm.value = { + ...reviewInlineForm.value, + amount: extractAmountInputValue(reviewInlineForm.value.amount) + } + } + + clearInlineReviewFieldError(key) + reviewInlineEditorKey.value = key if (key !== 'expense_type') { reviewOtherCategoryOpen.value = false } @@ -1303,16 +1660,41 @@ export default { } function commitInlineReviewEditor() { - reviewInlineForm.value = { + const activeEditorKey = reviewInlineEditorKey.value + const nextForm = { ...reviewInlineForm.value, occurred_date: String(reviewInlineForm.value.occurred_date || '').trim(), amount: String(reviewInlineForm.value.amount || '').trim(), customer_name: String(reviewInlineForm.value.customer_name || '').trim(), + location: String(reviewInlineForm.value.location || '').trim(), + merchant_name: String(reviewInlineForm.value.merchant_name || '').trim(), + participants: String(reviewInlineForm.value.participants || '').trim(), scene_label: String(reviewInlineForm.value.scene_label || '').trim(), reason_value: String(reviewInlineForm.value.reason_value || reviewInlineForm.value.scene_label || '').trim(), expense_type: String(reviewInlineForm.value.expense_type || '').trim() } + + if (activeEditorKey === 'occurred_date' && nextForm.occurred_date && !isValidIsoDateString(nextForm.occurred_date)) { + setInlineReviewFieldError('occurred_date', `请输入正确的时间格式:${DATE_INPUT_FORMAT}`) + return false + } + + if (activeEditorKey === 'amount' && nextForm.amount) { + const normalizedAmount = normalizeAmountValue(nextForm.amount) + if (!normalizedAmount) { + setInlineReviewFieldError('amount', '请输入正确的数字金额,例如 200 或 200.50') + return false + } + nextForm.amount = normalizedAmount + } + + if (activeEditorKey) { + clearInlineReviewFieldError(activeEditorKey) + } + + reviewInlineForm.value = nextForm reviewInlineEditorKey.value = '' + return true } function selectInlineScene(scene) { @@ -1367,6 +1749,10 @@ export default { async function saveInlineReviewChanges() { if (!activeReviewPayload.value || !reviewInlineDirty.value || reviewActionBusy.value) return + if (reviewInlineEditorKey.value && !commitInlineReviewEditor()) { + return + } + reviewActionBusy.value = true try { const fields = mergeInlineReviewFields(reviewInlineBaseFields.value, reviewInlineForm.value) @@ -1445,6 +1831,8 @@ export default { submitting.value = true nextTick(scrollToBottom) + let responsePayload = null + try { const user = currentUser.value || {} let ocrPayload = null @@ -1483,6 +1871,7 @@ export default { ...extraContext } }) + responsePayload = payload conversationId.value = String(payload?.conversation_id || '').trim() || conversationId.value draftClaimId.value = @@ -1519,6 +1908,8 @@ export default { submitting.value = false nextTick(scrollToBottom) } + + return responsePayload } function openCancelReviewDialog(message) { @@ -1590,21 +1981,50 @@ export default { return } + if (reviewInlineEditorKey.value && !commitInlineReviewEditor()) { + return + } + reviewActionBusy.value = true try { - const fields = cloneReviewEditFields(message?.reviewPayload?.edit_fields) - await submitComposer({ + const baseFields = reviewInlineBaseFields.value.length + ? reviewInlineBaseFields.value + : cloneReviewEditFields(message?.reviewPayload?.edit_fields) + const fields = mergeInlineReviewFields(baseFields, reviewInlineForm.value) + const reviewChangedUserText = reviewInlineDirty.value + ? buildInlineReviewUserText( + reviewInlineBaseForm.value, + reviewInlineForm.value, + reviewInlinePendingFiles.value + ) + : '' + const payload = await submitComposer({ rawText: actionType === 'save_draft' ? '请按当前已识别信息先保存草稿,缺失字段后续再补。' : '我已核对右侧识别结果,请进入下一步。', - userText: actionType === 'save_draft' ? '我先按当前信息保存草稿。' : '我确认当前识别结果,继续下一步。', + userText: + reviewChangedUserText + || (actionType === 'save_draft' ? '我先按当前信息保存草稿。' : '我确认当前识别结果,继续下一步。'), pendingText: actionType === 'save_draft' ? '正在保存当前草稿...' : '正在进入下一步...', extraContext: { review_action: actionType, review_form_values: buildReviewFormValues(fields) } }) + + if (actionType === 'save_draft' && payload?.result?.draft_payload?.claim_no) { + emit( + 'draft-saved', + buildDraftSavedPayload({ + draftPayload: payload.result.draft_payload, + reviewPayload: payload?.result?.review_payload || message?.reviewPayload || activeReviewPayload.value, + inlineState: reviewInlineForm.value, + linkedRequest: linkedRequest.value, + currentUser: currentUser.value + }) + ) + } } finally { reviewActionBusy.value = false } @@ -1633,18 +2053,23 @@ export default { reviewIntentText, reviewFactCards, reviewCategoryOptions, + reviewOtherCategoryOptions, reviewSelectedOtherCategory, reviewInlineDirty, reviewInlineForm, reviewInlineEditorKey, + reviewInlineErrors, reviewOtherCategoryOpen, reviewInlinePendingFiles, + DATE_INPUT_FORMAT, REVIEW_SCENE_OPTIONS, REVIEW_OTHER_CATEGORY_OPTIONS, reviewPanelConfidence, reviewRiskScore, reviewRiskSummary, reviewRiskItems, + reviewRiskEmpty, + reviewRiskActionAvailable, recognizedNarratives, reviewRecognitionNotes, reviewDocumentSummaries, @@ -1676,6 +2101,7 @@ export default { openInlineReviewEditor, closeInlineReviewEditor, commitInlineReviewEditor, + clearInlineReviewFieldError, selectInlineScene, selectReviewCategory, selectReviewOtherCategory, diff --git a/web/src/views/scripts/TravelRequestDetailView.js b/web/src/views/scripts/TravelRequestDetailView.js index f151c77..80579b0 100644 --- a/web/src/views/scripts/TravelRequestDetailView.js +++ b/web/src/views/scripts/TravelRequestDetailView.js @@ -1,139 +1,311 @@ -import { computed, ref } from 'vue' +import { computed, 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 { 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: '其他费用' } +] + +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 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 [ + { + 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: request.sceneTarget, + amount: request.amountDisplay, + status: '待补充', + tone: 'bad', + attachmentStatus: '待上传', + attachmentHint: '请在此单据中继续补充附件', + attachmentTone: 'missing', + attachments: [], + riskLabel: '待补材料', + riskText: request.riskSummary, + riskTone: 'medium' + } + ] +} + +function isPlaceholderValue(value) { + const text = String(value || '').trim() + if (!text) { + return true + } + + return ['待补充', '暂无', '无', '未知', '处理中'].includes(text.replace(/\s+/g, '')) +} + +function isValidIsoDate(value) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(String(value || '').trim())) { + return false + } + + const nextDate = new Date(`${value}T00:00:00`) + return !Number.isNaN(nextDate.getTime()) && nextDate.toISOString().slice(0, 10) === value +} + +function buildExpenseDraftIssues(item) { + const issues = [] + + if (!isValidIsoDate(item.itemDate)) { + issues.push('缺少日期') + } + if (isPlaceholderValue(item.itemType)) { + issues.push('缺少费用项目') + } + if (isPlaceholderValue(item.itemReason)) { + issues.push('缺少说明') + } + if (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 = [] + + if (isPlaceholderValue(request.profileName)) { + issues.push('申请人未完善') + } + if (isPlaceholderValue(request.profileDepartment)) { + issues.push('所属部门未完善') + } + if (isPlaceholderValue(request.typeLabel)) { + issues.push('报销类型未完善') + } + if (isPlaceholderValue(request.reason)) { + issues.push('报销事由未完善') + } + if (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)] +} export default { name: 'TravelRequestDetailView', + components: { + ConfirmDialog + }, props: { - request: { - type: Object, - default: () => ({}) - } -}, - emits: ['backToRequests', 'openAssistant'] , + request: { + type: Object, + default: () => ({}) + } + }, + emits: ['backToRequests', 'openAssistant', 'request-updated', 'request-deleted'], setup(props, { emit }) { + const { toast } = useToast() const expandedExpenseId = ref(null) - const aiEntryOpen = ref(false) - const aiDraft = ref('') - const aiFileInput = ref(null) - const aiEntrySeed = ref(2) - const pendingAiExpense = ref(null) - const uploadedAiFiles = ref([]) - const expenseItems = ref([ + const editingExpenseId = ref('') + const savingExpenseId = ref('') + const submitBusy = ref(false) + const deleteBusy = ref(false) + const deleteDialogOpen = ref(false) + 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: '', + profileName: '当前申请人', + profileDepartment: '待补充部门', + profileAvatar: '申' + } + ) + }) + + 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 profile = computed(() => ({ + name: request.value.profileName, + department: request.value.profileDepartment, + avatar: request.value.profileAvatar + })) + + const expenseItems = ref([]) + + watch( + request, + (nextRequest) => { + expenseItems.value = + Array.isArray(nextRequest.expenseItems) && nextRequest.expenseItems.length + ? nextRequest.expenseItems + : buildFallbackExpenseItems(nextRequest) + expandedExpenseId.value = null + editingExpenseId.value = '' + }, + { immediate: true } + ) + + const heroStats = computed(() => [ { - id: 'exp-1', - time: '07-08', - dayLabel: '第 1 天', - name: '高铁票', - category: '交通', - desc: '上海虹桥 -> 杭州东', - detail: '客户方案汇报前往现场', - amount: '¥236.00', - status: '规则通过', - tone: 'ok', - attachmentStatus: '2 份附件', - attachmentHint: '车票 + 行程单', - attachmentTone: 'ok', - attachments: ['高铁票.pdf', '行程单.pdf'], - riskLabel: '规则通过', - riskText: '票据与行程匹配', - riskTone: 'low' + label: '金额', + value: request.value.amountDisplay, + kind: 'text' }, { - id: 'exp-2', - time: '07-09', - dayLabel: '第 2 天', - name: '酒店住宿', - category: '住宿', - desc: '杭州西湖商务酒店', - detail: '1 晚住宿,含早餐', - amount: '¥1,180.00', - status: '待补材料', - tone: 'bad', - attachmentStatus: '缺 1 份', - attachmentHint: '缺少入住清单', - attachmentTone: 'partial', - attachments: ['酒店发票.jpg'], - riskLabel: '待补材料', - riskText: '需补酒店入住清单', - riskTone: 'medium' + label: '当前节点', + value: request.value.node, + kind: 'pill', + className: 'state-pill', + tone: request.value.approvalTone }, { - id: 'exp-3', - time: '07-10', - dayLabel: '第 3 天', - name: '出租车', - category: '市内交通', - desc: '客户公司往返酒店', - detail: '含夜间打车 2 次', - amount: '¥128.00', - status: '需说明', - tone: 'bad', - attachmentStatus: '3 份附件', - attachmentHint: '发票已上传', - attachmentTone: 'ok', - attachments: ['出租车发票1.jpg', '出租车发票2.jpg', '打车订单.png'], - riskLabel: '超标说明', - riskText: '1 笔夜间交通需补充说明', - riskTone: 'medium' + label: '审批状态', + value: request.value.approval, + kind: 'pill', + className: 'approval-pill', + tone: request.value.approvalTone }, { - id: 'exp-4', - time: '07-11', - dayLabel: '第 4 天', - name: '餐补', - category: '补贴', - desc: '差旅餐补', - detail: '按 4 天标准自动计算', - amount: '¥320.00', - status: '规则通过', - tone: 'ok', - attachmentStatus: '系统生成', - attachmentHint: '无需上传附件', - attachmentTone: 'neutral', - attachments: [], - riskLabel: '规则通过', - riskText: '补贴标准校验通过', - riskTone: 'low' + label: request.value.secondaryStatusLabel, + value: request.value.secondaryStatusValue, + kind: 'pill', + className: 'risk-pill', + tone: request.value.secondaryStatusTone } ]) - const request = computed(() => ({ - id: props.request?.id ?? 'BR240712001', - reason: props.request?.reason ?? '客户方案汇报', - city: props.request?.city ?? '上海', - period: props.request?.period ?? '07-08~07-11 (4天)', - applyTime: props.request?.applyTime ?? '2024-07-07', - amount: props.request?.amount ?? '¥3,680.00', - node: props.request?.node ?? '财务审核', - approval: props.request?.approval ?? '审批中', - approvalTone: props.request?.approvalTone ?? 'info', - travel: props.request?.travel ?? '已订酒店/机票', - travelTone: props.request?.travelTone ?? 'low' - })) + 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' } + ] - const profile = { - name: '张晓明', - department: '财务管理员', - avatar: '张' - } + 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' } + ] + } - const summaryItems = [ - { label: '出差城市', value: request.value.city, icon: 'mdi mdi-map-marker-path' }, - { label: '出差区间', value: request.value.period, icon: 'mdi mdi-clock-outline' }, - { label: '票据关联', value: '6 条明细 / 5 份材料', icon: 'mdi mdi-file-document-multiple-outline' }, - { label: '商旅状态', value: request.value.travel, icon: 'mdi mdi-airplane' }, - { 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.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' } + ] + }) - const heroSummaryItems = computed(() => [ - { label: '单号', value: request.value.id, icon: 'mdi mdi-pound-box-outline' }, - { label: '申请类型', value: '差旅费申请/报销', icon: 'mdi mdi-tag-multiple' }, - ...summaryItems - ]) + const progressSteps = computed(() => + Array.isArray(request.value.progressSteps) && request.value.progressSteps.length + ? request.value.progressSteps + : buildFallbackProgressSteps() + ) const currentProgressRingMotion = { initial: { scale: 1, - opacity: 0.34, + opacity: 0.34 }, enter: { scale: [1, 1.42, 1.78], @@ -144,41 +316,186 @@ export default { repeatType: 'loop', repeatDelay: 0.85, ease: 'easeOut', - times: [0, 0.5, 1], - }, - }, + times: [0, 0.5, 1] + } + } } - const progressSteps = computed(() => { - return [ - { index: 1, label: '提交申请', time: '07-11 08:46', done: true, active: true }, - { index: 2, label: '票据识别', time: '07-11 08:48', done: true, active: true }, - { index: 3, label: '费用归类', time: '07-11 08:49', done: true, active: true }, - { index: 4, label: '部门负责人审批', time: '07-11 11:28', done: true, active: true }, - { index: 5, label: '财务审批', time: '进行中', active: true, current: true }, - { index: 6, label: '归档入账', time: '待处理' } - ] - }) - 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 canSendAiEntry = computed(() => Boolean(aiDraft.value.trim() || uploadedAiFiles.value.length)) - const detailNote = '本次出差用于客户方案汇报与现场沟通,需覆盖往返交通、住宿及市内交通费用。已完成主要票据上传,待补酒店入住清单后即可进入完整审批流程。' + const uploadedExpenseCount = computed(() => expenseItems.value.filter((item) => item.attachments.length).length) + 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 + ? '当前草稿信息完整,可以提交审批。' + : '当前草稿仍有未完善字段,提交按钮会保持禁用。' + ) function toggleExpenseAttachments(id) { expandedExpenseId.value = expandedExpenseId.value === id ? null : id } + function resolveExpenseIssues(item) { + return buildExpenseDraftIssues(item) + } + function showExpenseRisk(item) { - return Boolean(item.riskText) + return Boolean(resolveExpenseIssues(item).length || item.riskText) + } + + function startExpenseEdit(item) { + if (!isDraftRequest.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 || (item.detail === '待补充' ? '' : item.detail) + expenseEditor.itemAmount = item.itemAmount ? String(item.itemAmount) : '' + expenseEditor.invoiceId = item.invoiceId || '' + expandedExpenseId.value = null + } + + 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 (isPlaceholderValue(expenseEditor.itemLocation)) { + return '请输入业务地点。' + } + + const amount = Number(expenseEditor.itemAmount) + if (!Number.isFinite(amount) || amount <= 0) { + return '请输入大于 0 的费用金额。' + } + if (isPlaceholderValue(expenseEditor.invoiceId)) { + return '请输入票据标识或附件名称。' + } + + return '' + } + + async function saveExpenseEdit(item) { + if (!request.value.claimId) { + toast('当前草稿缺少 claimId,暂时无法保存费用明细。') + return + } + + const validationError = validateExpenseEditor() + if (validationError) { + toast(validationError) + return + } + + savingExpenseId.value = item.id + try { + 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() + }) + editingExpenseId.value = '' + toast('费用明细已保存。') + 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 { + await submitExpenseClaim(request.value.claimId) + toast(`${request.value.id} 已提交审批。`) + emit('request-updated', { claimId: request.value.claimId }) + } catch (error) { + toast(error?.message || '提交审批失败,请稍后重试。') + } finally { + submitBusy.value = false + } + } + + async function handleDeleteDraft() { + if (!request.value.claimId) { + toast('当前草稿缺少 claimId,暂时无法删除。') + return + } + + deleteDialogOpen.value = true + } + + function closeDeleteDialog() { + if (deleteBusy.value) { + return + } + + deleteDialogOpen.value = false + } + + async function confirmDeleteDraft() { + 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 openAiEntry() { - aiEntryOpen.value = false emit('openAssistant', { source: 'detail', prompt: '', @@ -186,266 +503,45 @@ export default { }) } - function closeAiEntry() { - aiEntryOpen.value = false - aiDraft.value = '' - pendingAiExpense.value = null - uploadedAiFiles.value = [] - if (aiFileInput.value) { - aiFileInput.value.value = '' - } - } - - function parseCurrency(value) { - return Number.parseFloat(String(value).replace(/[^\d.]/g, '')) || 0 - } - - function formatCurrency(value) { - return `¥${value.toFixed(2)}` - } - - function buildNextExpenseId() { - aiEntrySeed.value += 1 - return `exp-ai-${aiEntrySeed.value}` - } - - function inferExpenseCategory(text) { - if (/高铁|火车|机票|航班|打车|出租车|地铁|公交|交通/.test(text)) return '交通' - if (/酒店|住宿|房费/.test(text)) return '住宿' - if (/餐|午饭|晚饭|早餐|餐补/.test(text)) return '餐饮' - return '其他' - } - - function inferExpenseName(text, category) { - if (/高铁/.test(text)) return '高铁票' - if (/机票|航班/.test(text)) return '机票' - if (/出租车|打车/.test(text)) return '出租车' - if (/酒店|住宿/.test(text)) return '酒店住宿' - if (/餐补/.test(text)) return '餐补' - if (/餐|午饭|晚饭|早餐/.test(text)) return '餐饮' - return `${category}费用` - } - - function inferAttachments(text, uploadedFiles = []) { - if (uploadedFiles.length) { - return { - status: `${uploadedFiles.length} 份附件`, - hint: uploadedFiles.map((file) => file.name).join(' + '), - tone: 'ok', - files: uploadedFiles.map((file) => file.name), - } - } - - if (/无需|免附件|系统生成/.test(text)) { - return { - status: '系统生成', - hint: '无需上传附件', - tone: 'neutral', - files: [], - } - } - - const uploaded = /已上传|上传了|附上|附件/.test(text) - const receipt = /发票/.test(text) - const itinerary = /行程单/.test(text) - const ticket = /车票|机票/.test(text) - const hotelList = /入住清单/.test(text) - const files = [] - - if (receipt) files.push('发票.jpg') - if (itinerary) files.push('行程单.pdf') - if (ticket && !files.includes('票据.pdf')) files.push('票据.pdf') - if (hotelList) files.push('入住清单.pdf') - - if (uploaded || files.length) { - return { - status: `${Math.max(files.length, 1)} 份附件`, - hint: files.length ? files.join(' + ') : '已上传附件待识别', - tone: 'ok', - files: files.length ? files : ['附件1.jpg'], - } - } - - return { - status: '缺 1 份', - hint: '待补上传票据原件', - tone: 'missing', - files: [], - } - } - - function inferRisk(text, attachmentTone) { - if (/夜间|超标|说明/.test(text)) { - return { - status: '需说明', - tone: 'bad', - riskLabel: '超标说明', - riskText: '识别到特殊场景,建议补充费用说明', - riskTone: 'medium', - } - } - - if (attachmentTone === 'missing' || attachmentTone === 'partial') { - return { - status: '待补材料', - tone: 'bad', - riskLabel: '待补材料', - riskText: '附件不完整,需补齐后再提交审批', - riskTone: 'medium', - } - } - - return { - status: '规则通过', - tone: 'ok', - riskLabel: '规则通过', - riskText: 'AI 识别通过,字段已结构化', - riskTone: 'low', - } - } - - function extractDateLabel(text) { - const match = text.match(/(\d{1,2})月(\d{1,2})日|(\d{1,2})[-/.](\d{1,2})/) - if (!match) { - return { time: '07-12', dayLabel: `第 ${expenseItems.value.length + 1} 天` } - } - - const month = String(match[1] || match[3] || '07').padStart(2, '0') - const day = String(match[2] || match[4] || '12').padStart(2, '0') - return { time: `${month}-${day}`, dayLabel: `第 ${expenseItems.value.length + 1} 天` } - } - - function extractAmount(text) { - const match = text.match(/(\d+(?:\.\d{1,2})?)\s*元/) - return formatCurrency(Number.parseFloat(match?.[1] || '0')) - } - - function buildAiExpense(text) { - const category = inferExpenseCategory(text) - const name = inferExpenseName(text, category) - const dateInfo = extractDateLabel(text) - const attachments = inferAttachments(text, uploadedAiFiles.value) - const risk = inferRisk(text, attachments.tone) - - return { - id: buildNextExpenseId(), - time: dateInfo.time, - dayLabel: dateInfo.dayLabel, - name, - category, - desc: text.slice(0, 24), - detail: text, - amount: extractAmount(text), - status: risk.status, - tone: risk.tone, - attachmentStatus: attachments.status, - attachmentHint: attachments.hint, - attachmentTone: attachments.tone, - attachments: attachments.files, - riskLabel: risk.riskLabel, - riskText: risk.riskText, - riskTone: risk.riskTone, - } - } - - const aiMessages = ref([ - { - id: 'ai-msg-1', - role: 'assistant', - text: '请直接描述费用场景、日期、金额和是否已上传票据,我会整理成费用明细。', - }, - ]) - - function sendAiEntry() { - const text = aiDraft.value.trim() || `已上传 ${uploadedAiFiles.value.length} 份单据,请根据附件识别费用。` - if (!text && !uploadedAiFiles.value.length) return - - aiMessages.value.push({ - id: `ai-msg-user-${Date.now()}`, - role: 'user', - text: uploadedAiFiles.value.length ? `${text}\n附件:${uploadedAiFiles.value.map((file) => file.name).join('、')}` : text, - }) - - pendingAiExpense.value = buildAiExpense(text) - - aiMessages.value.push({ - id: `ai-msg-assistant-${Date.now()}`, - role: 'assistant', - text: `已识别为 ${pendingAiExpense.value.name},金额 ${pendingAiExpense.value.amount},可直接加入费用明细。`, - }) - - aiDraft.value = '' - } - - function regenerateAiEntry() { - if (!pendingAiExpense.value) return - const sourceText = pendingAiExpense.value.detail - pendingAiExpense.value = buildAiExpense(sourceText.replace('待补上传票据原件', '已上传发票')) - aiMessages.value.push({ - id: `ai-msg-regenerate-${Date.now()}`, - role: 'assistant', - text: '已重新整理识别结果,你可以继续确认后加入费用明细。', - }) - } - - function applyAiExpense() { - if (!pendingAiExpense.value) return - expenseItems.value.push({ ...pendingAiExpense.value }) - expandedExpenseId.value = pendingAiExpense.value.id - aiMessages.value.push({ - id: `ai-msg-apply-${Date.now()}`, - role: 'assistant', - text: '该费用条目已加入下方费用明细表。', - }) - pendingAiExpense.value = null - aiDraft.value = '' - uploadedAiFiles.value = [] - if (aiFileInput.value) { - aiFileInput.value.value = '' - } - aiEntryOpen.value = false - } - - function triggerAiUpload() { - aiFileInput.value?.click() - } - - function handleAiFilesChange(event) { - const files = Array.from(event.target.files ?? []) - uploadedAiFiles.value = files - } - return { emit, - expandedExpenseId, - aiEntryOpen, - aiDraft, - aiFileInput, - aiEntrySeed, - pendingAiExpense, - uploadedAiFiles, - expenseItems, - request, - profile, - summaryItems, - heroSummaryItems, + actionBusy, + canSubmit, + closeDeleteDialog, + confirmDeleteDraft, currentProgressRingMotion, - progressSteps, - expenseTotal, - uploadedExpenseCount, - canSendAiEntry, + deleteBusy, + deleteDialogOpen, detailNote, - toggleExpenseAttachments, - showExpenseRisk, + draftBlockingIssues, + editingExpenseId, + expenseEditor, + expenseItems, + expenseSummaryText, + expenseTotal, + expandedExpenseId, + expenseTypeOptions: EXPENSE_TYPE_OPTIONS, + handleDeleteDraft, + handleSubmit, + heroStats, + heroSummaryItems, + isDraftRequest, + isTravelRequest, openAiEntry, - closeAiEntry, - aiMessages, - sendAiEntry, - regenerateAiEntry, - applyAiExpense, - triggerAiUpload, - handleAiFilesChange + profile, + progressSteps, + request, + resolveExpenseIssues, + savingExpenseId, + showExpenseRisk, + startExpenseEdit, + submitBusy, + toggleExpenseAttachments, + uploadedExpenseCount, + validationSummary, + validationTone, + cancelExpenseEdit, + saveExpenseEdit } } } -