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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user