Files
X-Financial/web/src/views/scripts/useApplicationPreviewEditor.js
caoxiaozhu ded8b39ccb feat(web): 申请单预览编辑器增强与报销流程细节适配
- useApplicationPreviewEditor 扩展字段编辑与校验,useTravelReimbursementApplicationPreviewDateEditor 微调日期处理
- travelReimbursementExpenseQueryModel/reimbursements 服务/expenseApplicationPreview 适配工号/邮箱字段与关联动作
- useWorkbenchAiApplicationPreviewFlow/usePersonalWorkbenchAiMode 接入关联门控后的预览流转
- TravelReimbursementCreateView 调整入口,TravelReimbursementMessageItem 适配
- 新增 expense-application-fast-preview 测试,更新 attachment-association-confirmation、review-drawer-switch 测试
2026-06-22 15:56:06 +08:00

418 lines
14 KiB
JavaScript

import { ref } from 'vue'
import {
APPLICATION_TRANSPORT_MODE_OPTIONS,
applyApplicationPolicyEstimateError,
applyApplicationPolicyEstimateResult,
buildApplicationPreviewRows,
buildApplicationPolicyEstimateRequest,
buildLocalApplicationPreviewMessage,
normalizeApplicationPreview,
resolveApplicationDaysFromDateRange,
refreshApplicationPreviewTransportEstimate
} from '../../utils/expenseApplicationPreview.js'
import {
buildWorkbenchDateLabel,
canApplyWorkbenchDateSelection,
getTodayDateValue
} from '../../utils/workbenchComposerDate.js'
function parseEditorDateMatches(value) {
const text = String(value || '').trim()
return [...text.matchAll(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/g)]
.map((item) => normalizeEditorIsoDate(item[0]))
.filter(Boolean)
}
function parseEditorDateValue(value) {
const matches = parseEditorDateMatches(value)
const startDate = matches[0] || getTodayDateValue()
const endDate = matches[1] || startDate
return {
dateMode: matches.length > 1 && startDate !== endDate ? 'range' : 'single',
singleDate: startDate,
rangeStartDate: startDate,
rangeEndDate: endDate
}
}
function isApplicationPreviewDateField(fieldKey = '') {
return ['time', 'time_return'].includes(String(fieldKey || '').trim())
}
function formatApplicationPreviewDateRange(startDate = '', endDate = '') {
const start = String(startDate || '').trim()
const end = String(endDate || '').trim()
if (!start && !end) return ''
if (!start) return end
if (!end || end === start) return start
return `${start}${end}`
}
function normalizeEditorIsoDate(value = '') {
const match = String(value || '').trim().match(/^(20\d{2})[-/.](\d{1,2})[-/.](\d{1,2})$/)
if (!match) return ''
return buildEditorIsoDate(Number(match[1]), Number(match[2]), Number(match[3]))
}
function buildEditorIsoDate(year, month, day) {
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) return ''
const date = new Date(Date.UTC(year, month - 1, day))
if (
Number.isNaN(date.getTime()) ||
date.getUTCFullYear() !== year ||
date.getUTCMonth() + 1 !== month ||
date.getUTCDate() !== day
) {
return ''
}
return [
String(year).padStart(4, '0'),
String(month).padStart(2, '0'),
String(day).padStart(2, '0')
].join('-')
}
function parseEditorDateParts(value = '') {
const normalized = normalizeEditorIsoDate(value)
const match = normalized.match(/^(20\d{2})-(\d{2})-(\d{2})$/)
if (!match) return null
return {
year: Number(match[1]),
month: Number(match[2]),
day: Number(match[3]),
value: normalized
}
}
function addDaysToEditorDate(value = '', offset = 0) {
const parts = parseEditorDateParts(value)
if (!parts) return ''
const date = new Date(Date.UTC(parts.year, parts.month - 1, parts.day))
date.setUTCDate(date.getUTCDate() + offset)
return buildEditorIsoDate(date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate())
}
function buildEditorDateFromMonthDay(referenceDate = '', month = 0, day = 0) {
const reference = parseEditorDateParts(referenceDate) || parseEditorDateParts(getTodayDateValue())
if (!reference) return ''
const year = month < reference.month ? reference.year + 1 : reference.year
return buildEditorIsoDate(year, month, day)
}
function buildEditorDateFromDay(referenceDate = '', day = 0) {
const reference = parseEditorDateParts(referenceDate) || parseEditorDateParts(getTodayDateValue())
if (!reference) return ''
let year = reference.year
let month = reference.month
let candidate = buildEditorIsoDate(year, month, day)
if (candidate && candidate < reference.value) {
month += 1
if (month > 12) {
month = 1
year += 1
}
candidate = buildEditorIsoDate(year, month, day)
}
return candidate
}
function normalizeEditorDateInput(value = '', referenceDate = '') {
const text = String(value || '').trim()
if (!text) return ''
const fullDate = text.match(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/)
if (fullDate) {
return normalizeEditorIsoDate(fullDate[0])
}
const monthDay = text.match(/(\d{1,2})\s*月\s*(\d{1,2})\s*日?/) || text.match(/(\d{1,2})[/-](\d{1,2})/)
if (monthDay) {
return buildEditorDateFromMonthDay(referenceDate, Number(monthDay[1]), Number(monthDay[2]))
}
const dayOnly = text.match(/^(\d{1,2})\s*日?$/)
if (dayOnly) {
return buildEditorDateFromDay(referenceDate, Number(dayOnly[1]))
}
if (/大后天/.test(text)) return addDaysToEditorDate(referenceDate || getTodayDateValue(), 3)
if (/后天/.test(text)) return addDaysToEditorDate(referenceDate || getTodayDateValue(), 2)
if (/明天/.test(text)) return addDaysToEditorDate(referenceDate || getTodayDateValue(), 1)
if (/今天/.test(text)) return normalizeEditorIsoDate(referenceDate || getTodayDateValue())
return ''
}
function buildEmptyEditor() {
return {
messageId: '',
fieldKey: '',
draftValue: '',
dateMode: 'single',
singleDate: getTodayDateValue(),
rangeStartDate: getTodayDateValue(),
rangeEndDate: getTodayDateValue(),
committing: false
}
}
function shouldRefreshTransportEstimate(fieldKey) {
return ['transportMode', 'time', 'time_return', 'location', 'days'].includes(fieldKey)
}
function resolveEditorCurrentUser(currentUser) {
if (currentUser && typeof currentUser === 'object' && 'value' in currentUser) {
return currentUser.value || {}
}
return currentUser || {}
}
function buildEditedApplicationPreviewFields(fields = {}, editor = {}, nextValue = '') {
const isDateField = isApplicationPreviewDateField(editor.fieldKey)
const nextFields = {
...fields,
[isDateField ? 'time' : editor.fieldKey]: nextValue
}
if (isDateField) {
const resolvedDays = resolveApplicationDaysFromDateRange(nextValue)
if (resolvedDays) {
nextFields.days = resolvedDays
}
}
return nextFields
}
function buildApplicationPreviewDateSelectionValue(editor = {}) {
return buildWorkbenchDateLabel({
mode: editor.dateMode,
singleDate: editor.singleDate,
rangeStartDate: editor.rangeStartDate,
rangeEndDate: editor.rangeEndDate
})
}
function buildApplicationPreviewTextDateValue(fields = {}, editor = {}) {
const currentDateState = parseEditorDateValue(fields.time)
const referenceDate = currentDateState.rangeStartDate || currentDateState.singleDate || getTodayDateValue()
const typedDates = parseEditorDateMatches(editor.draftValue)
if (typedDates.length >= 2) {
return formatApplicationPreviewDateRange(typedDates[0], typedDates[typedDates.length - 1])
}
const typedDate = typedDates[0] || normalizeEditorDateInput(editor.draftValue, referenceDate) || String(editor.draftValue || '').trim()
if (!typedDate) {
return ''
}
if (editor.fieldKey === 'time_return') {
return formatApplicationPreviewDateRange(currentDateState.rangeStartDate, typedDate)
}
const currentEndDate = currentDateState.rangeEndDate || typedDate
return formatApplicationPreviewDateRange(typedDate, currentEndDate)
}
function buildApplicationPreviewEditorValue(fields = {}, editor = {}, options = {}) {
if (!isApplicationPreviewDateField(editor.fieldKey)) {
return String(editor.draftValue || '').trim()
}
return options.useDateSelection
? buildApplicationPreviewDateSelectionValue(editor)
: buildApplicationPreviewTextDateValue(fields, editor)
}
function buildTransportEstimatePendingPreview(preview = {}) {
const fields = preview?.fields || {}
return normalizeApplicationPreview({
...preview,
fields: {
...fields,
transportPolicy: '正在预估交通费用...',
policyEstimate: '正在同步费用测算...',
transportEstimatedAmount: '查询中'
}
})
}
export function useApplicationPreviewEditor({
persistSessionState,
toast,
calculateTravelReimbursement,
currentUser
} = {}) {
const applicationPreviewEditor = ref(buildEmptyEditor())
async function refreshApplicationPreviewEstimate(preview = {}) {
const user = resolveEditorCurrentUser(currentUser)
const estimateRequest = buildApplicationPolicyEstimateRequest(preview, user)
if (estimateRequest.canCalculate && typeof calculateTravelReimbursement === 'function') {
try {
const result = await calculateTravelReimbursement(estimateRequest.payload)
return applyApplicationPolicyEstimateResult(preview, result, user)
} catch (error) {
console.warn('Application preview estimate refresh failed:', error)
return applyApplicationPolicyEstimateError(preview, error, user)
}
}
return refreshApplicationPreviewTransportEstimate(preview)
}
function resolveApplicationPreviewRows(message) {
return buildApplicationPreviewRows(message?.applicationPreview || {})
}
function resolveApplicationPreviewEditorControl(fieldKey) {
if (fieldKey === 'transportMode') return 'select'
if (isApplicationPreviewDateField(fieldKey)) return 'date'
return 'text'
}
function resolveApplicationPreviewEditorOptions(fieldKey) {
return fieldKey === 'transportMode' ? APPLICATION_TRANSPORT_MODE_OPTIONS : []
}
function isApplicationPreviewEditing(message, fieldKey) {
return (
String(applicationPreviewEditor.value.messageId || '') === String(message?.id || '') &&
applicationPreviewEditor.value.fieldKey === fieldKey
)
}
function openApplicationPreviewEditor(message, fieldKey, value) {
if (!message?.applicationPreview || !fieldKey) return
const targetRow = buildApplicationPreviewRows(message.applicationPreview)
.find((row) => row.key === fieldKey)
if (targetRow && targetRow.editable === false) return
const normalizedValue = String(value || '').trim() === '待补充' ? '' : String(value || '')
const fields = message.applicationPreview.fields || {}
const dateState = isApplicationPreviewDateField(fieldKey)
? parseEditorDateValue(fields.time || normalizedValue)
: {}
applicationPreviewEditor.value = {
messageId: String(message.id || ''),
fieldKey,
draftValue: fieldKey === 'transportMode' && !APPLICATION_TRANSPORT_MODE_OPTIONS.includes(normalizedValue)
? ''
: normalizedValue,
committing: false,
...dateState
}
}
function cancelApplicationPreviewEditor() {
applicationPreviewEditor.value = buildEmptyEditor()
}
function isApplicationPreviewDateEditorOpen(message) {
return (
isApplicationPreviewEditing(message, 'time') ||
isApplicationPreviewEditing(message, 'time_return')
)
}
function setApplicationPreviewDateMode(mode) {
applicationPreviewEditor.value.dateMode = mode === 'range' ? 'range' : 'single'
}
function canApplyApplicationPreviewDateSelection() {
const editor = applicationPreviewEditor.value
return canApplyWorkbenchDateSelection({
mode: editor.dateMode,
singleDate: editor.singleDate,
rangeStartDate: editor.rangeStartDate,
rangeEndDate: editor.rangeEndDate
})
}
async function commitApplicationPreviewEditor(message, options = {}) {
const editor = applicationPreviewEditor.value
if (editor.committing) {
return false
}
if (!message?.applicationPreview || String(editor.messageId || '') !== String(message.id || '') || !editor.fieldKey) {
cancelApplicationPreviewEditor()
return false
}
applicationPreviewEditor.value = {
...editor,
committing: true
}
const nextValue = buildApplicationPreviewEditorValue(
message.applicationPreview.fields || {},
editor,
options
)
if (isApplicationPreviewDateField(editor.fieldKey) && !nextValue) {
toast?.('请先选择有效日期。')
applicationPreviewEditor.value = {
...applicationPreviewEditor.value,
committing: false
}
return false
}
const nextPreview = normalizeApplicationPreview({
...message.applicationPreview,
fields: buildEditedApplicationPreviewFields(
message.applicationPreview.fields || {},
editor,
nextValue
)
})
const needRefreshEstimate = shouldRefreshTransportEstimate(editor.fieldKey)
message.applicationPreview = needRefreshEstimate
? buildTransportEstimatePendingPreview(nextPreview)
: nextPreview
message.text = buildLocalApplicationPreviewMessage(message.applicationPreview)
cancelApplicationPreviewEditor()
persistSessionState?.()
if (needRefreshEstimate) {
const refreshedPreview = await refreshApplicationPreviewEstimate(nextPreview)
message.applicationPreview = refreshedPreview
message.text = buildLocalApplicationPreviewMessage(refreshedPreview)
persistSessionState?.()
toast?.('已更新出行方式和费用测算。')
return true
}
toast?.('已更新核对表内容。')
return true
}
async function commitApplicationPreviewDateEditor(message) {
if (!canApplyApplicationPreviewDateSelection()) {
toast?.('请确认结束日期不早于开始日期。')
return false
}
return commitApplicationPreviewEditor(message, { useDateSelection: true })
}
function handleApplicationPreviewEditorKeydown(event, message) {
if (event.key === 'Enter') {
event.preventDefault()
commitApplicationPreviewEditor(message)
return
}
if (event.key === 'Escape') {
event.preventDefault()
cancelApplicationPreviewEditor()
}
}
return {
applicationPreviewEditor,
resolveApplicationPreviewRows,
resolveApplicationPreviewEditorControl,
resolveApplicationPreviewEditorOptions,
refreshApplicationPreviewEstimate,
isApplicationPreviewEditing,
isApplicationPreviewDateEditorOpen,
openApplicationPreviewEditor,
commitApplicationPreviewEditor,
commitApplicationPreviewDateEditor,
cancelApplicationPreviewEditor,
setApplicationPreviewDateMode,
canApplyApplicationPreviewDateSelection,
handleApplicationPreviewEditorKeydown
}
}