Files
X-Financial/web/src/views/scripts/useTravelReimbursementComposerTools.js
caoxiaozhu d0e946cf47 feat: 完善文档中心与报销申请交互及侧边栏重构
后端优化编排器报销查询和本体检测精度,增强报销单草稿保
存和附件回填逻辑,前端重构侧边栏组件支持折叠和图标导
航,完善文档中心状态筛选和详情提示,报销创建和审批详情
页优化会话管理和费用明细交互,新增助手应用服务和预设动
作工具函数,补充单元测试覆盖。
2026-05-25 13:35:39 +08:00

572 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { computed, nextTick, ref } from 'vue'
const COMMON_DESTINATION_PREFIXES = [
'上海',
'北京',
'广州',
'深圳',
'杭州',
'南京',
'苏州',
'成都',
'重庆',
'武汉',
'西安',
'天津',
'宁波',
'青岛',
'长沙',
'郑州',
'济南',
'合肥',
'福州',
'厦门',
'昆明',
'南昌',
'沈阳',
'大连',
'无锡',
'佛山',
'东莞'
]
const CHINESE_DAY_NUMBERS = {
: 1,
: 2,
: 2,
: 3,
: 4,
: 5,
: 6,
: 7,
: 8,
: 9,
: 10
}
function normalizeComposerText(value) {
return String(value || '').trim().replace(/\s+/g, ' ')
}
function parseDayCount(value) {
const text = String(value || '').trim()
const numericValue = Number.parseInt(text, 10)
if (Number.isFinite(numericValue) && numericValue > 0) {
return numericValue
}
if (text === '十') {
return 10
}
if (/^十[一二三四五六七八九]$/.test(text)) {
return 10 + (CHINESE_DAY_NUMBERS[text.slice(1)] || 0)
}
if (/^[一二两三四五六七八九]十$/.test(text)) {
return (CHINESE_DAY_NUMBERS[text.slice(0, 1)] || 1) * 10
}
if (/^[一二两三四五六七八九]十[一二三四五六七八九]$/.test(text)) {
return (CHINESE_DAY_NUMBERS[text.slice(0, 1)] || 1) * 10 + (CHINESE_DAY_NUMBERS[text.slice(2)] || 0)
}
return CHINESE_DAY_NUMBERS[text] || 0
}
function calculateBusinessDays(businessTimeContext) {
const startDate = String(businessTimeContext?.start_date || '').trim()
const endDate = String(businessTimeContext?.end_date || startDate).trim()
if (!startDate || !endDate || startDate > endDate) {
return 0
}
const startAt = Date.parse(`${startDate}T00:00:00Z`)
const endAt = Date.parse(`${endDate}T00:00:00Z`)
if (!Number.isFinite(startAt) || !Number.isFinite(endAt)) {
return 0
}
return Math.max(1, Math.round((endAt - startAt) / 86400000) + 1)
}
function stripBusinessTimePrefix(text) {
return normalizeComposerText(text)
.replace(/^(?:业务)?发生时间[:]\s*[^,。\n]+(?:至\s*[^,。\n]+)?[,。\s]*/u, '')
.trim()
}
function resolveDestinationFromText(text) {
const normalized = normalizeComposerText(text).replace(/\s+/g, '')
const targetMatch = normalized.match(/(?:出差|去|到|赴|前往)([^,。;;]+)/u)
const targetText = String(targetMatch?.[1] || '').trim()
if (!targetText) {
return ''
}
const knownDestination = COMMON_DESTINATION_PREFIXES.find((item) => targetText.startsWith(item))
if (knownDestination) {
return knownDestination
}
const verbIndex = targetText.search(/支撑|支持|部署|实施|驻场|出差|拜访|处理|办理|参加|进行|协助|服务器|项目/u)
if (verbIndex > 0) {
return targetText.slice(0, verbIndex)
}
return targetText.slice(0, 12)
}
function resolveTripDaysFromText(text, businessTimeContext) {
const dayMatch = normalizeComposerText(text).match(/(?:出差|共|总计)?\s*([0-9]+|[一二两三四五六七八九十]{1,3})\s*天/u)
const explicitDays = parseDayCount(dayMatch?.[1])
return explicitDays || calculateBusinessDays(businessTimeContext)
}
function resolveReasonFromText(text, destination) {
let reason = normalizeComposerText(text)
.replace(/^(?:出差|去|到|赴|前往)\s*/u, '')
.trim()
if (destination && reason.startsWith(destination)) {
reason = reason.slice(destination.length).trim()
}
return reason
.replace(/[,。\s]*(?:出差|共|总计)?\s*(?:[0-9]+|[一二两三四五六七八九十]{1,3})\s*天/u, '')
.replace(/[,。\s]*(?:申请|发起|办理)?(?:差旅费|差旅|费用)?报销(?:申请)?[。.!]?$/u, '')
.replace(/^[,。;;\s]+|[,。;;\s]+$/gu, '')
.trim()
}
export function buildStructuredComposerSubmitText(rawText, businessTimeContext = null) {
const normalizedText = normalizeComposerText(rawText)
const timeDisplay = String(
businessTimeContext?.business_time ||
businessTimeContext?.time_range ||
businessTimeContext?.display_value ||
''
).trim()
if (!timeDisplay || !normalizedText) {
return normalizedText
}
const bodyText = stripBusinessTimePrefix(normalizedText)
if (!bodyText) {
return `发生时间:${timeDisplay}`
}
const destination = resolveDestinationFromText(bodyText)
const reason = resolveReasonFromText(bodyText, destination)
const days = resolveTripDaysFromText(bodyText, businessTimeContext)
const lines = [`发生时间:${timeDisplay}`]
if (destination) {
lines.push(`地点:${destination}`)
}
if (reason) {
lines.push(`事由:${reason}`)
}
if (days > 0 && (days > 1 || /出差|差旅|至/.test(timeDisplay) || /出差|差旅/.test(bodyText))) {
lines.push(`天数:${days}`)
}
return lines.join('\n')
}
export function useTravelReimbursementComposerTools({
currentUser,
activeReviewPayload,
reviewInlineForm,
latestReviewMessage,
currentInsight,
messages,
composerDraft,
composerTextareaRef,
adjustComposerTextareaHeight,
scrollToBottom,
toast,
calculateTravelReimbursement,
createMessage,
buildReviewSlotMap,
isValidIsoDateString,
buildLocallySyncedReviewPayload,
formatDateInputValue
}) {
const composerDatePickerOpen = ref(false)
const composerDateMode = ref('single')
const composerSingleDate = ref(formatDateInputValue())
const composerRangeStartDate = ref(formatDateInputValue())
const composerRangeEndDate = ref(formatDateInputValue())
const composerBusinessTimeTags = ref([])
const composerBusinessTimeDraftTouched = ref(false)
const travelCalculatorOpen = ref(false)
const travelCalculatorBusy = ref(false)
const travelCalculatorError = ref('')
const travelCalculatorResult = ref(null)
const travelCalculatorForm = ref({
days: '1',
location: ''
})
const composerCanApplyDateSelection = computed(() => {
if (composerDateMode.value === 'single') {
return Boolean(composerSingleDate.value)
}
return Boolean(
composerRangeStartDate.value
&& composerRangeEndDate.value
&& composerRangeStartDate.value <= composerRangeEndDate.value
)
})
const travelCalculatorCanSubmit = computed(() =>
!travelCalculatorBusy.value
&& Number(travelCalculatorForm.value.days) >= 1
&& Boolean(String(travelCalculatorForm.value.location || '').trim())
)
function buildComposerBusinessTimeLabel() {
if (composerDateMode.value === 'single') {
return `发生时间:${composerSingleDate.value}`
}
if (composerRangeStartDate.value === composerRangeEndDate.value) {
return `发生时间:${composerRangeStartDate.value}`
}
return `发生时间:${composerRangeStartDate.value}${composerRangeEndDate.value}`
}
function hasComposerBusinessTimeSelection() {
return composerBusinessTimeTags.value.length > 0 || composerBusinessTimeDraftTouched.value
}
function buildComposerBusinessTimeContext() {
if (!hasComposerBusinessTimeSelection()) {
return null
}
const mode = composerDateMode.value === 'range' ? 'range' : 'single'
const startDate = String(mode === 'range' ? composerRangeStartDate.value : composerSingleDate.value).trim()
const endDate = String(mode === 'range' ? composerRangeEndDate.value : startDate).trim()
if (!isValidIsoDateString(startDate) || !isValidIsoDateString(endDate) || startDate > endDate) {
return null
}
const displayValue = mode === 'range' && startDate !== endDate
? `${startDate}${endDate}`
: startDate
return {
mode,
start_date: startDate,
end_date: endDate,
occurred_date: startDate,
time_range: displayValue,
business_time: displayValue,
time_range_raw: buildComposerBusinessTimeLabel()
}
}
function mergeBusinessTimeIntoExtraContext(extraContext, businessTimeContext) {
if (!businessTimeContext) {
return extraContext
}
const baseReviewFormValues =
extraContext.review_form_values && typeof extraContext.review_form_values === 'object'
? extraContext.review_form_values
: {}
return {
...extraContext,
occurred_date: businessTimeContext.occurred_date,
business_time: businessTimeContext.business_time,
business_time_context: {
mode: businessTimeContext.mode,
start_date: businessTimeContext.start_date,
end_date: businessTimeContext.end_date,
display_value: businessTimeContext.business_time
},
review_form_values: {
...baseReviewFormValues,
occurred_date: businessTimeContext.occurred_date,
time_range: businessTimeContext.time_range,
business_time: businessTimeContext.business_time,
time_range_raw: businessTimeContext.time_range_raw
}
}
}
function syncComposerBusinessTimeToReviewCard(businessTimeContext) {
if (!businessTimeContext || !activeReviewPayload.value) {
return
}
const nextInlineState = {
...reviewInlineForm.value,
occurred_date: businessTimeContext.occurred_date
}
const nextReviewPayload = buildLocallySyncedReviewPayload(activeReviewPayload.value, nextInlineState)
reviewInlineForm.value = nextInlineState
if (latestReviewMessage.value) {
latestReviewMessage.value.reviewPayload = nextReviewPayload
}
if (currentInsight.value?.agent) {
currentInsight.value = {
...currentInsight.value,
agent: {
...currentInsight.value.agent,
reviewPayload: nextReviewPayload
}
}
}
}
function resolveComposerSubmitText(explicitRawText) {
const draftPart = String(explicitRawText ?? composerDraft.value).trim()
const tagPart = composerBusinessTimeTags.value.map((item) => item.label).join('')
if (!tagPart) {
return draftPart
}
if (!draftPart) {
return tagPart
}
return `${tagPart}${draftPart}`
}
function resolveComposerDisplaySubmitText(rawText) {
const businessTimeContext = buildComposerBusinessTimeContext()
if (!businessTimeContext) {
return String(rawText || '').trim()
}
return buildStructuredComposerSubmitText(rawText, businessTimeContext)
}
function toggleComposerDatePicker() {
composerDatePickerOpen.value = !composerDatePickerOpen.value
if (composerDatePickerOpen.value) {
travelCalculatorOpen.value = false
}
}
function closeComposerDatePicker() {
composerDatePickerOpen.value = false
}
function setComposerDateMode(mode) {
composerDateMode.value = mode === 'range' ? 'range' : 'single'
}
function handleComposerDateInputChange() {
composerBusinessTimeDraftTouched.value = true
syncComposerBusinessTimeToReviewCard(buildComposerBusinessTimeContext())
}
function removeComposerBusinessTimeTag(tagId) {
composerBusinessTimeTags.value = composerBusinessTimeTags.value.filter((item) => item.id !== tagId)
if (!composerBusinessTimeTags.value.length) {
composerBusinessTimeDraftTouched.value = false
}
}
function handleComposerDatePickerOutside(event) {
if (!composerDatePickerOpen.value && !travelCalculatorOpen.value) {
return
}
if (event.target instanceof Element && event.target.closest('.composer-date-anchor')) {
return
}
if (event.target instanceof Element && event.target.closest('.travel-calculator-anchor')) {
return
}
if (composerDatePickerOpen.value) {
composerDatePickerOpen.value = false
}
if (travelCalculatorOpen.value && !travelCalculatorBusy.value) {
travelCalculatorOpen.value = false
}
}
async function applyComposerDateSelection() {
if (!composerCanApplyDateSelection.value) {
return
}
composerBusinessTimeDraftTouched.value = true
composerBusinessTimeTags.value = [
{
id: `biz-time-${Date.now()}`,
label: buildComposerBusinessTimeLabel()
}
]
syncComposerBusinessTimeToReviewCard(buildComposerBusinessTimeContext())
composerDatePickerOpen.value = false
await nextTick()
adjustComposerTextareaHeight()
composerTextareaRef.value?.focus()
}
function resolveTravelCalculatorInitialDays() {
const businessTimeContext = buildComposerBusinessTimeContext()
if (!businessTimeContext) {
return 1
}
const startDate = businessTimeContext.start_date
const endDate = businessTimeContext.end_date || startDate
if (!isValidIsoDateString(startDate) || !isValidIsoDateString(endDate) || startDate > endDate) {
return 1
}
const startAt = Date.parse(`${startDate}T00:00:00Z`)
const endAt = Date.parse(`${endDate}T00:00:00Z`)
if (!Number.isFinite(startAt) || !Number.isFinite(endAt)) {
return 1
}
return Math.max(1, Math.round((endAt - startAt) / 86400000) + 1)
}
function resolveTravelCalculatorInitialLocation() {
const slotMap = buildReviewSlotMap(activeReviewPayload.value)
const candidates = [
reviewInlineForm.value.location,
slotMap.business_location?.normalized_value,
slotMap.business_location?.value,
slotMap.location?.normalized_value,
slotMap.location?.value,
currentUser.value?.location
]
return String(candidates.find((item) => String(item || '').trim()) || '').trim()
}
function openTravelCalculator() {
closeComposerDatePicker()
travelCalculatorError.value = ''
travelCalculatorResult.value = null
travelCalculatorForm.value = {
days: String(resolveTravelCalculatorInitialDays()),
location: resolveTravelCalculatorInitialLocation()
}
travelCalculatorOpen.value = true
}
function toggleTravelCalculator() {
if (travelCalculatorOpen.value) {
closeTravelCalculator()
return
}
openTravelCalculator()
}
function closeTravelCalculator() {
if (travelCalculatorBusy.value) {
return
}
travelCalculatorOpen.value = false
}
function formatTravelCalculatorMoney(value) {
const amount = Number(value)
if (!Number.isFinite(amount)) {
return String(value || '0')
}
return amount.toFixed(2)
}
function buildTravelCalculatorResultText(result) {
const days = Number(result?.days) || 1
const location = String(result?.location || '').trim() || '未填写地点'
const matchedCity = String(result?.matched_city || location).trim()
const grade = String(result?.grade || '').trim() || '当前职级'
const gradeBandLabel = String(result?.grade_band_label || result?.grade_band || '').trim() || '对应档位'
const allowanceRegion = String(result?.allowance_region || '').trim() || '默认区域'
const ruleName = String(result?.rule_name || '').trim() || '公司差旅费报销规则'
const ruleVersion = String(result?.rule_version || '').trim()
const hotelRate = formatTravelCalculatorMoney(result?.hotel_rate)
const hotelAmount = formatTravelCalculatorMoney(result?.hotel_amount)
const mealRate = formatTravelCalculatorMoney(result?.meal_allowance_rate)
const basicRate = formatTravelCalculatorMoney(result?.basic_allowance_rate)
const allowanceRate = formatTravelCalculatorMoney(result?.total_allowance_rate)
const allowanceAmount = formatTravelCalculatorMoney(result?.allowance_amount)
const totalAmount = formatTravelCalculatorMoney(result?.total_amount)
const ruleVersionText = ruleVersion ? `${ruleVersion}` : ''
const user = currentUser.value || {}
const displayName = String(user.name || user.display_name || user.username || '').trim()
const greeting = displayName ? `您好,${displayName}` : '您好,'
return [
`${greeting}根据您输入的地点和天数,我匹配到您要出差的地区为:**${matchedCity}**,出差天数为:**${days} 天**,我根据公司的报销文件给您预估金额如下:`,
'',
`**参考可报销合计:${totalAmount} 元**`,
'',
'| 项目 | 标准口径 | 天数 | 小计 |',
'| --- | --- | ---: | ---: |',
`| 住宿费 | ${matchedCity} / ${grade}${gradeBandLabel})标准:${hotelRate} 元/天 | ${days} | ${hotelAmount} 元 |`,
`| 出差补贴 | ${allowanceRegion}:伙食 ${mealRate} 元 + 基本 ${basicRate} 元 = ${allowanceRate} 元/天 | ${days} | ${allowanceAmount} 元 |`,
'',
'**计算过程**',
`1. 住宿费:${hotelRate} × ${days} = ${hotelAmount}`,
`2. 出差补贴:(${mealRate} + ${basicRate}) × ${days} = ${allowanceRate} × ${days} = ${allowanceAmount}`,
`3. 合计:${hotelAmount} + ${allowanceAmount} = ${totalAmount}`,
'',
`**规则依据**${ruleName}${ruleVersionText}。出差地点“${location}”匹配为“${matchedCity}”,当前职级“${grade}”匹配“${gradeBandLabel}”档。`,
'',
'这个结果是提交前的规则测算参考,最终仍以实际票据、审批意见和财务复核口径为准。'
].join('\n')
}
async function submitTravelCalculator() {
if (!travelCalculatorCanSubmit.value) {
travelCalculatorError.value = '请填写出差天数和地点后再计算。'
return
}
travelCalculatorBusy.value = true
travelCalculatorError.value = ''
try {
const user = currentUser.value || {}
const payload = await calculateTravelReimbursement({
days: Math.max(1, Number.parseInt(String(travelCalculatorForm.value.days || '1'), 10) || 1),
location: String(travelCalculatorForm.value.location || '').trim(),
grade: String(user.grade || '').trim()
})
travelCalculatorResult.value = payload
messages.value.push(createMessage('assistant', buildTravelCalculatorResultText(payload), [], {
meta: ['差旅计算器'],
metaTone: 'low'
}))
travelCalculatorOpen.value = false
nextTick(scrollToBottom)
} catch (error) {
travelCalculatorError.value = error?.message || '差旅金额测算失败,请稍后重试。'
} finally {
travelCalculatorBusy.value = false
}
}
return {
composerDatePickerOpen,
composerDateMode,
composerSingleDate,
composerRangeStartDate,
composerRangeEndDate,
composerBusinessTimeTags,
composerBusinessTimeDraftTouched,
composerCanApplyDateSelection,
travelCalculatorOpen,
travelCalculatorBusy,
travelCalculatorError,
travelCalculatorResult,
travelCalculatorForm,
travelCalculatorCanSubmit,
buildComposerBusinessTimeLabel,
hasComposerBusinessTimeSelection,
buildComposerBusinessTimeContext,
mergeBusinessTimeIntoExtraContext,
syncComposerBusinessTimeToReviewCard,
resolveComposerSubmitText,
resolveComposerDisplaySubmitText,
toggleComposerDatePicker,
closeComposerDatePicker,
setComposerDateMode,
handleComposerDateInputChange,
removeComposerBusinessTimeTag,
handleComposerDatePickerOutside,
applyComposerDateSelection,
resolveTravelCalculatorInitialDays,
resolveTravelCalculatorInitialLocation,
openTravelCalculator,
toggleTravelCalculator,
closeTravelCalculator,
formatTravelCalculatorMoney,
buildTravelCalculatorResultText,
submitTravelCalculator
}
}