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,139 +1,311 @@
import { computed, ref } from 'vue'
import { computed, reactive, ref, watch } from 'vue'
import { useToast } from '../../composables/useToast.js'
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
import { deleteExpenseClaim, submitExpenseClaim, updateExpenseClaimItem } from '../../services/reimbursements.js'
import { normalizeRequestForUi } from '../../utils/requestViewModel.js'
const EXPENSE_TYPE_OPTIONS = [
{ value: 'travel', label: '差旅费' },
{ value: 'entertainment', label: '业务招待费' },
{ value: 'office', label: '办公费' },
{ value: 'meeting', label: '会务费' },
{ value: 'training', label: '培训费' },
{ value: 'hotel', label: '住宿费' },
{ value: 'transport', label: '交通费' },
{ value: 'meal', label: '餐费' },
{ value: 'other', label: '其他费用' }
]
function parseCurrency(value) {
return Number.parseFloat(String(value).replace(/[^\d.]/g, '')) || 0
}
function formatCurrency(value) {
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY',
minimumFractionDigits: 0,
maximumFractionDigits: Number.isInteger(value) ? 0 : 2
}).format(value)
}
function buildFallbackProgressSteps() {
return [
{ index: 1, label: '保存草稿', time: '已完成', done: true, active: true },
{ index: 2, label: '待提交', time: '进行中', active: true, current: true },
{ index: 3, label: 'AI验审', time: '待处理' },
{ index: 4, label: '直属领导审批', time: '待处理' },
{ index: 5, label: '财务审批', time: '待处理' },
{ index: 6, label: '归档入账', time: '待处理' }
]
}
function buildFallbackExpenseItems(request) {
return [
{
id: 'fallback-1',
itemDate: '',
itemType: request.typeCode || 'other',
itemReason: request.reason,
itemLocation: request.sceneTarget,
itemAmount: parseCurrency(request.amountDisplay),
invoiceId: '',
time: '待补充',
dayLabel: request.detailVariant === 'travel' ? '出行日' : '业务发生日',
name: request.typeLabel,
category: request.typeLabel,
desc: request.reason,
detail: request.sceneTarget,
amount: request.amountDisplay,
status: '待补充',
tone: 'bad',
attachmentStatus: '待上传',
attachmentHint: '请在此单据中继续补充附件',
attachmentTone: 'missing',
attachments: [],
riskLabel: '待补材料',
riskText: request.riskSummary,
riskTone: 'medium'
}
]
}
function isPlaceholderValue(value) {
const text = String(value || '').trim()
if (!text) {
return true
}
return ['待补充', '暂无', '无', '未知', '处理中'].includes(text.replace(/\s+/g, ''))
}
function isValidIsoDate(value) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(String(value || '').trim())) {
return false
}
const nextDate = new Date(`${value}T00:00:00`)
return !Number.isNaN(nextDate.getTime()) && nextDate.toISOString().slice(0, 10) === value
}
function buildExpenseDraftIssues(item) {
const issues = []
if (!isValidIsoDate(item.itemDate)) {
issues.push('缺少日期')
}
if (isPlaceholderValue(item.itemType)) {
issues.push('缺少费用项目')
}
if (isPlaceholderValue(item.itemReason)) {
issues.push('缺少说明')
}
if (isPlaceholderValue(item.itemLocation)) {
issues.push('缺少地点')
}
if (!Number.isFinite(Number(item.itemAmount)) || Number(item.itemAmount) <= 0) {
issues.push('缺少金额')
}
if (isPlaceholderValue(item.invoiceId)) {
issues.push('缺少票据标识')
}
return issues
}
function buildDraftBlockingIssues(request, expenseItems) {
const issues = []
if (isPlaceholderValue(request.profileName)) {
issues.push('申请人未完善')
}
if (isPlaceholderValue(request.profileDepartment)) {
issues.push('所属部门未完善')
}
if (isPlaceholderValue(request.typeLabel)) {
issues.push('报销类型未完善')
}
if (isPlaceholderValue(request.reason)) {
issues.push('报销事由未完善')
}
if (isPlaceholderValue(request.location)) {
issues.push('业务地点未完善')
}
if (isPlaceholderValue(request.occurredDisplay)) {
issues.push('发生时间未完善')
}
if (!Number.isFinite(Number(request.amountValue)) || Number(request.amountValue) <= 0) {
issues.push('报销金额未完善')
}
if (!expenseItems.length) {
issues.push('费用明细不能为空')
}
expenseItems.forEach((item, index) => {
buildExpenseDraftIssues(item).forEach((issue) => {
issues.push(`费用明细第 ${index + 1}${issue}`)
})
})
return [...new Set(issues)]
}
export default {
name: 'TravelRequestDetailView',
components: {
ConfirmDialog
},
props: {
request: {
type: Object,
default: () => ({})
}
},
emits: ['backToRequests', 'openAssistant'] ,
request: {
type: Object,
default: () => ({})
}
},
emits: ['backToRequests', 'openAssistant', 'request-updated', 'request-deleted'],
setup(props, { emit }) {
const { toast } = useToast()
const expandedExpenseId = ref(null)
const aiEntryOpen = ref(false)
const aiDraft = ref('')
const aiFileInput = ref(null)
const aiEntrySeed = ref(2)
const pendingAiExpense = ref(null)
const uploadedAiFiles = ref([])
const expenseItems = ref([
const editingExpenseId = ref('')
const savingExpenseId = ref('')
const submitBusy = ref(false)
const deleteBusy = ref(false)
const deleteDialogOpen = ref(false)
const expenseEditor = reactive({
itemDate: '',
itemType: 'other',
itemReason: '',
itemLocation: '',
itemAmount: '',
invoiceId: ''
})
const request = computed(() => {
const normalized = normalizeRequestForUi(props.request)
return (
normalized || {
id: 'EXP-202605-000',
claimId: '',
reason: '待补充报销事由',
typeLabel: '其他费用',
typeCode: 'other',
detailVariant: 'general',
sceneTarget: '待补充',
location: '待补充',
occurredDisplay: '待补充',
applyTime: '待补充',
amountDisplay: '¥0',
amountValue: 0,
node: '待提交',
approval: '草稿',
approvalKey: 'draft',
approvalTone: 'draft',
secondaryStatusLabel: '票据状态',
secondaryStatusValue: '待补充',
secondaryStatusTone: 'warning',
relatedCustomer: '待补充',
attachmentSummary: '待补充',
riskSummary: '待补充',
note: '',
profileName: '当前申请人',
profileDepartment: '待补充部门',
profileAvatar: '申'
}
)
})
const isTravelRequest = computed(() => request.value.detailVariant === 'travel')
const isDraftRequest = computed(() => request.value.approvalKey === 'draft')
const actionBusy = computed(() => Boolean(savingExpenseId.value) || submitBusy.value || deleteBusy.value)
const profile = computed(() => ({
name: request.value.profileName,
department: request.value.profileDepartment,
avatar: request.value.profileAvatar
}))
const expenseItems = ref([])
watch(
request,
(nextRequest) => {
expenseItems.value =
Array.isArray(nextRequest.expenseItems) && nextRequest.expenseItems.length
? nextRequest.expenseItems
: buildFallbackExpenseItems(nextRequest)
expandedExpenseId.value = null
editingExpenseId.value = ''
},
{ immediate: true }
)
const heroStats = computed(() => [
{
id: 'exp-1',
time: '07-08',
dayLabel: '第 1 天',
name: '高铁票',
category: '交通',
desc: '上海虹桥 -> 杭州东',
detail: '客户方案汇报前往现场',
amount: '¥236.00',
status: '规则通过',
tone: 'ok',
attachmentStatus: '2 份附件',
attachmentHint: '车票 + 行程单',
attachmentTone: 'ok',
attachments: ['高铁票.pdf', '行程单.pdf'],
riskLabel: '规则通过',
riskText: '票据与行程匹配',
riskTone: 'low'
label: '金额',
value: request.value.amountDisplay,
kind: 'text'
},
{
id: 'exp-2',
time: '07-09',
dayLabel: '第 2 天',
name: '酒店住宿',
category: '住宿',
desc: '杭州西湖商务酒店',
detail: '1 晚住宿,含早餐',
amount: '¥1,180.00',
status: '待补材料',
tone: 'bad',
attachmentStatus: '缺 1 份',
attachmentHint: '缺少入住清单',
attachmentTone: 'partial',
attachments: ['酒店发票.jpg'],
riskLabel: '待补材料',
riskText: '需补酒店入住清单',
riskTone: 'medium'
label: '当前节点',
value: request.value.node,
kind: 'pill',
className: 'state-pill',
tone: request.value.approvalTone
},
{
id: 'exp-3',
time: '07-10',
dayLabel: '第 3 天',
name: '出租车',
category: '市内交通',
desc: '客户公司往返酒店',
detail: '含夜间打车 2 次',
amount: '¥128.00',
status: '需说明',
tone: 'bad',
attachmentStatus: '3 份附件',
attachmentHint: '发票已上传',
attachmentTone: 'ok',
attachments: ['出租车发票1.jpg', '出租车发票2.jpg', '打车订单.png'],
riskLabel: '超标说明',
riskText: '1 笔夜间交通需补充说明',
riskTone: 'medium'
label: '审批状态',
value: request.value.approval,
kind: 'pill',
className: 'approval-pill',
tone: request.value.approvalTone
},
{
id: 'exp-4',
time: '07-11',
dayLabel: '第 4 天',
name: '餐补',
category: '补贴',
desc: '差旅餐补',
detail: '按 4 天标准自动计算',
amount: '¥320.00',
status: '规则通过',
tone: 'ok',
attachmentStatus: '系统生成',
attachmentHint: '无需上传附件',
attachmentTone: 'neutral',
attachments: [],
riskLabel: '规则通过',
riskText: '补贴标准校验通过',
riskTone: 'low'
label: request.value.secondaryStatusLabel,
value: request.value.secondaryStatusValue,
kind: 'pill',
className: 'risk-pill',
tone: request.value.secondaryStatusTone
}
])
const request = computed(() => ({
id: props.request?.id ?? 'BR240712001',
reason: props.request?.reason ?? '客户方案汇报',
city: props.request?.city ?? '上海',
period: props.request?.period ?? '07-08~07-11 (4天)',
applyTime: props.request?.applyTime ?? '2024-07-07',
amount: props.request?.amount ?? '¥3,680.00',
node: props.request?.node ?? '财务审核',
approval: props.request?.approval ?? '审批中',
approvalTone: props.request?.approvalTone ?? 'info',
travel: props.request?.travel ?? '已订酒店/机票',
travelTone: props.request?.travelTone ?? 'low'
}))
const heroSummaryItems = computed(() => {
const commonItems = [
{ label: '单号', value: request.value.id, icon: 'mdi mdi-pound-box-outline' },
{ label: '报销类型', value: request.value.typeLabel, icon: 'mdi mdi-tag-multiple' }
]
const profile = {
name: '张晓明',
department: '财务管理员',
avatar: '张'
}
if (isTravelRequest.value) {
return [
...commonItems,
{ label: '出行路线', value: request.value.sceneTarget, icon: 'mdi mdi-map-marker-path' },
{ label: '出差区间', value: request.value.occurredDisplay, icon: 'mdi mdi-clock-outline' },
{ label: '关联客户', value: request.value.relatedCustomer, icon: 'mdi mdi-domain' },
{ label: '票据关联', value: request.value.attachmentSummary, icon: 'mdi mdi-file-document-multiple-outline' },
{ label: '出差事由', value: request.value.reason, icon: 'mdi mdi-briefcase-outline' }
]
}
const summaryItems = [
{ label: '出差城市', value: request.value.city, icon: 'mdi mdi-map-marker-path' },
{ label: '出差区间', value: request.value.period, icon: 'mdi mdi-clock-outline' },
{ label: '票据关联', value: '6 条明细 / 5 份材料', icon: 'mdi mdi-file-document-multiple-outline' },
{ label: '商旅状态', value: request.value.travel, icon: 'mdi mdi-airplane' },
{ label: '出差事由', value: request.value.reason, icon: 'mdi mdi-briefcase-outline' }
]
return [
...commonItems,
{ label: '业务地点', value: request.value.sceneTarget, icon: 'mdi mdi-map-marker-outline' },
{ label: '发生时间', value: request.value.occurredDisplay, icon: 'mdi mdi-calendar-month-outline' },
{ label: '关联客户', value: request.value.relatedCustomer, icon: 'mdi mdi-domain' },
{ label: '票据关联', value: request.value.attachmentSummary, icon: 'mdi mdi-paperclip' },
{ label: '风险提示', value: request.value.riskSummary, icon: 'mdi mdi-shield-alert-outline' }
]
})
const heroSummaryItems = computed(() => [
{ label: '单号', value: request.value.id, icon: 'mdi mdi-pound-box-outline' },
{ label: '申请类型', value: '差旅费申请/报销', icon: 'mdi mdi-tag-multiple' },
...summaryItems
])
const progressSteps = computed(() =>
Array.isArray(request.value.progressSteps) && request.value.progressSteps.length
? request.value.progressSteps
: buildFallbackProgressSteps()
)
const currentProgressRingMotion = {
initial: {
scale: 1,
opacity: 0.34,
opacity: 0.34
},
enter: {
scale: [1, 1.42, 1.78],
@@ -144,41 +316,186 @@ export default {
repeatType: 'loop',
repeatDelay: 0.85,
ease: 'easeOut',
times: [0, 0.5, 1],
},
},
times: [0, 0.5, 1]
}
}
}
const progressSteps = computed(() => {
return [
{ index: 1, label: '提交申请', time: '07-11 08:46', done: true, active: true },
{ index: 2, label: '票据识别', time: '07-11 08:48', done: true, active: true },
{ index: 3, label: '费用归类', time: '07-11 08:49', done: true, active: true },
{ index: 4, label: '部门负责人审批', time: '07-11 11:28', done: true, active: true },
{ index: 5, label: '财务审批', time: '进行中', active: true, current: true },
{ index: 6, label: '归档入账', time: '待处理' }
]
})
const expenseTotal = computed(() => {
const total = expenseItems.value.reduce((sum, item) => sum + parseCurrency(item.amount), 0)
return formatCurrency(total)
})
const uploadedExpenseCount = computed(() => expenseItems.value.filter((item) => item.attachments.length).length)
const canSendAiEntry = computed(() => Boolean(aiDraft.value.trim() || uploadedAiFiles.value.length))
const detailNote = '本次出差用于客户方案汇报与现场沟通,需覆盖往返交通、住宿及市内交通费用。已完成主要票据上传,待补酒店入住清单后即可进入完整审批流程。'
const uploadedExpenseCount = computed(() => expenseItems.value.filter((item) => item.attachments.length).length)
const expenseSummaryText = computed(
() => request.value.expenseTableSummary || '请继续补充票据、说明和系统校验结果。'
)
const detailNote = computed(
() =>
request.value.note
|| (isTravelRequest.value
? '该差旅报销单尚未补充完整说明,请继续完善后再提交审批。'
: '该报销单尚未补充完整说明,请继续完善后再提交审批。')
)
const draftBlockingIssues = computed(() =>
isDraftRequest.value ? buildDraftBlockingIssues(request.value, expenseItems.value) : []
)
const canSubmit = computed(() => isDraftRequest.value && draftBlockingIssues.value.length === 0 && !actionBusy.value)
const validationTone = computed(() => (canSubmit.value ? 'ready' : 'pending'))
const validationSummary = computed(() =>
canSubmit.value
? '当前草稿信息完整,可以提交审批。'
: '当前草稿仍有未完善字段,提交按钮会保持禁用。'
)
function toggleExpenseAttachments(id) {
expandedExpenseId.value = expandedExpenseId.value === id ? null : id
}
function resolveExpenseIssues(item) {
return buildExpenseDraftIssues(item)
}
function showExpenseRisk(item) {
return Boolean(item.riskText)
return Boolean(resolveExpenseIssues(item).length || item.riskText)
}
function startExpenseEdit(item) {
if (!isDraftRequest.value || actionBusy.value) {
return
}
editingExpenseId.value = item.id
expenseEditor.itemDate = item.itemDate || ''
expenseEditor.itemType = item.itemType || 'other'
expenseEditor.itemReason = item.itemReason || (item.desc === '待补充' ? '' : item.desc)
expenseEditor.itemLocation = item.itemLocation || (item.detail === '待补充' ? '' : item.detail)
expenseEditor.itemAmount = item.itemAmount ? String(item.itemAmount) : ''
expenseEditor.invoiceId = item.invoiceId || ''
expandedExpenseId.value = null
}
function cancelExpenseEdit() {
editingExpenseId.value = ''
}
function validateExpenseEditor() {
if (!isValidIsoDate(expenseEditor.itemDate)) {
return '请输入正确的费用日期,格式为 YYYY-MM-DD。'
}
if (isPlaceholderValue(expenseEditor.itemType)) {
return '请选择费用项目。'
}
if (isPlaceholderValue(expenseEditor.itemReason)) {
return '请输入费用说明。'
}
if (isPlaceholderValue(expenseEditor.itemLocation)) {
return '请输入业务地点。'
}
const amount = Number(expenseEditor.itemAmount)
if (!Number.isFinite(amount) || amount <= 0) {
return '请输入大于 0 的费用金额。'
}
if (isPlaceholderValue(expenseEditor.invoiceId)) {
return '请输入票据标识或附件名称。'
}
return ''
}
async function saveExpenseEdit(item) {
if (!request.value.claimId) {
toast('当前草稿缺少 claimId暂时无法保存费用明细。')
return
}
const validationError = validateExpenseEditor()
if (validationError) {
toast(validationError)
return
}
savingExpenseId.value = item.id
try {
await updateExpenseClaimItem(request.value.claimId, item.id, {
item_date: expenseEditor.itemDate,
item_type: expenseEditor.itemType,
item_reason: expenseEditor.itemReason.trim(),
item_location: expenseEditor.itemLocation.trim(),
item_amount: Number(expenseEditor.itemAmount),
invoice_id: expenseEditor.invoiceId.trim()
})
editingExpenseId.value = ''
toast('费用明细已保存。')
emit('request-updated', { claimId: request.value.claimId })
} catch (error) {
toast(error?.message || '费用明细保存失败,请稍后重试。')
} finally {
savingExpenseId.value = ''
}
}
async function handleSubmit() {
if (!request.value.claimId) {
toast('当前草稿缺少 claimId暂时无法提交。')
return
}
if (!canSubmit.value) {
toast(draftBlockingIssues.value[0] || '请先补全草稿信息,再提交审批。')
return
}
submitBusy.value = true
try {
await submitExpenseClaim(request.value.claimId)
toast(`${request.value.id} 已提交审批。`)
emit('request-updated', { claimId: request.value.claimId })
} catch (error) {
toast(error?.message || '提交审批失败,请稍后重试。')
} finally {
submitBusy.value = false
}
}
async function handleDeleteDraft() {
if (!request.value.claimId) {
toast('当前草稿缺少 claimId暂时无法删除。')
return
}
deleteDialogOpen.value = true
}
function closeDeleteDialog() {
if (deleteBusy.value) {
return
}
deleteDialogOpen.value = false
}
async function confirmDeleteDraft() {
if (!request.value.claimId) {
toast('当前草稿缺少 claimId暂时无法删除。')
return
}
deleteBusy.value = true
try {
const payload = await deleteExpenseClaim(request.value.claimId)
deleteDialogOpen.value = false
toast(payload?.message || `${request.value.id} 草稿已删除。`)
emit('request-deleted', { claimId: request.value.claimId })
} catch (error) {
toast(error?.message || '删除草稿失败,请稍后重试。')
} finally {
deleteBusy.value = false
}
}
function openAiEntry() {
aiEntryOpen.value = false
emit('openAssistant', {
source: 'detail',
prompt: '',
@@ -186,266 +503,45 @@ export default {
})
}
function closeAiEntry() {
aiEntryOpen.value = false
aiDraft.value = ''
pendingAiExpense.value = null
uploadedAiFiles.value = []
if (aiFileInput.value) {
aiFileInput.value.value = ''
}
}
function parseCurrency(value) {
return Number.parseFloat(String(value).replace(/[^\d.]/g, '')) || 0
}
function formatCurrency(value) {
return `${value.toFixed(2)}`
}
function buildNextExpenseId() {
aiEntrySeed.value += 1
return `exp-ai-${aiEntrySeed.value}`
}
function inferExpenseCategory(text) {
if (/高铁|火车|机票|航班|打车|出租车|地铁|公交|交通/.test(text)) return '交通'
if (/酒店|住宿|房费/.test(text)) return '住宿'
if (/餐|午饭|晚饭|早餐|餐补/.test(text)) return '餐饮'
return '其他'
}
function inferExpenseName(text, category) {
if (/高铁/.test(text)) return '高铁票'
if (/机票|航班/.test(text)) return '机票'
if (/出租车|打车/.test(text)) return '出租车'
if (/酒店|住宿/.test(text)) return '酒店住宿'
if (/餐补/.test(text)) return '餐补'
if (/餐|午饭|晚饭|早餐/.test(text)) return '餐饮'
return `${category}费用`
}
function inferAttachments(text, uploadedFiles = []) {
if (uploadedFiles.length) {
return {
status: `${uploadedFiles.length} 份附件`,
hint: uploadedFiles.map((file) => file.name).join(' + '),
tone: 'ok',
files: uploadedFiles.map((file) => file.name),
}
}
if (/无需|免附件|系统生成/.test(text)) {
return {
status: '系统生成',
hint: '无需上传附件',
tone: 'neutral',
files: [],
}
}
const uploaded = /已上传|上传了|附上|附件/.test(text)
const receipt = /发票/.test(text)
const itinerary = /行程单/.test(text)
const ticket = /车票|机票/.test(text)
const hotelList = /入住清单/.test(text)
const files = []
if (receipt) files.push('发票.jpg')
if (itinerary) files.push('行程单.pdf')
if (ticket && !files.includes('票据.pdf')) files.push('票据.pdf')
if (hotelList) files.push('入住清单.pdf')
if (uploaded || files.length) {
return {
status: `${Math.max(files.length, 1)} 份附件`,
hint: files.length ? files.join(' + ') : '已上传附件待识别',
tone: 'ok',
files: files.length ? files : ['附件1.jpg'],
}
}
return {
status: '缺 1 份',
hint: '待补上传票据原件',
tone: 'missing',
files: [],
}
}
function inferRisk(text, attachmentTone) {
if (/夜间|超标|说明/.test(text)) {
return {
status: '需说明',
tone: 'bad',
riskLabel: '超标说明',
riskText: '识别到特殊场景,建议补充费用说明',
riskTone: 'medium',
}
}
if (attachmentTone === 'missing' || attachmentTone === 'partial') {
return {
status: '待补材料',
tone: 'bad',
riskLabel: '待补材料',
riskText: '附件不完整,需补齐后再提交审批',
riskTone: 'medium',
}
}
return {
status: '规则通过',
tone: 'ok',
riskLabel: '规则通过',
riskText: 'AI 识别通过,字段已结构化',
riskTone: 'low',
}
}
function extractDateLabel(text) {
const match = text.match(/(\d{1,2})月(\d{1,2})日|(\d{1,2})[-/.](\d{1,2})/)
if (!match) {
return { time: '07-12', dayLabel: `${expenseItems.value.length + 1}` }
}
const month = String(match[1] || match[3] || '07').padStart(2, '0')
const day = String(match[2] || match[4] || '12').padStart(2, '0')
return { time: `${month}-${day}`, dayLabel: `${expenseItems.value.length + 1}` }
}
function extractAmount(text) {
const match = text.match(/(\d+(?:\.\d{1,2})?)\s*元/)
return formatCurrency(Number.parseFloat(match?.[1] || '0'))
}
function buildAiExpense(text) {
const category = inferExpenseCategory(text)
const name = inferExpenseName(text, category)
const dateInfo = extractDateLabel(text)
const attachments = inferAttachments(text, uploadedAiFiles.value)
const risk = inferRisk(text, attachments.tone)
return {
id: buildNextExpenseId(),
time: dateInfo.time,
dayLabel: dateInfo.dayLabel,
name,
category,
desc: text.slice(0, 24),
detail: text,
amount: extractAmount(text),
status: risk.status,
tone: risk.tone,
attachmentStatus: attachments.status,
attachmentHint: attachments.hint,
attachmentTone: attachments.tone,
attachments: attachments.files,
riskLabel: risk.riskLabel,
riskText: risk.riskText,
riskTone: risk.riskTone,
}
}
const aiMessages = ref([
{
id: 'ai-msg-1',
role: 'assistant',
text: '请直接描述费用场景、日期、金额和是否已上传票据,我会整理成费用明细。',
},
])
function sendAiEntry() {
const text = aiDraft.value.trim() || `已上传 ${uploadedAiFiles.value.length} 份单据,请根据附件识别费用。`
if (!text && !uploadedAiFiles.value.length) return
aiMessages.value.push({
id: `ai-msg-user-${Date.now()}`,
role: 'user',
text: uploadedAiFiles.value.length ? `${text}\n附件:${uploadedAiFiles.value.map((file) => file.name).join('、')}` : text,
})
pendingAiExpense.value = buildAiExpense(text)
aiMessages.value.push({
id: `ai-msg-assistant-${Date.now()}`,
role: 'assistant',
text: `已识别为 ${pendingAiExpense.value.name},金额 ${pendingAiExpense.value.amount},可直接加入费用明细。`,
})
aiDraft.value = ''
}
function regenerateAiEntry() {
if (!pendingAiExpense.value) return
const sourceText = pendingAiExpense.value.detail
pendingAiExpense.value = buildAiExpense(sourceText.replace('待补上传票据原件', '已上传发票'))
aiMessages.value.push({
id: `ai-msg-regenerate-${Date.now()}`,
role: 'assistant',
text: '已重新整理识别结果,你可以继续确认后加入费用明细。',
})
}
function applyAiExpense() {
if (!pendingAiExpense.value) return
expenseItems.value.push({ ...pendingAiExpense.value })
expandedExpenseId.value = pendingAiExpense.value.id
aiMessages.value.push({
id: `ai-msg-apply-${Date.now()}`,
role: 'assistant',
text: '该费用条目已加入下方费用明细表。',
})
pendingAiExpense.value = null
aiDraft.value = ''
uploadedAiFiles.value = []
if (aiFileInput.value) {
aiFileInput.value.value = ''
}
aiEntryOpen.value = false
}
function triggerAiUpload() {
aiFileInput.value?.click()
}
function handleAiFilesChange(event) {
const files = Array.from(event.target.files ?? [])
uploadedAiFiles.value = files
}
return {
emit,
expandedExpenseId,
aiEntryOpen,
aiDraft,
aiFileInput,
aiEntrySeed,
pendingAiExpense,
uploadedAiFiles,
expenseItems,
request,
profile,
summaryItems,
heroSummaryItems,
actionBusy,
canSubmit,
closeDeleteDialog,
confirmDeleteDraft,
currentProgressRingMotion,
progressSteps,
expenseTotal,
uploadedExpenseCount,
canSendAiEntry,
deleteBusy,
deleteDialogOpen,
detailNote,
toggleExpenseAttachments,
showExpenseRisk,
draftBlockingIssues,
editingExpenseId,
expenseEditor,
expenseItems,
expenseSummaryText,
expenseTotal,
expandedExpenseId,
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
handleDeleteDraft,
handleSubmit,
heroStats,
heroSummaryItems,
isDraftRequest,
isTravelRequest,
openAiEntry,
closeAiEntry,
aiMessages,
sendAiEntry,
regenerateAiEntry,
applyAiExpense,
triggerAiUpload,
handleAiFilesChange
profile,
progressSteps,
request,
resolveExpenseIssues,
savingExpenseId,
showExpenseRisk,
startExpenseEdit,
submitBusy,
toggleExpenseAttachments,
uploadedExpenseCount,
validationSummary,
validationTone,
cancelExpenseEdit,
saveExpenseEdit
}
}
}