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