feat: 完善报销单审批流程及退回原因追踪

新增直属领导审批通过接口和审批待办列表查询,报销单退回
支持原因码分类和审批环节标记,优化票据附件去重和路径
回退查找,前端新增退回原因对话框、审批收件箱和工作台
图标组件,补充工具函数和单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-20 21:00:47 +08:00
parent f8b25a7ccc
commit 002bf4f756
62 changed files with 5331 additions and 2101 deletions

View File

@@ -1,13 +1,12 @@
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
import { mapExpenseClaimToRequest } from '../../composables/useRequests.js'
import { useApprovalInbox } from '../../composables/useApprovalInbox.js'
import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js'
import { deleteExpenseClaim, fetchExpenseClaims, returnExpenseClaim } from '../../services/reimbursements.js'
import { canManageExpenseClaims } from '../../utils/accessControl.js'
import { fetchApprovalExpenseClaims } from '../../services/reimbursements.js'
import { listPendingApprovalRequests } from '../../utils/approvalInbox.js'
import TravelRequestDetailView from '../TravelRequestDetailView.vue'
const DEFAULT_SLA_HOURS = 24
const tabs = ['全部待审', '高风险', '即将超时', '已处理']
@@ -61,94 +60,6 @@ function resolveRiskTone(riskFlags, riskSummary) {
return 'low'
}
function resolveRiskItems(request) {
const riskFlags = Array.isArray(request?.riskFlags) ? request.riskFlags : []
const items = riskFlags
.map((item) => {
const tone = resolveRiskTone([item], '')
const text = String(item?.message || item?.label || item?.reason || '').trim()
if (!text) {
return null
}
return {
text,
level: tone === 'high' ? '高' : tone === 'medium' ? '中' : '低',
tone,
icon: tone === 'high' ? 'mdi mdi-alert-circle' : tone === 'medium' ? 'mdi mdi-alert' : 'mdi mdi-shield-check'
}
})
.filter(Boolean)
if (items.length) {
return items
}
const summary = String(request?.riskSummary || '').trim()
if (summary && summary !== '无') {
return summary.split('').filter(Boolean).map((text) => ({
text,
level: '中',
tone: 'medium',
icon: 'mdi mdi-alert'
}))
}
return [
{
text: 'AI预审已通过当前未发现额外风险。',
level: '低',
tone: 'low',
icon: 'mdi mdi-shield-check'
}
]
}
function resolveAttachmentMeta(name) {
const normalized = String(name || '').trim()
const lowerName = normalized.toLowerCase()
if (lowerName.endsWith('.pdf')) {
return { icon: 'mdi mdi-file-pdf-box', iconClass: 'pdf' }
}
if (/\.(png|jpg|jpeg|webp|bmp)$/i.test(lowerName)) {
return { icon: 'mdi mdi-image', iconClass: 'img' }
}
return { icon: 'mdi mdi-file-document-outline', iconClass: 'file' }
}
function buildAttachments(expenseItems) {
const seen = new Set()
const attachments = []
for (const item of Array.isArray(expenseItems) ? expenseItems : []) {
for (const fileName of Array.isArray(item?.attachments) ? item.attachments : []) {
const normalized = String(fileName || '').trim()
if (!normalized || seen.has(normalized)) {
continue
}
seen.add(normalized)
attachments.push({
name: normalized,
size: '已识别',
...resolveAttachmentMeta(normalized)
})
}
}
if (attachments.length) {
return attachments
}
return [
{
name: '当前无附件',
size: '待补充',
icon: 'mdi mdi-file-document-outline',
iconClass: 'miss',
missing: true
}
]
}
function resolveSlaMeta(submittedAt) {
const startAt = toDate(submittedAt)
if (!startAt) {
@@ -173,55 +84,8 @@ function resolveSlaMeta(submittedAt) {
return { label, tone: 'safe', urgent: false }
}
function buildHeroSummaryItems(request) {
return [
{ label: '单号', value: request.id || '-', icon: 'mdi mdi-pound-box-outline' },
{ label: '报销类型', value: request.typeLabel || '-', icon: 'mdi mdi-briefcase-outline' },
{ label: '业务地点', value: request.sceneTarget || '待补充', icon: 'mdi mdi-map-marker-outline' },
{ label: '发生时间', value: request.occurredDisplay || '待补充', icon: 'mdi mdi-calendar-range' },
{ label: '票据关联', value: request.attachmentSummary || '无', icon: 'mdi mdi-paperclip' },
{ label: '事由', value: request.title || '待补充', icon: 'mdi mdi-text-box-outline' }
]
}
function buildFlowItems(request) {
return Array.isArray(request?.progressSteps)
? request.progressSteps.map((item) => ({
label: item.label,
desc: item.current ? '当前处理节点' : item.done ? '已完成' : '待处理',
time: item.time,
icon: item.current ? 'mdi mdi-circle-slice-8' : item.done ? 'mdi mdi-check' : 'mdi mdi-circle-outline',
current: item.current,
pending: !item.done && !item.current
}))
: []
}
function canCurrentUserProcessRequest(request, currentUser) {
const node = String(request?.workflowNode || '').trim()
const currentName = String(currentUser?.name || '').trim()
const applicantName = String(request?.person || request?.employeeName || '').trim()
if (currentName && applicantName && currentName === applicantName) {
return false
}
if (canManageExpenseClaims(currentUser)) {
return true
}
return (
node.includes('直属领导')
|| node.includes('领导审批')
|| node.includes('部门负责人')
|| node.includes('负责人审批')
)
}
function buildApprovalRow(request) {
const riskTone = resolveRiskTone(request.riskFlags, request.riskSummary)
const riskItems = resolveRiskItems(request)
const expenseItems = Array.isArray(request.expenseItems) ? request.expenseItems : []
const slaMeta = resolveSlaMeta(request.submittedAt || request.createdAt)
const statusTone = slaMeta.urgent ? 'urgent' : 'pending'
@@ -240,37 +104,35 @@ function buildApprovalRow(request) {
node: request.workflowNode || '审批中',
status: statusTone === 'urgent' ? '即将超时' : '待审批',
statusTone,
spotlight: riskTone === 'high' || statusTone === 'urgent',
heroSummaryItems: buildHeroSummaryItems(request),
summaryItems: buildHeroSummaryItems(request).slice(2),
progressSteps: Array.isArray(request.progressSteps) ? request.progressSteps : [],
expenseItems,
attachments: buildAttachments(expenseItems),
riskItems,
flowItems: buildFlowItems(request)
spotlight: riskTone === 'high' || statusTone === 'urgent'
}
}
export default {
name: 'ApprovalCenterView',
components: {
ConfirmDialog,
TravelRequestDetailView,
TableLoadingState,
TableEmptyState
},
setup() {
const { currentUser } = useSystemState()
const { toast } = useToast()
const { markClaimViewed, syncPendingClaimIds } = useApprovalInbox()
const activeTab = ref('全部待审')
const selectedClaimId = ref('')
const expandedExpenseId = ref(null)
const listKeyword = ref('')
const rows = ref([])
const loading = ref(false)
const error = ref('')
const actionBusy = ref(false)
const returnDialogOpen = ref(false)
const deleteDialogOpen = ref(false)
watch(
() => selectedClaimId.value,
(claimId) => {
if (claimId) {
markClaimViewed(claimId)
}
}
)
const selectedRow = computed({
get() {
@@ -278,14 +140,12 @@ export default {
},
set(value) {
selectedClaimId.value = value?.claimId || ''
expandedExpenseId.value = null
}
})
const visibleRows = computed(() => {
let filteredRows = rows.value
// 根据标签筛选
if (activeTab.value === '高风险') {
filteredRows = filteredRows.filter((row) => row.riskTone === 'high')
} else if (activeTab.value === '即将超时') {
@@ -294,25 +154,20 @@ export default {
filteredRows = []
}
// 根据搜索关键词筛选
if (listKeyword.value.trim()) {
const keyword = listKeyword.value.trim().toLowerCase()
filteredRows = filteredRows.filter((row) => {
return (
String(row.id || '').toLowerCase().includes(keyword) ||
String(row.applicant || '').toLowerCase().includes(keyword) ||
String(row.department || '').toLowerCase().includes(keyword) ||
String(row.type || '').toLowerCase().includes(keyword) ||
String(row.amount || '').toLowerCase().includes(keyword)
)
})
filteredRows = filteredRows.filter((row) => (
String(row.id || '').toLowerCase().includes(keyword)
|| String(row.applicant || '').toLowerCase().includes(keyword)
|| String(row.department || '').toLowerCase().includes(keyword)
|| String(row.type || '').toLowerCase().includes(keyword)
|| String(row.amount || '').toLowerCase().includes(keyword)
))
}
return filteredRows
})
const showTable = computed(() => !loading.value && !error.value && visibleRows.value.length > 0)
const showEmpty = computed(() => !loading.value && !error.value && visibleRows.value.length === 0)
const canManageClaims = computed(() => canManageExpenseClaims(currentUser.value))
const approvalEmptyState = computed(() => {
if (!rows.value.length) {
return {
@@ -343,45 +198,6 @@ export default {
}
})
const approvalSteps = computed(() => selectedRow.value?.progressSteps || [])
const summaryItems = computed(() => selectedRow.value?.summaryItems || [])
const heroSummaryItems = computed(() => selectedRow.value?.heroSummaryItems || [])
const expenseItems = computed(() => selectedRow.value?.expenseItems || [])
const expenseTotal = computed(() => selectedRow.value?.amount || formatCurrency(0))
const uploadedExpenseCount = computed(
() => expenseItems.value.filter((item) => Array.isArray(item?.attachments) && item.attachments.length).length
)
const attachments = computed(() => selectedRow.value?.attachments || [])
const riskItems = computed(() => selectedRow.value?.riskItems || [])
const flowItems = computed(() => selectedRow.value?.flowItems || [])
const currentProgressRingMotion = {
initial: {
scale: 1,
opacity: 0.34
},
enter: {
scale: [1, 1.42, 1.78],
opacity: [0.34, 0.16, 0],
transition: {
duration: 3.2,
repeat: Infinity,
repeatType: 'loop',
repeatDelay: 0.85,
ease: 'easeOut',
times: [0, 0.5, 1]
}
}
}
function showExpenseRisk(item) {
return ['medium', 'high'].includes(String(item?.riskTone || '').trim())
}
function toggleExpenseAttachments(id) {
expandedExpenseId.value = expandedExpenseId.value === id ? null : id
}
function handleEmptyAction() {
if (!rows.value.length) {
void reload()
@@ -391,74 +207,18 @@ export default {
activeTab.value = '全部待审'
}
function handleReturnSelected() {
if (!selectedRow.value?.claimId || !canManageClaims.value || actionBusy.value) {
return
}
returnDialogOpen.value = true
function closeSelectedDetail() {
selectedClaimId.value = ''
}
function handleDeleteSelected() {
if (!selectedRow.value?.claimId || !canManageClaims.value || actionBusy.value) {
return
}
deleteDialogOpen.value = true
async function handleDetailUpdated() {
selectedClaimId.value = ''
await reload()
}
function closeReturnDialog() {
if (!actionBusy.value) {
returnDialogOpen.value = false
}
}
function closeDeleteDialog() {
if (!actionBusy.value) {
deleteDialogOpen.value = false
}
}
async function confirmReturnSelected() {
const row = selectedRow.value
if (!row?.claimId || actionBusy.value) {
return
}
actionBusy.value = true
try {
await returnExpenseClaim(row.claimId, {
reason: '审批中心退回,请申请人调整后重新提交。'
})
toast(`${row.id} 已退回待提交。`)
returnDialogOpen.value = false
selectedClaimId.value = ''
await reload()
} catch (nextError) {
toast(nextError?.message || '退回单据失败,请稍后重试。')
} finally {
actionBusy.value = false
}
}
async function confirmDeleteSelected() {
const row = selectedRow.value
if (!row?.claimId || actionBusy.value) {
return
}
actionBusy.value = true
try {
const payload = await deleteExpenseClaim(row.claimId)
toast(payload?.message || `${row.id} 报销单已删除。`)
deleteDialogOpen.value = false
selectedClaimId.value = ''
await reload()
} catch (nextError) {
toast(nextError?.message || '删除单据失败,请稍后重试。')
} finally {
actionBusy.value = false
}
async function handleDetailDeleted() {
selectedClaimId.value = ''
await reload()
}
async function reload() {
@@ -466,15 +226,11 @@ export default {
error.value = ''
try {
const payload = await fetchExpenseClaims()
const mappedRows = Array.isArray(payload)
? payload
.map((item) => mapExpenseClaimToRequest(item))
.filter((item) => item.approvalKey === 'in_progress')
.filter((item) => canCurrentUserProcessRequest(item, currentUser.value))
.map((item) => buildApprovalRow(item))
: []
const payload = await fetchApprovalExpenseClaims()
const pendingRequests = listPendingApprovalRequests(payload, currentUser.value)
const mappedRows = pendingRequests.map((item) => buildApprovalRow(item))
rows.value = mappedRows
syncPendingClaimIds(mappedRows.map((item) => item.claimId))
if (!mappedRows.some((item) => item.claimId === selectedClaimId.value)) {
selectedClaimId.value = ''
}
@@ -491,42 +247,21 @@ export default {
return {
activeTab,
selectedRow,
expandedExpenseId,
listKeyword,
tabs,
filters,
rows,
visibleRows,
showTable,
showEmpty,
actionBusy,
approvalEmptyState,
approvalSteps,
canManageClaims,
closeDeleteDialog,
closeReturnDialog,
confirmDeleteSelected,
confirmReturnSelected,
deleteDialogOpen,
summaryItems,
heroSummaryItems,
currentProgressRingMotion,
expenseItems,
expenseTotal,
uploadedExpenseCount,
showExpenseRisk,
toggleExpenseAttachments,
attachments,
riskItems,
flowItems,
handleEmptyAction,
handleDeleteSelected,
handleReturnSelected,
loading,
closeSelectedDetail,
error,
returnDialogOpen,
reload
filters,
handleDetailDeleted,
handleDetailUpdated,
handleEmptyAction,
listKeyword,
loading,
reload,
rows,
selectedRow,
showEmpty,
tabs,
visibleRows
}
}
}

View File

@@ -258,6 +258,10 @@ function sameValues(left, right) {
return left.every((value, index) => value === right[index])
}
function padDatePart(value) {
return String(Number(value)).padStart(2, '0')
}
function formatEmployeeHistoryTime(value) {
const raw = normalizeText(value)
if (!raw) {
@@ -269,13 +273,13 @@ function formatEmployeeHistoryTime(value) {
)
if (chineseMatched) {
const [, year, month, day, hour, minute] = chineseMatched
return `${year}${Number(month)}${Number(day)}${Number(hour)}${Number(minute)}`
return `${year}-${padDatePart(month)}-${padDatePart(day)} ${padDatePart(hour)}:${padDatePart(minute)}`
}
const isoMatched = raw.match(/^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2}))?/)
const isoMatched = raw.match(/^(\d{4})-(\d{1,2})-(\d{1,2})(?:[ T](\d{1,2}):(\d{1,2}))?/)
if (isoMatched) {
const [, year, month, day, hour = '0', minute = '0'] = isoMatched
return `${year}${Number(month)}${Number(day)}${Number(hour)}${Number(minute)}`
return `${year}-${padDatePart(month)}-${padDatePart(day)} ${padDatePart(hour)}:${padDatePart(minute)}`
}
return raw.replace(/(\d{1,2}分)\d{1,2}秒$/, '$1')

View File

@@ -24,7 +24,7 @@ export default {
emits: ['ask', 'approve', 'reject', 'create-request', 'reload'],
setup(props, { emit }) {
const activeTab = ref('全部')
const tabs = ['全部', '草稿', '审批中', '待补充', '已完成']
const tabs = ['全部', '草稿', '待提交', '审批中', '待补充', '已完成']
const filters = ['报销状态', '报销类型', '所属主体']
const listKeyword = ref('')
@@ -98,8 +98,9 @@ export default {
const matchesTab =
activeTab.value === '全部'
|| (activeTab.value === '草稿' && row.approvalKey === 'draft')
|| (activeTab.value === '待提交' && row.approvalKey === 'supplement' && row.status === 'returned')
|| (activeTab.value === '审批中' && row.approvalKey === 'in_progress')
|| (activeTab.value === '待补充' && row.approvalKey === 'supplement')
|| (activeTab.value === '待补充' && row.approvalKey === 'supplement' && row.status !== 'returned')
|| (activeTab.value === '已完成' && row.approvalKey === 'completed')
return matchesKeyword && matchesDateRange && matchesTab
@@ -150,7 +151,7 @@ export default {
artLabel: hasListFilters.value ? 'FILTER' : 'QUEUE',
tips: hasListFilters.value
? ['关键词、时间段和状态会叠加生效', '可尝试搜索单号、事由或报销类型']
: ['已完成单据会保留在列表中便于追踪', '草稿、审批中和待补充会按真实状态实时归类']
: ['已完成单据会保留在列表中便于追踪', '草稿、待提交、审批中和待补充会按真实状态实时归类']
}
})

View File

@@ -8,6 +8,12 @@ import { recognizeOcrFiles } from '../../services/ocr.js'
import { fetchAgentRunDetail } from '../../services/agentAssets.js'
import { clearUserConversations, deleteConversation, fetchLatestConversation, runOrchestrator } from '../../services/orchestrator.js'
import { renderMarkdown } from '../../utils/markdown.js'
import {
buildLocalExtractionProgressMessages,
buildLocalIntentPreview,
summarizeSemanticIntentDetail,
TRANSPORT_KEYWORD_PATTERN
} from '../../utils/reimbursementTextInference.js'
import {
fetchExpenseClaimAttachmentAsset,
fetchExpenseClaimDetail,
@@ -284,7 +290,7 @@ const HOT_KNOWLEDGE_QUESTIONS = [
const CATEGORY_CONFIDENCE_KEYWORDS = {
travel: [/出差|差旅|行程|机票|火车|高铁|航班/],
hotel: [/住宿|酒店|宾馆|民宿/],
transport: [/交通|打车|网约车|出租车|车费|地铁|公交|停车|过路费/],
transport: [TRANSPORT_KEYWORD_PATTERN],
meal: [/餐费|用餐|午餐|晚餐|早餐|伙食|餐饮/],
meeting: [/会务|会议|论坛|展会|参会|会场/],
entertainment: [/招待|宴请|请客|请客户|客户.*吃饭|商务用餐|陪同/],
@@ -304,13 +310,6 @@ const FLOW_MISSING_SLOT_LABELS = {
participants: '参与人员',
attachments: '票据附件'
}
const FLOW_INTENT_KEYWORDS = {
draft: ['报销', '草稿', '生成', '提交', '申请', '请走报销'],
query: ['查询', '查一下', '多少', '明细', '统计'],
risk_check: ['风险', '异常', '重复', '超标'],
explain: ['为什么', '依据', '规则', '怎么']
}
let messageSeed = 0
function nowTime() {
@@ -439,116 +438,6 @@ function summarizeSemanticParseDetail(semanticParse, ontologyJson = {}) {
return FLOW_STEP_FALLBACKS.extraction.completedText
}
function summarizeSemanticIntentDetail(semanticParse) {
if (!semanticParse || typeof semanticParse !== 'object') {
return FLOW_STEP_FALLBACKS.intent.completedText
}
const scenarioLabel = SCENARIO_LABELS[String(semanticParse.scenario || '').trim()] || String(semanticParse.scenario || '').trim() || '通用'
const intentLabel = INTENT_LABELS[String(semanticParse.intent || '').trim()] || String(semanticParse.intent || '').trim() || '处理'
return `已识别为${scenarioLabel}场景,当前目标是${intentLabel}`
}
function extractLocalFlowCandidates(rawText) {
const text = String(rawText || '').trim()
const compact = text.replace(/\s+/g, '')
let time = ''
const explicitTimeMatch = text.match(/发生时间[:]?\s*([0-9]{4}[-/年][0-9]{1,2}[-/月][0-9]{1,2}日?)/)
if (explicitTimeMatch?.[1]) {
time = explicitTimeMatch[1].replace(/年/g, '-').replace(/月/g, '-').replace(/日/g, '').replace(/\//g, '-')
} else {
const dateMatch = text.match(/([0-9]{4}[-/年][0-9]{1,2}[-/月][0-9]{1,2}日?)/)
if (dateMatch?.[1]) {
time = dateMatch[1].replace(/年/g, '-').replace(/月/g, '-').replace(/日/g, '').replace(/\//g, '-')
} else if (/今天|今日/.test(compact)) {
time = '今天'
} else if (/昨天|昨日/.test(compact)) {
time = '昨天'
} else if (/前天/.test(compact)) {
time = '前天'
}
}
let amount = ''
const amountMatch = text.match(/([0-9]+(?:\.[0-9]{1,2})?)\s*(?:元|员|圆|园|块|块钱|万元|万)/)
if (amountMatch?.[1]) {
const numericValue = Number(amountMatch[1])
if (Number.isFinite(numericValue)) {
amount = Number.isInteger(numericValue) ? `${numericValue}` : `${numericValue.toFixed(2)}`
}
}
let event = ''
let expenseType = ''
if (/客户.*吃饭|请客户.*吃饭|招待|宴请|请客/.test(compact)) {
event = '请客户吃饭'
expenseType = '业务招待费'
} else if (/出差|差旅|机票|高铁|火车|行程/.test(compact)) {
event = '出差行程'
expenseType = '差旅费'
} else if (/打车|网约车|出租车|车费|停车/.test(compact)) {
event = '交通出行'
expenseType = '交通费'
} else if (/住宿|酒店|宾馆/.test(compact)) {
event = '住宿报销'
expenseType = '住宿费'
} else if (/餐费|用餐|午餐|晚餐|早餐|餐饮/.test(compact)) {
event = '餐饮用餐'
expenseType = '餐费'
}
return {
time,
amount,
event,
expenseType
}
}
function buildLocalIntentPreview(rawText, sessionType = SESSION_TYPE_EXPENSE) {
if (sessionType === SESSION_TYPE_KNOWLEDGE) {
return '初步识别为财务知识问答,正在准备检索范围'
}
const text = String(rawText || '').trim()
const compact = text.replace(/\s+/g, '')
const intentKey = Object.entries(FLOW_INTENT_KEYWORDS).find(([, keywords]) =>
keywords.some((keyword) => compact.includes(keyword))
)?.[0] || 'draft'
const intentLabel = INTENT_LABELS[intentKey] || '处理'
return `初步识别为报销场景,准备进入${intentLabel}`
}
function buildLocalExtractionProgressMessages(rawText, options = {}) {
const candidates = extractLocalFlowCandidates(rawText)
const messages = []
messages.push('正在提取发生时间...')
messages.push(
candidates.time
? `发现发生时间 ${candidates.time},继续提取金额...`
: '暂未定位到明确时间,继续提取金额...'
)
messages.push(
candidates.amount
? `发现金额 ${candidates.amount},继续识别事件类型...`
: '暂未定位到明确金额,继续识别事件类型...'
)
if (candidates.event || candidates.expenseType) {
const eventParts = [candidates.event, candidates.expenseType].filter(Boolean)
messages.push(`识别到${eventParts.join(' / ')},继续判断待补项...`)
} else {
messages.push('正在识别事件类型和费用分类...')
}
const attachmentHint = Number(options.attachmentCount || 0) > 0 ? '附件完整性' : '票据附件'
messages.push(`正在判断待补项:客户名称、参与人员、${attachmentHint}`)
return messages
}
function formatFlowDuration(ms) {
const numericValue = Number(ms)
if (!Number.isFinite(numericValue) || numericValue < 0) {
@@ -2039,7 +1928,7 @@ function matchPresetSceneFromReason(reason) {
if (/酒店|住宿/.test(compactReason)) {
return '住宿报销'
}
if (/交通|打车|车费|停车|网约车|出租车|地铁|公交/.test(compactReason)) {
if (TRANSPORT_KEYWORD_PATTERN.test(compactReason)) {
return '交通出行'
}
if (/会务|会议|参会|论坛|展会/.test(compactReason)) {
@@ -3162,6 +3051,7 @@ export default {
const reviewRecognitionNotes = computed(() => buildReviewRecognitionNotes(activeReviewPayload.value))
const reviewDocumentSummaries = computed(() => buildReviewDocumentSummaries(activeReviewPayload.value))
const reviewDocumentCount = computed(() => reviewDocumentDrafts.value.length)
const isReviewOverviewDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_REVIEW)
const isReviewDocumentDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_DOCUMENTS)
const isReviewRiskDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_RISK)
const isReviewFlowDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_FLOW)
@@ -3175,7 +3065,7 @@ export default {
: '报销识别核对'
))
const reviewDocumentDrawerLabel = computed(() => (
isReviewDocumentDrawer.value ? '显示核对' : '显示票据'
'单据识别'
))
const reviewDocumentDrawerIcon = computed(() => (
isReviewDocumentDrawer.value
@@ -3183,7 +3073,7 @@ export default {
: 'mdi mdi-file-document-multiple-outline'
))
const reviewRiskDrawerLabel = computed(() => (
isReviewRiskDrawer.value ? '显示核对' : '显示风险'
'显示风险'
))
const reviewRiskDrawerIcon = computed(() => (
isReviewRiskDrawer.value
@@ -3191,7 +3081,7 @@ export default {
: 'mdi mdi-shield-alert-outline'
))
const reviewFlowDrawerLabel = computed(() => (
isReviewFlowDrawer.value ? '显示核对' : '显示流程'
'调用流程'
))
const reviewFlowDrawerIcon = computed(() => (
isReviewFlowDrawer.value
@@ -3714,7 +3604,7 @@ export default {
function startSemanticFlowPreview(rawText, options = {}) {
clearFlowSimulationTimers()
const intentPreview = buildLocalIntentPreview(rawText, activeSessionType.value)
const intentPreview = buildLocalIntentPreview(rawText, activeSessionType.value, { intentLabels: INTENT_LABELS })
const extractionMessages = buildLocalExtractionProgressMessages(rawText, options)
const completeIntentTimer = window.setTimeout(() => {
@@ -3867,7 +3757,12 @@ export default {
const extractionStep = flowSteps.value.find((step) => step.key === 'extraction')
completePendingFlowStep(
'intent',
summarizeSemanticIntentDetail(run.semantic_parse),
summarizeSemanticIntentDetail(run.semantic_parse, {
scenarioLabels: SCENARIO_LABELS,
intentLabels: INTENT_LABELS,
expenseTypeLabels: EXPENSE_TYPE_LABELS,
fallbackText: FLOW_STEP_FALLBACKS.intent.completedText
}),
intentStep?.startedAt ? null : semanticDurations.intentMs
)
completePendingFlowStep(
@@ -4393,34 +4288,36 @@ export default {
insightPanelCollapsed.value = !insightPanelCollapsed.value
}
function switchReviewDrawerMode(mode) {
if (reviewDrawerMode.value === mode) {
return
}
reviewDrawerMode.value = mode
}
function switchToReviewOverviewDrawer() {
switchReviewDrawerMode(REVIEW_DRAWER_MODE_REVIEW)
}
function toggleReviewDocumentDrawer() {
if (!reviewDocumentDrawerAvailable.value) {
return
}
reviewDrawerMode.value =
reviewDrawerMode.value === REVIEW_DRAWER_MODE_DOCUMENTS
? REVIEW_DRAWER_MODE_REVIEW
: REVIEW_DRAWER_MODE_DOCUMENTS
switchReviewDrawerMode(REVIEW_DRAWER_MODE_DOCUMENTS)
}
function toggleReviewRiskDrawer() {
if (!reviewRiskDrawerAvailable.value) {
return
}
reviewDrawerMode.value =
reviewDrawerMode.value === REVIEW_DRAWER_MODE_RISK
? REVIEW_DRAWER_MODE_REVIEW
: REVIEW_DRAWER_MODE_RISK
switchReviewDrawerMode(REVIEW_DRAWER_MODE_RISK)
}
function toggleReviewFlowDrawer() {
if (!reviewFlowDrawerAvailable.value) {
return
}
reviewDrawerMode.value =
reviewDrawerMode.value === REVIEW_DRAWER_MODE_FLOW
? REVIEW_DRAWER_MODE_REVIEW
: REVIEW_DRAWER_MODE_FLOW
switchReviewDrawerMode(REVIEW_DRAWER_MODE_FLOW)
}
function setInlineReviewFieldError(key, message) {
@@ -5335,6 +5232,7 @@ export default {
activeReviewPayload,
activeReviewFilePreviews,
reviewDrawerMode,
isReviewOverviewDrawer,
isReviewDocumentDrawer,
isReviewRiskDrawer,
isReviewFlowDrawer,
@@ -5433,6 +5331,7 @@ export default {
resolveFlowStepStatusLabel,
resolveFlowStepDetail,
toggleInsightPanel,
switchToReviewOverviewDrawer,
toggleReviewDocumentDrawer,
toggleReviewRiskDrawer,
toggleReviewFlowDrawer,

View File

@@ -3,7 +3,9 @@ import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js'
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
import ReturnReasonDialog from '../../components/shared/ReturnReasonDialog.vue'
import {
approveExpenseClaim,
createExpenseClaimItem,
deleteExpenseClaimItem,
deleteExpenseClaimItemAttachment,
@@ -15,8 +17,13 @@ import {
uploadExpenseClaimItemAttachment,
updateExpenseClaimItem
} from '../../services/reimbursements.js'
import { canManageExpenseClaims } from '../../utils/accessControl.js'
import { canManageExpenseClaims, canReturnExpenseClaims } from '../../utils/accessControl.js'
import { normalizeRequestForUi } from '../../utils/requestViewModel.js'
import {
buildAiAdviceViewModel,
buildAttachmentInsightViewModel,
buildAttachmentRiskCards
} from './travelRequestDetailInsights.js'
const EXPENSE_TYPE_OPTIONS = [
{ value: 'travel', label: '差旅费' },
@@ -30,21 +37,6 @@ const EXPENSE_TYPE_OPTIONS = [
{ value: 'other', label: '其他费用' }
]
const DOCUMENT_TYPE_LABELS = {
flight_itinerary: '机票/航班行程单',
train_ticket: '火车/高铁票',
hotel_invoice: '酒店住宿票据',
taxi_receipt: '出租车/网约车票据',
parking_toll_receipt: '停车/通行费票据',
meal_receipt: '餐饮票据',
office_invoice: '办公用品票据',
meeting_invoice: '会议/会务票据',
training_invoice: '培训票据',
vat_invoice: '增值税发票',
receipt: '一般收据/凭证',
other: '其他单据'
}
const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
'travel',
'meeting',
@@ -72,18 +64,10 @@ function resolveExpenseTypeLabel(value) {
return EXPENSE_TYPE_OPTIONS.find((option) => option.value === normalizeExpenseType(value))?.label || '其他费用'
}
function resolveDocumentTypeLabel(value) {
return DOCUMENT_TYPE_LABELS[String(value || '').trim()] || DOCUMENT_TYPE_LABELS.other
}
function isLocationRequiredExpenseType(value) {
return LOCATION_REQUIRED_EXPENSE_TYPES.has(normalizeExpenseType(value))
}
function resolveLocationInputPlaceholder(value) {
return isLocationRequiredExpenseType(value) ? '输入业务地点' : '输入采购/收货地点(可选)'
}
function resolveLocationSummaryLabel(value) {
return isLocationRequiredExpenseType(value) ? '业务地点' : '采购/收货地点'
}
@@ -191,9 +175,28 @@ function normalizeIsoDateValue(value) {
return `${year}-${month}-${day}`
}
function formatExpenseFilledTime(value) {
const normalized = String(value || '').trim()
if (!normalized) {
return ''
}
const candidate = value instanceof Date ? value : new Date(normalized)
if (Number.isNaN(candidate.getTime())) {
return normalized
}
const year = candidate.getFullYear()
const month = String(candidate.getMonth() + 1).padStart(2, '0')
const day = String(candidate.getDate()).padStart(2, '0')
const hours = String(candidate.getHours()).padStart(2, '0')
const minutes = String(candidate.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}`
}
function resolveExpenseUploadHint(value) {
const normalized = String(value || '').trim()
return normalized || '支持上传 JPG、PNG、PDF,未上传也可先保存草稿'
return normalized || '支持上传 1 张 JPG、PNG、PDF 单据'
}
function extractAttachmentDisplayName(value) {
@@ -216,6 +219,12 @@ function buildExpenseItemViewModel(source, index, requestModel) {
const attachments = invoiceId ? [attachmentName || invoiceId] : []
const amountDisplay = itemAmount > 0 ? formatCurrency(itemAmount) : '待补充'
const riskText = String(source?.riskText || '').trim()
const filledAt = formatExpenseFilledTime(
source?.filledAt
|| source?.filled_at
|| source?.createdAt
|| source?.created_at
)
return {
id: String(source?.id || `${requestModel?.claimId || requestModel?.id || 'claim'}-item-${index}`),
@@ -226,6 +235,7 @@ function buildExpenseItemViewModel(source, index, requestModel) {
itemAmount,
invoiceId,
time: itemDate || '待补充',
filledAt: filledAt || '待同步',
dayLabel: requestModel?.detailVariant === 'travel' ? `${index + 1}` : '业务发生项',
name: resolveExpenseTypeLabel(itemType),
category: resolveExpenseTypeLabel(itemType),
@@ -234,7 +244,7 @@ function buildExpenseItemViewModel(source, index, requestModel) {
amount: amountDisplay,
status: attachments.length ? '已识别' : '待补充',
tone: attachments.length ? 'ok' : 'bad',
attachmentStatus: attachments.length ? `${attachments.length} 份附件` : '未上传',
attachmentStatus: attachments.length ? '已关联票据' : '未上传',
attachmentHint: attachments.length ? attachmentName || attachments[0] : resolveExpenseUploadHint(),
attachmentTone: attachments.length ? 'ok' : 'missing',
attachments,
@@ -372,12 +382,21 @@ function mapIssueToAdvice(issue) {
export default {
name: 'TravelRequestDetailView',
components: {
ConfirmDialog
ConfirmDialog,
ReturnReasonDialog
},
props: {
request: {
type: Object,
default: () => ({})
},
backLabel: {
type: String,
default: '返回报销列表'
},
approvalMode: {
type: Boolean,
default: false
}
},
emits: ['backToRequests', 'openAssistant', 'request-updated', 'request-deleted'],
@@ -392,10 +411,14 @@ export default {
const deletingExpenseId = ref('')
const pendingUploadExpenseId = ref('')
const submitBusy = ref(false)
const submitConfirmDialogOpen = ref(false)
const deleteBusy = ref(false)
const deleteDialogOpen = ref(false)
const returnBusy = ref(false)
const returnDialogOpen = ref(false)
const approveBusy = ref(false)
const approveConfirmDialogOpen = ref(false)
const leaderOpinion = ref('')
const expenseUploadInput = ref(null)
const expenseAttachmentMeta = reactive({})
const attachmentPreviewOpen = ref(false)
@@ -404,6 +427,7 @@ export default {
const attachmentPreviewUrl = ref('')
const attachmentPreviewName = ref('')
const attachmentPreviewMediaType = ref('')
const attachmentPreviewItemId = ref('')
const expenseEditor = reactive({
itemDate: '',
itemType: 'other',
@@ -455,13 +479,28 @@ export default {
const isTravelRequest = computed(() => request.value.detailVariant === 'travel')
const isDraftRequest = computed(() => request.value.approvalKey === 'draft')
const isEditableRequest = computed(() => ['draft', 'supplement'].includes(request.value.approvalKey))
const canOpenAiEntry = computed(() => isEditableRequest.value)
const canManageCurrentClaim = computed(() => canManageExpenseClaims(currentUser.value))
const canDeleteRequest = computed(() => isEditableRequest.value || canManageCurrentClaim.value)
const isDirectManagerApprovalStage = computed(() => {
const node = String(request.value.node || request.value.approvalStage || '').trim()
return node === '直属领导审批'
})
const showLeaderApprovalPanel = computed(() =>
Boolean(props.approvalMode)
&& request.value.approvalKey === 'in_progress'
&& isDirectManagerApprovalStage.value
&& Boolean(request.value.claimId)
)
const canReturnRequest = computed(() =>
canManageCurrentClaim.value
canReturnExpenseClaims(currentUser.value)
&& request.value.approvalKey === 'in_progress'
&& Boolean(request.value.claimId)
)
const canApproveRequest = computed(() =>
showLeaderApprovalPanel.value
&& canReturnExpenseClaims(currentUser.value)
)
const deleteActionLabel = computed(() => (isDraftRequest.value ? '删除草稿' : '删除单据'))
const deleteDialogTitle = computed(() => `确认${deleteActionLabel.value} ${request.value.id} 吗?`)
const deleteDialogDescription = computed(() =>
@@ -474,6 +513,7 @@ export default {
|| submitBusy.value
|| deleteBusy.value
|| returnBusy.value
|| approveBusy.value
|| creatingExpense.value
|| Boolean(uploadingExpenseId.value)
|| Boolean(deletingAttachmentId.value)
@@ -583,12 +623,8 @@ export default {
})
const uploadedExpenseCount = computed(() => expenseItems.value.filter((item) => item.attachments.length).length)
const hasExpenseRiskColumn = computed(() => expenseItems.value.some((item) => item.attachments.length))
const expenseTableColumnCount = computed(
() => 5 + (hasExpenseRiskColumn.value ? 1 : 0) + (isEditableRequest.value ? 1 : 0)
)
const expenseSummaryText = computed(
() => request.value.expenseTableSummary || '请继续补充票据、说明和系统校验结果。'
() => 6 + (isEditableRequest.value ? 1 : 0)
)
const detailNote = computed(
() =>
@@ -599,7 +635,49 @@ export default {
isEditableRequest.value ? buildDraftBlockingIssues(request.value, expenseItems.value) : []
)
const canSubmit = computed(() => isEditableRequest.value && draftBlockingIssues.value.length === 0 && !actionBusy.value)
const locationInputPlaceholder = computed(() => resolveLocationInputPlaceholder(expenseEditor.itemType))
const attachmentPreviewEntries = computed(() =>
expenseItems.value
.filter((item) => item.invoiceId)
.map((item, index) => ({
item,
itemId: item.id,
index,
name: resolveAttachmentDisplayName(item) || `${index + 1} 条附件`,
metadata: resolveAttachmentMeta(item)
}))
)
const currentAttachmentPreviewIndex = computed(() =>
attachmentPreviewEntries.value.findIndex((entry) => entry.itemId === attachmentPreviewItemId.value)
)
const currentAttachmentPreviewEntry = computed(() => {
const index = currentAttachmentPreviewIndex.value
return index >= 0 ? attachmentPreviewEntries.value[index] : null
})
const attachmentPreviewIndexLabel = computed(() => {
const currentIndex = currentAttachmentPreviewIndex.value
const total = attachmentPreviewEntries.value.length
return currentIndex >= 0 && total > 0 ? `${currentIndex + 1} / ${total}` : ''
})
const canNavigateAttachmentPreview = computed(() => attachmentPreviewEntries.value.length > 1)
const currentAttachmentPreviewInsight = computed(() => {
const entry = currentAttachmentPreviewEntry.value
if (!entry) {
return null
}
return buildAttachmentInsightViewModel(resolveAttachmentMeta(entry.item), entry.item)
})
const currentAttachmentPreviewRiskCards = computed(() => {
const entry = currentAttachmentPreviewEntry.value
if (!entry) {
return []
}
return buildAttachmentRiskCards({
expenseItems: [entry.item],
attachmentMetaByItemId: expenseAttachmentMeta
})
})
function applyLocalExpenseItemPatch(itemId, patch) {
expenseItems.value = rebuildExpenseItems(
@@ -617,36 +695,13 @@ export default {
return String(metadata?.file_name || item.attachmentHint || '').trim()
}
function resolveAttachmentPreviewTitle(item) {
const fileName = resolveAttachmentDisplayName(item)
return fileName ? `预览附件:${fileName}` : '预览附件'
}
function resolveAttachmentRecognition(item) {
const metadata = resolveAttachmentMeta(item)
const documentInfo = metadata?.document_info
const requirementCheck = metadata?.requirement_check
if (!documentInfo && !requirementCheck) {
return null
}
const fields = Array.isArray(documentInfo?.fields)
? documentInfo.fields
.map((field) => ({
label: String(field?.label || '').trim(),
value: String(field?.value || '').trim()
}))
.filter((field) => field.label && field.value)
: []
return {
documentTypeLabel:
String(documentInfo?.document_type_label || '').trim()
|| resolveDocumentTypeLabel(documentInfo?.document_type),
requirementLabel: requirementCheck
? (requirementCheck.matches ? '符合当前费用类型' : '不符合当前费用类型')
: '待校验附件类型',
requirementTone: requirementCheck
? (requirementCheck.matches ? 'pass' : 'high')
: 'medium',
message: String(requirementCheck?.message || '').trim(),
fields: fields.slice(0, 4).map((field) => `${field.label}${field.value}`)
}
return buildAttachmentInsightViewModel(resolveAttachmentMeta(item), item)
}
function buildAttachmentRiskNotice(attachment) {
@@ -676,7 +731,7 @@ export default {
function canPreviewAttachment(item) {
const metadata = resolveAttachmentMeta(item)
return Boolean(item.invoiceId && metadata?.previewable)
return Boolean(item.invoiceId && metadata?.previewable !== false)
}
function revokeAttachmentPreviewUrl() {
@@ -692,6 +747,7 @@ export default {
attachmentPreviewError.value = ''
attachmentPreviewName.value = ''
attachmentPreviewMediaType.value = ''
attachmentPreviewItemId.value = ''
revokeAttachmentPreviewUrl()
}
@@ -769,42 +825,16 @@ export default {
const aiAdvice = computed(() => {
const completionItems = draftBlockingIssues.value.map(mapIssueToAdvice).filter(Boolean)
const riskItems = expenseItems.value
.map((item, index) => {
const state = resolveExpenseRiskState(item)
if (!state || !['medium', 'high'].includes(state.tone)) {
return ''
}
const riskCards = buildAttachmentRiskCards({
expenseItems: expenseItems.value,
attachmentMetaByItemId: expenseAttachmentMeta,
claimRiskFlags: request.value.riskFlags || request.value.risk_flags_json || []
})
const adviceText = String(state.suggestion || state.summary || '').trim()
const prefix = state.tone === 'high' ? '优先整改' : '继续核对'
return `${index + 1} 条附件需${prefix}${adviceText || '请根据系统提示补充或更换附件。'}`
})
.filter(Boolean)
if (!completionItems.length && !riskItems.length) {
return {
tone: 'ready',
badge: '可直接提交',
summary: 'AI判断当前草稿已具备提交条件可以直接发起审批。',
items: [
'点击右下角“提交审批”进入流程。',
'提交前再核对一次合计金额与各条费用明细金额是否一致。',
'如有特殊业务背景或例外情况,可在下方附加说明中补充。'
]
}
}
const hasHighRisk = expenseItems.value.some((item) => resolveExpenseRiskState(item)?.tone === 'high')
return {
tone: hasHighRisk ? 'warning' : 'pending',
badge: hasHighRisk ? '优先整改' : '待补信息',
summary: completionItems.length
? '建议先补齐必填信息,再处理附件核验项,完成后即可提交审批。'
: '草稿信息已基本齐全,建议先处理附件风险后再提交审批。',
items: [...completionItems, ...riskItems]
}
return buildAiAdviceViewModel({
completionItems,
riskCards
})
})
function startExpenseEdit(item) {
@@ -836,12 +866,6 @@ export default {
if (isPlaceholderValue(expenseEditor.itemReason)) {
return '请输入费用说明。'
}
if (
isLocationRequiredExpenseType(expenseEditor.itemType)
&& isPlaceholderValue(expenseEditor.itemLocation)
) {
return '请输入业务地点。'
}
const amount = Number(expenseEditor.itemAmount)
if (!Number.isFinite(amount) || amount <= 0) {
@@ -890,7 +914,12 @@ export default {
}
if (!request.value.claimId) {
toast('当前草稿缺少 claimId暂时无法上传附件。')
toast('当前草稿缺少 claimId暂时无法上传单据。')
return
}
if (item?.invoiceId) {
toast('每条费用明细只能关联一张单据,如需更换请先删除当前单据。')
return
}
@@ -901,22 +930,29 @@ export default {
}
}
async function openAttachmentPreview(item) {
if (!request.value.claimId || !canPreviewAttachment(item)) {
async function loadAttachmentPreview(item) {
if (!request.value.claimId || !item?.invoiceId) {
return
}
closeAttachmentPreview()
attachmentPreviewOpen.value = true
attachmentPreviewLoading.value = true
attachmentPreviewError.value = ''
attachmentPreviewItemId.value = item.id
attachmentPreviewName.value = resolveAttachmentDisplayName(item)
const metadata = resolveAttachmentMeta(item)
attachmentPreviewMediaType.value =
String(metadata?.preview_kind || '').trim() === 'image'
? 'image/png'
: String(metadata?.media_type || '').trim()
let metadata = resolveAttachmentMeta(item)
try {
if (!metadata) {
metadata = await refreshExpenseAttachmentMeta(item.id)
}
if (metadata?.previewable === false) {
throw new Error('当前附件暂不支持直接预览。')
}
attachmentPreviewName.value = resolveAttachmentDisplayName(item)
attachmentPreviewMediaType.value =
String(metadata?.preview_kind || '').trim() === 'image'
? 'image/png'
: String(metadata?.media_type || '').trim()
const blob = await fetchExpenseClaimItemAttachmentPreview(request.value.claimId, item.id)
revokeAttachmentPreviewUrl()
attachmentPreviewUrl.value = URL.createObjectURL(blob)
@@ -928,11 +964,48 @@ export default {
}
}
async function openAttachmentPreview(item) {
if (!request.value.claimId || !canPreviewAttachment(item)) {
return
}
closeAttachmentPreview()
attachmentPreviewOpen.value = true
await loadAttachmentPreview(item)
}
async function goToAttachmentPreview(offset) {
if (!canNavigateAttachmentPreview.value || attachmentPreviewLoading.value) {
return
}
const entries = attachmentPreviewEntries.value
const currentIndex = currentAttachmentPreviewIndex.value
const nextIndex = (currentIndex + offset + entries.length) % entries.length
const nextEntry = entries[nextIndex]
if (nextEntry?.item) {
await loadAttachmentPreview(nextEntry.item)
}
}
function goToPreviousAttachmentPreview() {
void goToAttachmentPreview(-1)
}
function goToNextAttachmentPreview() {
void goToAttachmentPreview(1)
}
async function uploadExpenseFile(item, file) {
if (!item || !file) {
return
}
if (item?.invoiceId) {
toast('每条费用明细只能关联一张单据,如需更换请先删除当前单据。')
return
}
uploadingExpenseId.value = item.id
try {
@@ -986,7 +1059,9 @@ export default {
async function handleExpenseFileChange(event) {
const target = event?.target
const file = target?.files?.[0]
const fileList = target?.files
const fileCount = fileList?.length || 0
const file = fileList?.[0]
const itemId = pendingUploadExpenseId.value
pendingUploadExpenseId.value = ''
@@ -994,6 +1069,11 @@ export default {
target.value = ''
}
if (fileCount > 1) {
toast('一条费用明细只能上传一张单据,请只选择一个文件。')
return
}
if (!file || !itemId) {
return
}
@@ -1059,11 +1139,12 @@ export default {
savingExpenseId.value = item.id
try {
const nextInvoiceId = expenseEditor.invoiceId.trim()
const preservedLocation = String(item.itemLocation || expenseEditor.itemLocation || '').trim()
await updateExpenseClaimItem(request.value.claimId, item.id, {
item_date: expenseEditor.itemDate,
item_type: expenseEditor.itemType,
item_reason: expenseEditor.itemReason.trim(),
item_location: expenseEditor.itemLocation.trim(),
item_location: preservedLocation,
item_amount: Number(expenseEditor.itemAmount),
invoice_id: nextInvoiceId
})
@@ -1071,7 +1152,7 @@ export default {
itemDate: expenseEditor.itemDate,
itemType: expenseEditor.itemType,
itemReason: expenseEditor.itemReason.trim(),
itemLocation: expenseEditor.itemLocation.trim(),
itemLocation: preservedLocation,
itemAmount: Number(expenseEditor.itemAmount),
invoiceId: nextInvoiceId
})
@@ -1096,7 +1177,7 @@ export default {
}
}
async function handleSubmit() {
function handleSubmit() {
if (!request.value.claimId) {
toast('当前草稿缺少 claimId暂时无法提交。')
return
@@ -1107,6 +1188,30 @@ export default {
return
}
submitConfirmDialogOpen.value = true
}
function closeSubmitConfirmDialog() {
if (submitBusy.value) {
return
}
submitConfirmDialogOpen.value = false
}
async function confirmSubmitRequest() {
if (!request.value.claimId) {
toast('当前草稿缺少 claimId暂时无法提交。')
submitConfirmDialogOpen.value = false
return
}
if (!canSubmit.value) {
toast(draftBlockingIssues.value[0] || '请先补全草稿信息,再提交审批。')
submitConfirmDialogOpen.value = false
return
}
submitBusy.value = true
try {
const payload = await submitExpenseClaim(request.value.claimId)
@@ -1119,6 +1224,7 @@ export default {
} else {
toast(`${request.value.id} 提交结果已更新。`)
}
submitConfirmDialogOpen.value = false
emit('request-updated', { claimId: request.value.claimId })
} catch (error) {
toast(error?.message || '提交审批失败,请稍后重试。')
@@ -1190,7 +1296,7 @@ export default {
returnDialogOpen.value = false
}
async function confirmReturnRequest() {
async function confirmReturnRequest(payload) {
if (!request.value.claimId) {
toast('当前单据缺少 claimId暂时无法退回。')
return
@@ -1198,9 +1304,7 @@ export default {
returnBusy.value = true
try {
await returnExpenseClaim(request.value.claimId, {
reason: '详情页退回,请申请人调整后重新提交。'
})
await returnExpenseClaim(request.value.claimId, payload)
returnDialogOpen.value = false
toast(`${request.value.id} 已退回待提交。`)
emit('request-updated', { claimId: request.value.claimId })
@@ -1211,7 +1315,62 @@ export default {
}
}
function handleApproveRequest() {
if (!request.value.claimId) {
toast('当前单据缺少 claimId暂时无法审批通过。')
return
}
if (!canApproveRequest.value) {
toast('当前节点不支持领导审批通过。')
return
}
approveConfirmDialogOpen.value = true
}
function closeApproveConfirmDialog() {
if (approveBusy.value) {
return
}
approveConfirmDialogOpen.value = false
}
async function confirmApproveRequest() {
if (!request.value.claimId) {
toast('当前单据缺少 claimId暂时无法审批通过。')
approveConfirmDialogOpen.value = false
return
}
if (!canApproveRequest.value) {
toast('当前节点不支持领导审批通过。')
approveConfirmDialogOpen.value = false
return
}
approveBusy.value = true
try {
await approveExpenseClaim(request.value.claimId, {
opinion: leaderOpinion.value.trim()
})
approveConfirmDialogOpen.value = false
leaderOpinion.value = ''
toast(`${request.value.id} 已审批通过,流转至财务审批。`)
emit('request-updated', { claimId: request.value.claimId })
} catch (error) {
toast(error?.message || '审批通过失败,请稍后重试。')
} finally {
approveBusy.value = false
}
}
function openAiEntry() {
if (!canOpenAiEntry.value) {
return
}
emit('openAssistant', {
source: 'detail',
prompt: '',
@@ -1229,21 +1388,33 @@ export default {
actionBusy,
aiAdvice,
attachmentPreviewError,
attachmentPreviewIndexLabel,
attachmentPreviewLoading,
attachmentPreviewMediaType,
attachmentPreviewName,
attachmentPreviewOpen,
attachmentPreviewUrl,
approveBusy,
approveConfirmDialogOpen,
canDeleteRequest,
canManageCurrentClaim,
canNavigateAttachmentPreview,
canOpenAiEntry,
canApproveRequest,
canReturnRequest,
canSubmit,
canPreviewAttachment,
closeApproveConfirmDialog,
closeDeleteDialog,
closeAttachmentPreview,
closeSubmitConfirmDialog,
closeReturnDialog,
confirmApproveRequest,
confirmDeleteRequest,
confirmSubmitRequest,
confirmReturnRequest,
currentAttachmentPreviewInsight,
currentAttachmentPreviewRiskCards,
currentProgressRingMotion,
deleteActionLabel,
deleteBusy,
@@ -1258,39 +1429,43 @@ export default {
creatingExpense,
expenseEditor,
expenseItems,
expenseSummaryText,
expenseTableColumnCount,
expenseTotal,
expenseUploadInput,
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
handleAddExpenseItem,
handleApproveRequest,
handleDeleteRequest,
handleExpenseFileChange,
handleReturnRequest,
handleSubmit,
hasExpenseRiskColumn,
heroFactItems,
isDraftRequest,
isEditableRequest,
isTravelRequest,
locationInputPlaceholder,
openAiEntry,
openAttachmentPreview,
goToNextAttachmentPreview,
goToPreviousAttachmentPreview,
profile,
progressSteps,
removeExpenseItem,
request,
leaderOpinion,
removeExpenseAttachment,
removeExpenseItem,
resolveAttachmentDisplayName,
resolveAttachmentPreviewTitle,
resolveAttachmentRecognition,
resolveExpenseRiskState,
resolveExpenseIssues,
returnBusy,
returnDialogOpen,
savingExpenseId,
showLeaderApprovalPanel,
showExpenseRisk,
startExpenseEdit,
submitBusy,
submitConfirmDialogOpen,
triggerExpenseUpload,
uploadedExpenseCount,
uploadingExpenseId,

View File

@@ -0,0 +1,290 @@
const DOCUMENT_TYPE_LABELS = {
flight_itinerary: '机票/航班行程单',
train_ticket: '火车/高铁票',
hotel_invoice: '酒店住宿票据',
taxi_receipt: '出租车/网约车票据',
parking_toll_receipt: '停车/通行费票据',
meal_receipt: '餐饮票据',
office_invoice: '办公用品票据',
meeting_invoice: '会议/会务票据',
training_invoice: '培训票据',
vat_invoice: '增值税发票',
receipt: '一般收据/凭证',
other: '其他单据'
}
function normalizeText(value) {
return String(value || '').trim()
}
function uniqueTexts(values) {
return [...new Set(values.map((item) => normalizeText(item)).filter(Boolean))]
}
function normalizeTone(value) {
const tone = normalizeText(value).toLowerCase()
if (tone === 'pass') return 'pass'
if (tone === 'high') return 'high'
if (tone === 'medium') return 'medium'
if (tone === 'low') return 'low'
return 'medium'
}
function resolveDocumentTypeLabel(value) {
return DOCUMENT_TYPE_LABELS[normalizeText(value)] || DOCUMENT_TYPE_LABELS.other
}
function normalizeRuleBasis(value) {
if (Array.isArray(value)) {
return value.map((item) => normalizeText(item)).filter(Boolean)
}
const text = normalizeText(value)
return text ? [text] : []
}
export function buildAttachmentInsightViewModel(metadata, item = {}) {
if (!metadata) {
return null
}
const documentInfo = metadata.document_info || {}
const requirementCheck = metadata.requirement_check || null
const analysis = metadata.analysis || null
const documentTypeLabel =
normalizeText(documentInfo.document_type_label) || resolveDocumentTypeLabel(documentInfo.document_type)
const fields = Array.isArray(documentInfo.fields)
? documentInfo.fields
.map((field) => ({
label: normalizeText(field?.label),
value: normalizeText(field?.value)
}))
.filter((field) => field.label && field.value)
.map((field) => `${field.label}${field.value}`)
: []
const ruleBasis = uniqueTexts([
...normalizeRuleBasis(analysis?.rule_basis || analysis?.ruleBasis),
...normalizeRuleBasis(requirementCheck?.rule_basis || requirementCheck?.ruleBasis),
normalizeText(requirementCheck?.message),
documentTypeLabel ? `票据识别依据:系统将附件识别为${documentTypeLabel}` : '',
normalizeText(item?.name) ? `费用项目依据:当前明细为${normalizeText(item.name)}` : ''
])
return {
fileName: normalizeText(metadata.file_name || item.attachmentHint || item.invoiceId),
mediaType: normalizeText(metadata.media_type),
previewable: metadata.previewable !== false,
documentTypeLabel,
requirementLabel: requirementCheck
? (requirementCheck.matches ? '符合当前费用类型' : '不符合当前费用类型')
: '待校验附件类型',
requirementTone: requirementCheck
? (requirementCheck.matches ? 'pass' : 'high')
: 'medium',
message: normalizeText(requirementCheck?.message),
fields: fields.slice(0, 8),
ruleBasis,
analysis: analysis
? {
label: normalizeText(analysis.label) || 'AI提示',
tone: normalizeTone(analysis.severity),
headline: normalizeText(analysis.headline) || normalizeText(analysis.label) || 'AI提示',
summary: normalizeText(analysis.summary),
points: Array.isArray(analysis.points) ? analysis.points.map((point) => normalizeText(point)).filter(Boolean) : [],
suggestion: normalizeText(analysis.suggestion)
}
: null
}
}
function buildCardSuggestion(analysis, insight) {
return (
normalizeText(analysis?.suggestion)
|| normalizeText(insight?.message)
|| '请根据规则依据核对附件和费用明细,必要时补充说明、更换附件或调整费用项目。'
)
}
function buildRiskCardFromPoint({ item, index, point, pointIndex, insight, analysis }) {
const tone = normalizeTone(analysis?.severity)
const label = normalizeText(analysis?.label) || (tone === 'high' ? '高风险' : '中风险')
return {
id: `${normalizeText(item?.id) || `expense-${index}`}-${pointIndex}`,
tone,
label,
title: `${index + 1} 条:${normalizeText(analysis?.headline) || normalizeText(item?.name) || '附件风险'}`,
risk: normalizeText(point) || normalizeText(analysis?.summary) || '附件存在待核对风险。',
summary: normalizeText(analysis?.summary),
ruleBasis: insight?.ruleBasis?.length ? insight.ruleBasis : ['系统根据附件识别结果与费用项目规则进行比对。'],
suggestion: buildCardSuggestion(analysis, insight)
}
}
function parseReturnCount(flag) {
const count = Number(flag?.return_count ?? flag?.returnCount ?? 0)
return Number.isFinite(count) && count > 0 ? Math.floor(count) : 0
}
function resolveLatestManualReturnFlag(flags) {
const manualReturnFlags = flags.filter(
(flag) => flag && typeof flag === 'object' && normalizeText(flag.source) === 'manual_return'
)
if (!manualReturnFlags.length) {
return null
}
return manualReturnFlags.reduce((latest, flag) => {
const latestCount = parseReturnCount(latest)
const nextCount = parseReturnCount(flag)
if (nextCount !== latestCount) {
return nextCount > latestCount ? flag : latest
}
const latestTime = Date.parse(normalizeText(latest?.created_at || latest?.createdAt))
const nextTime = Date.parse(normalizeText(flag?.created_at || flag?.createdAt))
if (Number.isFinite(nextTime) && (!Number.isFinite(latestTime) || nextTime >= latestTime)) {
return flag
}
return latest
}, manualReturnFlags[0])
}
function buildManualReturnRiskCard(flag) {
if (!flag) {
return null
}
const returnCount = parseReturnCount(flag)
const stageReturnCount = Number(flag.stage_return_count ?? flag.stageReturnCount ?? 0)
const returnStage = normalizeText(flag.return_stage || flag.returnStage || flag.previous_approval_stage)
const riskPoints = Array.isArray(flag.risk_points || flag.riskPoints)
? (flag.risk_points || flag.riskPoints).map((item) => normalizeText(item)).filter(Boolean)
: []
const risk = normalizeText(flag.message || flag.reason || flag.summary) || '审批人退回该单据,请补充后重新提交。'
const ruleBasis = uniqueTexts([
returnCount ? `累计退回 ${returnCount} 次。` : '',
returnStage ? `本次退回环节:${returnStage}` : '',
stageReturnCount > 0 ? `该环节累计退回 ${Math.floor(stageReturnCount)} 次。` : '',
...riskPoints.map((item) => `退回风险点:${item}`)
])
return {
id: `manual-return-${returnCount || 'latest'}`,
tone: 'medium',
label: '退回原因',
title: returnCount ? `${returnCount} 次退回` : '审批退回',
risk,
summary: normalizeText(flag.reason),
ruleBasis: ruleBasis.length ? ruleBasis : ['审批人已退回该单据。'],
suggestion: '请按退回原因补充材料、修正明细或完善说明后重新提交。'
}
}
export function buildAttachmentRiskCards({
expenseItems = [],
attachmentMetaByItemId = {},
claimRiskFlags = []
} = {}) {
const attachmentCards = expenseItems.flatMap((item, index) => {
if (!item?.invoiceId) {
return []
}
const metadata = attachmentMetaByItemId[item.id]
const insight = buildAttachmentInsightViewModel(metadata, item)
const analysis = metadata?.analysis
const tone = normalizeTone(analysis?.severity)
if (!analysis || !['medium', 'high'].includes(tone)) {
return []
}
const points = Array.isArray(analysis.points) && analysis.points.length
? analysis.points
: [analysis.summary || analysis.headline || analysis.label]
return points
.map((point, pointIndex) => buildRiskCardFromPoint({ item, index, point, pointIndex, insight, analysis }))
.filter((card) => card.risk)
})
const normalizedClaimRiskFlags = Array.isArray(claimRiskFlags) ? claimRiskFlags : []
const latestManualReturnCard = buildManualReturnRiskCard(resolveLatestManualReturnFlag(normalizedClaimRiskFlags))
const claimCards = normalizedClaimRiskFlags
.map((flag, index) => {
if (flag && typeof flag === 'object' && normalizeText(flag.source) === 'manual_return') {
return null
}
if (!flag || typeof flag !== 'object') {
const risk = normalizeText(flag)
return risk
? {
id: `claim-risk-${index}`,
tone: 'medium',
label: '单据风险',
title: '单据风险提示',
risk,
summary: '',
ruleBasis: ['系统预审规则命中该风险提示。'],
suggestion: '请结合业务背景补充说明或调整单据后再提交。'
}
: null
}
const tone = normalizeTone(flag.severity)
if (!['medium', 'high'].includes(tone)) {
return null
}
return {
id: `claim-risk-${index}`,
tone,
label: normalizeText(flag.label) || (tone === 'high' ? '高风险' : '中风险'),
title: normalizeText(flag.label) || '单据风险提示',
risk: normalizeText(flag.message || flag.reason || flag.summary),
summary: normalizeText(flag.summary),
ruleBasis: normalizeRuleBasis(flag.rule_basis || flag.ruleBasis).length
? normalizeRuleBasis(flag.rule_basis || flag.ruleBasis)
: ['系统预审规则命中该风险提示。'],
suggestion: normalizeText(flag.suggestion) || '请结合业务背景补充说明或调整单据后再提交。'
}
})
.filter(Boolean)
if (latestManualReturnCard) {
claimCards.unshift(latestManualReturnCard)
}
return [...attachmentCards, ...claimCards]
}
export function buildAiAdviceViewModel({ completionItems = [], riskCards = [] } = {}) {
const normalizedCompletionItems = completionItems.map((item) => normalizeText(item)).filter(Boolean)
const normalizedRiskCards = riskCards.filter(Boolean)
const hasHighRisk = normalizedRiskCards.some((card) => card.tone === 'high')
if (!normalizedCompletionItems.length && !normalizedRiskCards.length) {
return {
tone: 'ready',
badge: '可直接提交',
summary: 'AI判断当前草稿已具备提交条件可以直接发起审批。',
items: [
'点击右下角“提交审批”进入流程。',
'提交前再核对一次合计金额与各条费用明细金额是否一致。',
'如有特殊业务背景或例外情况,可在下方附加说明中补充。'
],
riskCards: []
}
}
return {
tone: hasHighRisk ? 'warning' : 'pending',
badge: hasHighRisk ? '优先整改' : '待核对',
summary: normalizedRiskCards.length
? `AI已整理出 ${normalizedRiskCards.length} 个风险点,请逐项核对规则依据和修改建议。`
: '建议先补齐必填信息,完成后即可提交审批。',
items: normalizedCompletionItems,
riskCards: normalizedRiskCards
}
}