refactor(web): update view scripts

- AuditView.js: update audit view logic
- EmployeeManagementView.js: update employee management logic
- PoliciesView.js: update policies view logic
- RequestsView.js: update requests view logic
- TravelReimbursementCreateView.js: update travel form logic
- TravelRequestDetailView.js: update travel detail view logic
This commit is contained in:
caoxiaozhu
2026-05-13 03:35:44 +00:00
parent 8b72f4e962
commit 46644d429f
6 changed files with 1129 additions and 516 deletions

View File

@@ -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()

View File

@@ -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,

View File

@@ -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,

View File

@@ -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
}
}
}

View File

@@ -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,

View File

@@ -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
}
}
}