feat(web): 申请单预览编辑器增强与报销流程细节适配

- useApplicationPreviewEditor 扩展字段编辑与校验,useTravelReimbursementApplicationPreviewDateEditor 微调日期处理
- travelReimbursementExpenseQueryModel/reimbursements 服务/expenseApplicationPreview 适配工号/邮箱字段与关联动作
- useWorkbenchAiApplicationPreviewFlow/usePersonalWorkbenchAiMode 接入关联门控后的预览流转
- TravelReimbursementCreateView 调整入口,TravelReimbursementMessageItem 适配
- 新增 expense-application-fast-preview 测试,更新 attachment-association-confirmation、review-drawer-switch 测试
This commit is contained in:
caoxiaozhu
2026-06-22 15:56:06 +08:00
parent ba444a514f
commit ded8b39ccb
12 changed files with 468 additions and 43 deletions

View File

@@ -207,7 +207,7 @@
<template v-else>
<span
class="application-preview-text"
:class="{ 'application-preview-date-chip': row.key === 'time' && !row.missing }"
:class="{ 'application-preview-date-chip': ['time', 'time_return'].includes(row.key) && !row.missing }"
>{{ row.value }}</span>
<button
v-if="row.editable"
@@ -276,7 +276,7 @@
<Transition name="structured-card-reveal" appear>
<div
v-if="message.role === 'assistant' && !message.reviewPayload && !message.queryPayload && message.suggestedActions?.length"
v-if="message.role === 'assistant' && !message.reviewPayload && (!message.queryPayload || message.queryPayload.selectionMode === 'reimbursement_application_association') && message.suggestedActions?.length"
class="message-suggested-actions"
:class="{ 'compact-guidance-actions': message.assistantVariant === 'compact_guidance' }"
>

View File

@@ -210,6 +210,7 @@ export function usePersonalWorkbenchAiMode(props, emit) {
currentUser,
persistCurrentConversation,
pushInlineUserMessage,
replaceInlineMessage,
removeWorkbenchDateTag,
resolveLatestInlineUserPrompt,
scrollInlineConversationToBottom,
@@ -565,6 +566,20 @@ export function usePersonalWorkbenchAiMode(props, emit) {
return files.length ? '请帮我处理已上传的附件。' : ''
}
function isReimbursementCreationIntent(prompt = '') {
const compact = String(prompt || '').replace(/\s+/g, '')
if (!compact || !/报销|报账/.test(compact)) {
return false
}
if (/标准|制度|规则|政策|口径|怎么|如何|能不能|可以吗|查一下|查询|状态|进度|多少钱|多少/.test(compact)) {
return false
}
return (
/^(我要|我想|我需要|帮我|请帮我|需要|发起|新建|创建|提交|办理|走).{0,16}(报销|报账)/.test(compact) ||
/^(报销|报账)(一下|一笔|单|流程)?$/.test(compact)
)
}
function handleAiAnswerMarkdownClick(event) {
const target = event?.target
const link = target?.closest?.('a[href^="#ai-open-document-detail:"], a[href^="#ai-open-application-detail:"]')
@@ -600,6 +615,11 @@ export function usePersonalWorkbenchAiMode(props, emit) {
return
}
if (isReimbursementCreationIntent(cleanPrompt)) {
void expenseFlow.startAiReimbursementAssociationGate(cleanPrompt, entry.label || '我要报销')
return
}
if (conversationId.value === AI_SEARCH_CONVERSATION_ID) {
conversationId.value = ''
conversationMessages.value = []
@@ -636,7 +656,7 @@ export function usePersonalWorkbenchAiMode(props, emit) {
function runAiModeAction(item) {
if (String(item?.label || '').trim() === '发起报销') {
expenseFlow.pushInlineExpenseSceneSelectionPrompt(item.prompt, item.label)
void expenseFlow.startAiReimbursementAssociationGate(item.prompt, item.label)
return
}
startInlineConversation(item.prompt, item, Array.from(selectedFiles.value))

View File

@@ -40,7 +40,7 @@ function isApplicationPreviewEstimatePendingPreview(applicationPreview = {}) {
}
function shouldRefreshInlineApplicationPreviewEstimate(fieldKey = '') {
return ['transportMode', 'time', 'location', 'days'].includes(String(fieldKey || '').trim())
return ['transportMode', 'time', 'time_return', 'location', 'days'].includes(String(fieldKey || '').trim())
}
export function useWorkbenchAiApplicationPreviewFlow({

View File

@@ -72,24 +72,24 @@ async function fetchAllExpenseClaimPages(fetchPage, params = {}) {
return items
}
export function fetchExpenseClaims(params = {}) {
return apiRequest(`/reimbursements/claims${buildListQuery(params)}`)
export function fetchExpenseClaims(params = {}, options = {}) {
return apiRequest(`/reimbursements/claims${buildListQuery(params)}`, options)
}
export function fetchAllExpenseClaims(params = {}) {
return fetchAllExpenseClaimPages(fetchExpenseClaims, params)
}
export function fetchApprovalExpenseClaims(params = {}) {
return apiRequest(`/reimbursements/claims/approvals${buildListQuery(params)}`)
export function fetchApprovalExpenseClaims(params = {}, options = {}) {
return apiRequest(`/reimbursements/claims/approvals${buildListQuery(params)}`, options)
}
export function fetchAllApprovalExpenseClaims(params = {}) {
return fetchAllExpenseClaimPages(fetchApprovalExpenseClaims, params)
}
export function fetchArchivedExpenseClaims(params = {}) {
return apiRequest(`/reimbursements/claims/archives${buildListQuery(params)}`)
export function fetchArchivedExpenseClaims(params = {}, options = {}) {
return apiRequest(`/reimbursements/claims/archives${buildListQuery(params)}`, options)
}
export function fetchAllArchivedExpenseClaims(params = {}) {

View File

@@ -55,7 +55,7 @@ export {
export function buildApplicationPolicyEstimateRequest(preview = {}, currentUser = {}) {
const normalized = normalizeApplicationPreview(preview)
const fields = normalized.fields || {}
const days = parseApplicationDaysValue(fields.days)
const days = parseApplicationDaysValue(fields.days) || parseApplicationDaysValue(resolveDaysFromDateRange(fields.time))
const location = String(fields.location || '').trim()
const grade = String(fields.grade || resolveCurrentUserGrade(currentUser)).trim()
const applicationType = String(fields.applicationType || '').trim()
@@ -112,6 +112,7 @@ export function applyApplicationPolicyEstimateResult(preview = {}, result = {},
fields: {
...fields,
grade,
days: parseApplicationDaysValue(fields.days) ? fields.days : `${days}`,
lodgingDailyCap: formatDailyPolicyMoney(result?.hotel_rate),
subsidyDailyCap: formatDailyPolicyMoney(result?.total_allowance_rate),
transportPolicy: APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT,
@@ -174,6 +175,7 @@ export function applyApplicationPolicyEstimateResult(preview = {}, result = {},
fields: {
...fields,
grade,
days: parseApplicationDaysValue(fields.days) ? fields.days : `${days}`,
lodgingDailyCap: formatDailyPolicyMoney(result?.hotel_rate),
subsidyDailyCap: formatDailyPolicyMoney(result?.total_allowance_rate),
transportPolicy: buildTransportPolicyText(fields.transportMode, matchedCity || fields.location, transportEstimate, fields.time),
@@ -388,7 +390,7 @@ export function buildApplicationPreviewRows(preview = {}) {
key: 'time_return',
label: '返回时间',
value: tripDates.endDate || '待补充',
editable: false,
editable: item.editable !== false,
highlight: Boolean(item.highlight),
missing
}

View File

@@ -401,7 +401,7 @@ export default {
})
const { continueStewardApplicationFieldCompletion, handleSuggestedAction, isSuggestedActionSelected, runShortcut } = useTravelReimbursementSuggestedActions({
applicationPreviewEditor, attachedFiles, buildExpenseSceneSelectionActions, buildExpenseSceneSelectionMessage, commitApplicationPreviewEditor, composerDraft, composerFilesExpanded, composerTextareaRef, createMessage, currentUser, emit, handleGuidedShortcut, handleGuidedSuggestedAction, handleSceneSelectionApplicationGate, lockSuggestedActionMessage, mergeFilesWithLimit, messages, nextTick, openApplicationPreviewEditor: openApplicationPreviewEditorFromUi, persistSessionState, resolveApplicationPreviewMissingFields, reviewActionBusy, router, scrollToBottom, sessionSwitchBusy, startExpenseSceneSelectionAfterIntentConfirmation, submitComposer, submitComposerInternal, submitting, switchSessionType, toast, adjustComposerTextareaHeight
applicationPreviewEditor, attachedFiles, buildExpenseSceneSelectionActions, buildExpenseSceneSelectionMessage, commitApplicationPreviewEditor, composerDraft, composerFilesExpanded, composerTextareaRef, createMessage, currentUser, emit, fetchExpenseClaims, handleGuidedShortcut, handleGuidedSuggestedAction, handleSceneSelectionApplicationGate, lockSuggestedActionMessage, mergeFilesWithLimit, messages, nextTick, openApplicationPreviewEditor: openApplicationPreviewEditorFromUi, persistSessionState, resolveApplicationPreviewMissingFields, reviewActionBusy, router, scrollToBottom, sessionSwitchBusy, startExpenseSceneSelectionAfterIntentConfirmation, submitComposer, submitComposerInternal, submitting, switchSessionType, toast, adjustComposerTextareaHeight
})
const {
canShowTravelCalculator,

View File

@@ -247,6 +247,9 @@ export function buildExpenseQueryWindowLabel(queryPayload) {
if (queryPayload.selectionMode === 'draft_association') {
return '先选择要关联的草稿,确认后我再识别附件并归集到该单据。'
}
if (queryPayload.selectionMode === 'reimbursement_application_association') {
return '先确认是否关联申请单;选择关联后我会用申请单信息生成报销草稿。'
}
if (queryPayload.windowStartDate && queryPayload.windowEndDate) {
return `${queryPayload.windowStartDate}${queryPayload.windowEndDate}`

View File

@@ -17,9 +17,15 @@ import {
getTodayDateValue
} from '../../utils/workbenchComposerDate.js'
function parseEditorDateValue(value) {
function parseEditorDateMatches(value) {
const text = String(value || '').trim()
const matches = [...text.matchAll(/20\d{2}-\d{1,2}-\d{1,2}/g)].map((item) => item[0])
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 {
@@ -30,6 +36,113 @@ function parseEditorDateValue(value) {
}
}
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: '',
@@ -44,7 +157,7 @@ function buildEmptyEditor() {
}
function shouldRefreshTransportEstimate(fieldKey) {
return ['transportMode', 'time', 'location', 'days'].includes(fieldKey)
return ['transportMode', 'time', 'time_return', 'location', 'days'].includes(fieldKey)
}
function resolveEditorCurrentUser(currentUser) {
@@ -55,11 +168,12 @@ function resolveEditorCurrentUser(currentUser) {
}
function buildEditedApplicationPreviewFields(fields = {}, editor = {}, nextValue = '') {
const isDateField = isApplicationPreviewDateField(editor.fieldKey)
const nextFields = {
...fields,
[editor.fieldKey]: nextValue
[isDateField ? 'time' : editor.fieldKey]: nextValue
}
if (editor.fieldKey === 'time') {
if (isDateField) {
const resolvedDays = resolveApplicationDaysFromDateRange(nextValue)
if (resolvedDays) {
nextFields.days = resolvedDays
@@ -68,6 +182,45 @@ function buildEditedApplicationPreviewFields(fields = {}, editor = {}, nextValue
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({
@@ -110,7 +263,7 @@ export function useApplicationPreviewEditor({
function resolveApplicationPreviewEditorControl(fieldKey) {
if (fieldKey === 'transportMode') return 'select'
if (fieldKey === 'time') return 'date'
if (isApplicationPreviewDateField(fieldKey)) return 'date'
return 'text'
}
@@ -131,7 +284,10 @@ export function useApplicationPreviewEditor({
.find((row) => row.key === fieldKey)
if (targetRow && targetRow.editable === false) return
const normalizedValue = String(value || '').trim() === '待补充' ? '' : String(value || '')
const dateState = fieldKey === 'time' ? parseEditorDateValue(normalizedValue) : {}
const fields = message.applicationPreview.fields || {}
const dateState = isApplicationPreviewDateField(fieldKey)
? parseEditorDateValue(fields.time || normalizedValue)
: {}
applicationPreviewEditor.value = {
messageId: String(message.id || ''),
fieldKey,
@@ -148,7 +304,10 @@ export function useApplicationPreviewEditor({
}
function isApplicationPreviewDateEditorOpen(message) {
return isApplicationPreviewEditing(message, 'time')
return (
isApplicationPreviewEditing(message, 'time') ||
isApplicationPreviewEditing(message, 'time_return')
)
}
function setApplicationPreviewDateMode(mode) {
@@ -165,17 +324,7 @@ export function useApplicationPreviewEditor({
})
}
function buildApplicationPreviewDateDraftValue() {
const editor = applicationPreviewEditor.value
return buildWorkbenchDateLabel({
mode: editor.dateMode,
singleDate: editor.singleDate,
rangeStartDate: editor.rangeStartDate,
rangeEndDate: editor.rangeEndDate
})
}
async function commitApplicationPreviewEditor(message) {
async function commitApplicationPreviewEditor(message, options = {}) {
const editor = applicationPreviewEditor.value
if (editor.committing) {
return false
@@ -189,10 +338,12 @@ export function useApplicationPreviewEditor({
committing: true
}
const nextValue = editor.fieldKey === 'time'
? buildApplicationPreviewDateDraftValue()
: String(editor.draftValue || '').trim()
if (editor.fieldKey === 'time' && !nextValue) {
const nextValue = buildApplicationPreviewEditorValue(
message.applicationPreview.fields || {},
editor,
options
)
if (isApplicationPreviewDateField(editor.fieldKey) && !nextValue) {
toast?.('请先选择有效日期。')
applicationPreviewEditor.value = {
...applicationPreviewEditor.value,
@@ -232,7 +383,7 @@ export function useApplicationPreviewEditor({
toast?.('请确认结束日期不早于开始日期。')
return false
}
return commitApplicationPreviewEditor(message)
return commitApplicationPreviewEditor(message, { useDateSelection: true })
}
function handleApplicationPreviewEditorKeydown(event, message) {

View File

@@ -1,5 +1,9 @@
import { watch } from 'vue'
function isApplicationPreviewDateField(fieldKey = '') {
return ['time', 'time_return'].includes(String(fieldKey || '').trim())
}
export function useTravelReimbursementApplicationPreviewDateEditor({
applicationPreviewEditor,
cancelApplicationPreviewEditor,
@@ -17,7 +21,7 @@ export function useTravelReimbursementApplicationPreviewDateEditor({
}) {
function applyLinkedApplicationPreviewDateSelection(selection) {
const editor = applicationPreviewEditor.value
if (editor.fieldKey !== 'time' || !editor.messageId) {
if (!isApplicationPreviewDateField(editor.fieldKey) || !editor.messageId) {
return false
}
@@ -51,13 +55,13 @@ export function useTravelReimbursementApplicationPreviewDateEditor({
function openApplicationPreviewEditorFromUi(message, fieldKey, value) {
openApplicationPreviewEditor(message, fieldKey, value)
if (fieldKey === 'time' && isApplicationPreviewEditing(message, 'time')) {
if (isApplicationPreviewDateField(fieldKey) && isApplicationPreviewEditing(message, fieldKey)) {
syncComposerDateFromApplicationEditor()
}
}
watch(composerDatePickerOpen, (open, previousOpen) => {
if (!open && previousOpen && applicationPreviewEditor.value.fieldKey === 'time') {
if (!open && previousOpen && isApplicationPreviewDateField(applicationPreviewEditor.value.fieldKey)) {
cancelApplicationPreviewEditor()
}
})

View File

@@ -117,8 +117,12 @@ test('attachment upload association uses conversation selection instead of legac
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementFlow.js', import.meta.url)),
'utf8'
)
const flowToolSource = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelReimbursementFlowToolModel.js', import.meta.url)),
'utf8'
)
const conversationSource = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelReimbursementConversationModel.js', import.meta.url)),
fileURLToPath(new URL('../src/views/scripts/travelReimbursementConversationSessionModel.js', import.meta.url)),
'utf8'
)
@@ -140,7 +144,7 @@ test('attachment upload association uses conversation selection instead of legac
assert.match(submitComposerSource, /const appendToCurrentFlow = Boolean\(options\.appendToCurrentFlow\)/)
assert.match(submitComposerSource, /if \(!appendToCurrentFlow\) \{\s*resetFlowRun\(\)\s*\} else \{\s*clearFlowSimulationTimers\(\)/)
assert.match(flowSource, /link_to_existing_draft:\s*\{[\s\S]*key:\s*'attachment-association'/)
assert.match(flowSource, /responseMessage\.includes\('关联'\)[\s\S]*key:\s*'attachment-association'/)
assert.match(flowToolSource, /responseMessage\.includes\('关联'\)[\s\S]*key:\s*'attachment-association'/)
assert.match(flowSource, /'draft-risk-review'/)
assert.match(flowSource, /草稿风险识别/)
assert.match(conversationSource, /'attachment-association':\s*\{[\s\S]*title:\s*'票据关联草稿'/)

View File

@@ -436,6 +436,7 @@ test('application preview uses selected date range and business-specific time la
assert.equal(rows.find((row) => row.key === 'time')?.value, '2026-02-20')
assert.equal(rows.find((row) => row.key === 'time_return')?.label, '返回时间')
assert.equal(rows.find((row) => row.key === 'time_return')?.value, '2026-02-23')
assert.equal(rows.find((row) => row.key === 'time_return')?.editable, true)
assert.match(submitText, /出发时间2026-02-20/)
assert.match(submitText, /返回时间2026-02-23/)
assert.match(submitText, /事由:支撑国网仿生产环境部署/)
@@ -1610,6 +1611,52 @@ test('application preview calculates base policy estimate when transport mode is
assert.equal(staleEstimatePreview.fields.amount, '1,400元不含交通')
})
test('application preview estimate infers days from completed date range', () => {
const currentUser = { name: '\u674e\u6587\u9759', grade: 'P5', location: '\u6b66\u6c49' }
const preview = normalizeApplicationPreview({
fields: {
applicationType: '\u5dee\u65c5\u8d39\u7528\u7533\u8bf7',
time: '2026-06-23 \u81f3 2026-06-25',
location: '\u5317\u4eac',
reason: '\u652f\u6491\u5ba2\u6237\u73b0\u573a\u5b9e\u65bd',
days: '',
transportMode: '',
grade: 'P5'
}
})
const request = buildApplicationPolicyEstimateRequest(preview, currentUser)
assert.equal(request.canCalculate, true)
assert.deepEqual(request.payload, {
days: 3,
location: '\u5317\u4eac',
grade: 'P5',
transport_mode: null,
origin_location: '\u6b66\u6c49',
travel_date: '2026-06-23'
})
const estimatedPreview = applyApplicationPolicyEstimateResult(preview, {
days: 3,
location: '\u5317\u4eac',
matched_city: '\u5317\u4eac',
grade: 'P5',
hotel_rate: 450,
hotel_amount: 1350,
total_allowance_rate: 100,
allowance_amount: 300,
total_amount: 1650,
rule_name: '\u516c\u53f8\u5dee\u65c5\u8d39\u62a5\u9500\u89c4\u5219',
rule_version: 'v1.0.0'
}, currentUser)
assert.equal(estimatedPreview.fields.days, '3\u5929')
assert.equal(estimatedPreview.fields.lodgingDailyCap, '450\u5143/\u5929')
assert.equal(estimatedPreview.fields.subsidyDailyCap, '100\u5143/\u5929')
assert.equal(estimatedPreview.fields.amount, '1,650\u5143\uff08\u4e0d\u542b\u4ea4\u901a\uff09')
assert.match(estimatedPreview.fields.policyEstimate, /\u4ea4\u901a\u5f85\u8865\u5145/)
})
test('application preview editor refreshes transport estimate after mode change', async () => {
const preview = applyApplicationPolicyEstimateResult(
buildLocalApplicationPreview('申请 2026-05-25 至 2026-05-27 去上海出差3天服务项目部署', {
@@ -1726,3 +1773,197 @@ test('application preview editor recalculates days and subsidy after date range
assert.equal(message.applicationPreview.fields.subsidyDailyCap, '100\u5143/\u5929')
assert.match(message.applicationPreview.fields.policyEstimate, /\u8865\u8d34 400\u5143/)
})
test('application preview editor can edit return date from table row', async () => {
const preview = normalizeApplicationPreview({
fields: {
applicationType: '\u5dee\u65c5\u8d39\u7528\u7533\u8bf7',
time: '2026-02-20 \u81f3 2026-02-23',
location: '\u4e0a\u6d77',
reason: '\u670d\u52a1\u9879\u76ee\u90e8\u7f72',
days: '4\u5929',
transportMode: '\u706b\u8f66',
amount: '',
grade: 'P5',
applicant: '\u674e\u6587\u9759',
department: '\u6280\u672f\u90e8',
position: '\u8d22\u52a1\u667a\u80fd\u5316\u4ea7\u54c1\u7ecf\u7406',
managerName: '\u5411\u4e07\u7ea2'
}
})
const message = {
id: 'application-preview-editor-return-date-message',
applicationPreview: preview,
text: ''
}
const requestedPayloads = []
const editor = useApplicationPreviewEditor({
persistSessionState: () => {},
toast: () => {},
currentUser: ref({ grade: 'P5' }),
calculateTravelReimbursement: async (payload) => {
requestedPayloads.push(payload)
return {
days: payload.days,
location: payload.location,
matched_city: payload.location,
grade: payload.grade,
hotel_rate: 450,
hotel_amount: 2250,
total_allowance_rate: 100,
allowance_amount: 500,
total_amount: 2750,
rule_name: '\u516c\u53f8\u5dee\u65c5\u8d39\u62a5\u9500\u89c4\u5219',
rule_version: 'v1.0.0'
}
}
})
editor.openApplicationPreviewEditor(message, 'time_return', '2026-02-23')
assert.equal(editor.resolveApplicationPreviewEditorControl('time_return'), 'date')
assert.equal(editor.applicationPreviewEditor.value.dateMode, 'range')
assert.equal(editor.applicationPreviewEditor.value.rangeStartDate, '2026-02-20')
editor.applicationPreviewEditor.value.rangeEndDate = '2026-02-24'
const committed = await editor.commitApplicationPreviewDateEditor(message)
assert.equal(committed, true)
assert.deepEqual(requestedPayloads.at(-1), {
days: 5,
location: '\u4e0a\u6d77',
grade: 'P5',
transport_mode: '\u706b\u8f66',
origin_location: null,
travel_date: '2026-02-20'
})
assert.equal(message.applicationPreview.fields.time, '2026-02-20 \u81f3 2026-02-24')
assert.equal(message.applicationPreview.fields.days, '5\u5929')
})
test('application preview editor can edit return date from inline table input', async () => {
const preview = normalizeApplicationPreview({
fields: {
applicationType: '\u5dee\u65c5\u8d39\u7528\u7533\u8bf7',
time: '2026-02-20 \u81f3 2026-02-23',
location: '\u4e0a\u6d77',
reason: '\u670d\u52a1\u9879\u76ee\u90e8\u7f72',
days: '4\u5929',
transportMode: '\u706b\u8f66',
amount: '',
grade: 'P5',
applicant: '\u674e\u6587\u9759',
department: '\u6280\u672f\u90e8',
position: '\u8d22\u52a1\u667a\u80fd\u5316\u4ea7\u54c1\u7ecf\u7406',
managerName: '\u5411\u4e07\u7ea2'
}
})
const message = {
id: 'application-preview-editor-inline-return-date-message',
applicationPreview: preview,
text: ''
}
const requestedPayloads = []
const editor = useApplicationPreviewEditor({
persistSessionState: () => {},
toast: () => {},
currentUser: ref({ grade: 'P5' }),
calculateTravelReimbursement: async (payload) => {
requestedPayloads.push(payload)
return {
days: payload.days,
location: payload.location,
matched_city: payload.location,
grade: payload.grade,
hotel_rate: 450,
hotel_amount: 2250,
total_allowance_rate: 100,
allowance_amount: 500,
total_amount: 2750,
rule_name: '\u516c\u53f8\u5dee\u65c5\u8d39\u62a5\u9500\u89c4\u5219',
rule_version: 'v1.0.0'
}
}
})
editor.openApplicationPreviewEditor(message, 'time_return', '2026-02-23')
editor.applicationPreviewEditor.value.draftValue = '2026-02-24'
const committed = await editor.commitApplicationPreviewEditor(message)
assert.equal(committed, true)
assert.deepEqual(requestedPayloads.at(-1), {
days: 5,
location: '\u4e0a\u6d77',
grade: 'P5',
transport_mode: '\u706b\u8f66',
origin_location: null,
travel_date: '2026-02-20'
})
assert.equal(message.applicationPreview.fields.time, '2026-02-20 \u81f3 2026-02-24')
assert.equal(message.applicationPreview.fields.time_return, undefined)
assert.equal(message.applicationPreview.fields.days, '5\u5929')
})
test('application preview editor estimates after shorthand return date input', async () => {
const preview = normalizeApplicationPreview({
fields: {
applicationType: '\u5dee\u65c5\u8d39\u7528\u7533\u8bf7',
time: '2026-06-23',
location: '\u5317\u4eac',
reason: '\u652f\u6491\u5ba2\u6237\u73b0\u573a\u5b9e\u65bd',
days: '',
transportMode: '',
amount: '',
grade: 'P5',
applicant: '\u674e\u6587\u9759',
department: '\u6280\u672f\u90e8',
position: '\u8d22\u52a1\u667a\u80fd\u5316\u4ea7\u54c1\u7ecf\u7406',
managerName: '\u5411\u4e07\u7ea2'
}
})
const message = {
id: 'application-preview-editor-shorthand-return-date-message',
applicationPreview: preview,
text: ''
}
const requestedPayloads = []
const editor = useApplicationPreviewEditor({
persistSessionState: () => {},
toast: () => {},
currentUser: ref({ grade: 'P5', location: '\u6b66\u6c49' }),
calculateTravelReimbursement: async (payload) => {
requestedPayloads.push(payload)
return {
days: payload.days,
location: payload.location,
matched_city: payload.location,
grade: payload.grade,
hotel_rate: 450,
hotel_amount: 1350,
total_allowance_rate: 100,
allowance_amount: 300,
total_amount: 1650,
rule_name: '\u516c\u53f8\u5dee\u65c5\u8d39\u62a5\u9500\u89c4\u5219',
rule_version: 'v1.0.0'
}
}
})
editor.openApplicationPreviewEditor(message, 'time_return', '\u5f85\u8865\u5145')
editor.applicationPreviewEditor.value.draftValue = '6\u670825\u65e5'
const committed = await editor.commitApplicationPreviewEditor(message)
assert.equal(committed, true)
assert.deepEqual(requestedPayloads.at(-1), {
days: 3,
location: '\u5317\u4eac',
grade: 'P5',
transport_mode: null,
origin_location: '\u6b66\u6c49',
travel_date: '2026-06-23'
})
assert.equal(message.applicationPreview.fields.time, '2026-06-23 \u81f3 2026-06-25')
assert.equal(message.applicationPreview.fields.days, '3\u5929')
assert.equal(message.applicationPreview.fields.lodgingDailyCap, '450\u5143/\u5929')
assert.equal(message.applicationPreview.fields.subsidyDailyCap, '100\u5143/\u5929')
assert.equal(message.applicationPreview.fields.amount, '1,650\u5143\uff08\u4e0d\u542b\u4ea4\u901a\uff09')
assert.match(message.applicationPreview.fields.policyEstimate, /\u4ea4\u901a\u5f85\u8865\u5145/)
})

View File

@@ -396,7 +396,7 @@ test('submit composer scopes the side panel to intent overview, document upload,
test('expense query answers keep one clear result structure with document center jump link', () => {
assert.doesNotMatch(createViewTemplateSurface, /message\.meta\?\.length/)
assert.match(createViewTemplateSurface, /!message\.reviewPayload && !message\.queryPayload && message\.suggestedActions\?\.length/)
assert.match(createViewTemplateSurface, /!message\.reviewPayload && \(!message\.queryPayload \|\| message\.queryPayload\.selectionMode === 'reimbursement_application_association'\) && message\.suggestedActions\?\.length/)
assert.match(createViewTemplateSurface, /!message\.reviewPayload && !message\.queryPayload && message\.citations\?\.length/)
assert.match(createViewTemplateSurface, /message\.queryPayload\.title \|\| \(message\.queryPayload\.selectionMode === 'draft_association' \? '选择关联草稿' : '最近 5 条筛选结果'\)/)
assert.match(createViewTemplateSurface, /v-html="ui\.renderMarkdown\(ui\.buildExpenseQueryHint\(message\.queryPayload\)\)"/)