@@ -178,6 +188,7 @@
:key="item.label"
type="button"
class="workbench-ai-action"
+ :disabled="isAiModeInputLocked"
@click="runAiModeAction(item)"
>
@@ -329,14 +340,15 @@
:class="{
missing: row.missing,
editable: row.editable,
- highlight: row.highlight
+ highlight: row.highlight,
+ 'is-disabled': isApplicationPreviewEstimatePending(message)
}"
role="row"
- :tabindex="row.editable ? 0 : -1"
+ :tabindex="row.editable && !isApplicationPreviewEstimatePending(message) ? 0 : -1"
:aria-label="row.editable ? `编辑${row.label}` : row.label"
- @click.stop="row.editable && !isApplicationPreviewEditing(message, row.key) && openApplicationPreviewEditor(message, row.key, row.value)"
- @keydown.enter.prevent="row.editable && !isApplicationPreviewEditing(message, row.key) && openApplicationPreviewEditor(message, row.key, row.value)"
- @keydown.space.prevent="row.editable && !isApplicationPreviewEditing(message, row.key) && openApplicationPreviewEditor(message, row.key, row.value)"
+ @click.stop="row.editable && !isApplicationPreviewEstimatePending(message) && !isApplicationPreviewEditing(message, row.key) && openApplicationPreviewEditor(message, row.key, row.value)"
+ @keydown.enter.prevent="row.editable && !isApplicationPreviewEstimatePending(message) && !isApplicationPreviewEditing(message, row.key) && openApplicationPreviewEditor(message, row.key, row.value)"
+ @keydown.space.prevent="row.editable && !isApplicationPreviewEstimatePending(message) && !isApplicationPreviewEditing(message, row.key) && openApplicationPreviewEditor(message, row.key, row.value)"
>
{{ row.label }}
@@ -346,6 +358,7 @@
class="application-preview-input"
type="text"
autofocus
+ :disabled="isApplicationPreviewEstimatePending(message)"
@click.stop
@keydown.stop="handleInlineApplicationPreviewEditorKeydown($event, message)"
@blur="commitInlineApplicationPreviewEditor(message)"
@@ -355,6 +368,7 @@
v-model="applicationPreviewEditor.draftValue"
class="application-preview-input application-preview-select"
autofocus
+ :disabled="isApplicationPreviewEstimatePending(message)"
@click.stop
@change="commitInlineApplicationPreviewEditor(message)"
@keydown.stop="handleInlineApplicationPreviewEditorKeydown($event, message)"
@@ -377,6 +391,7 @@
class="application-preview-edit-btn"
title="修改内容"
aria-label="修改内容"
+ :disabled="isApplicationPreviewEstimatePending(message)"
@click.stop="openApplicationPreviewEditor(message, row.key, row.value)"
>
@@ -421,12 +436,13 @@
小财管家正在识别任务、拆解流程并准备下一步建议...
-
+
- 清除
+ 清除
完成
@@ -563,6 +586,7 @@
class="workbench-ai-icon-btn"
title="上传附件"
aria-label="上传附件"
+ :disabled="isAiModeInputLocked"
@click="triggerAiModeFileUpload"
>
@@ -572,6 +596,7 @@
class="workbench-ai-icon-btn"
title="语音输入"
aria-label="语音输入"
+ :disabled="isAiModeInputLocked"
@click="handleVoiceInput"
>
@@ -586,7 +611,7 @@
@@ -617,6 +642,30 @@
+
+
+
+
+
确认直接提交申请?
+
确认后系统会先查询你名下相同日期的申请单;若发现重复或重叠日期,会停止提交并列出已有单据供你查看。
+
若核查通过,申请单会直接进入审批流程。
+
+ 取消
+ 确认直接提交
+
+
+
+
@@ -667,6 +716,12 @@ import {
buildLocalApplicationPreviewMessage,
normalizeApplicationPreview
} from '../../utils/expenseApplicationPreview.js'
+import {
+ buildAiApplicationPrecheck,
+ buildAiApplicationPrecheckThinkingEvents,
+ buildAiApplicationSubmitConflictMessage,
+ isAiApplicationPrecheckBlocking
+} from '../../utils/aiApplicationPrecheckModel.js'
import { useApplicationPreviewEditor } from '../../views/scripts/useApplicationPreviewEditor.js'
import {
buildAiDocumentQueryConditionSummary,
@@ -686,6 +741,11 @@ import {
fetchApprovalExpenseClaims,
fetchExpenseClaims
} from '../../services/reimbursements.js'
+import {
+ AI_APPLICATION_ACTION_SAVE_DRAFT,
+ AI_APPLICATION_ACTION_SUBMIT,
+ runAiApplicationPreviewAction
+} from '../../services/aiApplicationPreviewActions.js'
const props = defineProps({
sidebarCommand: { type: Object, default: () => ({ seq: 0, type: '', payload: null }) }
@@ -712,6 +772,8 @@ const aiExpenseDraft = ref(null)
const thinkingExpandedMessageIds = ref(new Set())
const thinkingCollapsedMessageIds = ref(new Set())
const deleteDialogOpen = ref(false)
+const applicationSubmitConfirmOpen = ref(false)
+const applicationSubmitConfirmContext = ref(null)
let messageSeq = 0
const AI_SEARCH_CONVERSATION_ID = 'ai-search'
const INLINE_ANSWER_STREAM_CHUNK_SIZE = 6
@@ -719,6 +781,7 @@ const INLINE_ANSWER_STREAM_DELAY_MS = 24
const INLINE_AUTO_SCROLL_THRESHOLD = 96
const INLINE_LAYOUT_SETTLE_SCROLL_DELAY_MS = 260
const AI_DOCUMENT_DETAIL_HREF_PREFIX = '#ai-open-document-detail:'
+const AI_APPLICATION_DETAIL_HREF_PREFIX = '#ai-open-application-detail:'
const {
applicationPreviewEditor,
@@ -809,10 +872,18 @@ const modelSelectorTitle = computed(() => {
return provider ? `当前模型:${provider} / ${model}` : `当前模型:${model}`
})
+const applicationPreviewEstimatePending = computed(() => (
+ conversationMessages.value.some((message) => isApplicationPreviewEstimatePending(message))
+))
+
+const isAiModeInputLocked = computed(() => applicationPreviewEstimatePending.value)
+
const canSubmitAiModePrompt = computed(() => (
- Boolean(assistantDraft.value.trim())
- || selectedFiles.value.length > 0
- || Boolean(workbenchDateTagLabel.value)
+ !isAiModeInputLocked.value && (
+ Boolean(assistantDraft.value.trim())
+ || selectedFiles.value.length > 0
+ || Boolean(workbenchDateTagLabel.value)
+ )
))
async function loadSystemSettings() {
@@ -896,6 +967,7 @@ function createInlineMessage(role, content, options = {}) {
stewardPlan: options.stewardPlan || null,
suggestedActions: Array.isArray(options.suggestedActions) ? options.suggestedActions : [],
applicationPreview: options.applicationPreview || null,
+ draftPayload: options.draftPayload || null,
text: options.text || normalizedContent,
createdAt: options.createdAt || Date.now()
}
@@ -953,6 +1025,7 @@ function normalizeRuntimeMessage(message = {}) {
stewardPlan: message.stewardPlan || null,
suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : [],
applicationPreview: message.applicationPreview || null,
+ draftPayload: message.draftPayload || null,
text: message.text || message.content || ''
})
}
@@ -966,7 +1039,8 @@ function serializeRuntimeMessage(message = {}) {
feedback: message.feedback || '',
stewardPlan: message.stewardPlan || null,
suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : [],
- applicationPreview: message.applicationPreview || null
+ applicationPreview: message.applicationPreview || null,
+ draftPayload: message.draftPayload || null
}
}
@@ -1013,6 +1087,8 @@ function resetInlineConversationState() {
thinkingExpandedMessageIds.value = new Set()
thinkingCollapsedMessageIds.value = new Set()
deleteDialogOpen.value = false
+ applicationSubmitConfirmOpen.value = false
+ applicationSubmitConfirmContext.value = null
clearWorkbenchDateSelection()
clearAiModeFiles()
}
@@ -1039,6 +1115,36 @@ function renderInlineConversationHtml(content) {
return renderAiConversationHtml(content)
}
+function isApplicationPreviewEstimatePendingPreview(applicationPreview = {}) {
+ const fields = normalizeApplicationPreview(applicationPreview).fields || {}
+ return [
+ fields.transportPolicy,
+ fields.policyEstimate,
+ fields.transportEstimatedAmount,
+ fields.amount
+ ].some((value) => /正在|查询中/.test(String(value || '')))
+}
+
+function isApplicationPreviewEstimatePending(message = {}) {
+ return Boolean(message?.applicationPreview && isApplicationPreviewEstimatePendingPreview(message.applicationPreview))
+}
+
+function shouldRefreshInlineApplicationPreviewEstimate(fieldKey = '') {
+ return ['transportMode', 'time', 'location', 'days'].includes(String(fieldKey || '').trim())
+}
+
+function canShowInlineSuggestedActions(message = {}) {
+ return Boolean(message?.suggestedActions?.length) && !isApplicationPreviewEstimatePending(message)
+}
+
+function isInlineSuggestedActionDisabled(action = {}, message = {}) {
+ const actionType = String(action?.action_type || '').trim()
+ return (
+ [AI_APPLICATION_ACTION_SAVE_DRAFT, AI_APPLICATION_ACTION_SUBMIT].includes(actionType) &&
+ isApplicationPreviewEstimatePending(message)
+ )
+}
+
function resolveInlineApplicationPreviewRows(message) {
return buildApplicationPreviewRows(message?.applicationPreview || {})
}
@@ -1059,9 +1165,15 @@ function syncInlineApplicationPreviewMessageContent(message) {
const nextContent = buildLocalApplicationPreviewMessage(message.applicationPreview)
message.content = nextContent
message.text = nextContent
+ message.suggestedActions = buildInlineApplicationPreviewSuggestedActions(message.applicationPreview, message.draftPayload)
}
async function commitInlineApplicationPreviewEditor(message) {
+ const shouldLockForEstimate = shouldRefreshInlineApplicationPreviewEstimate(applicationPreviewEditor.value.fieldKey)
+ if (shouldLockForEstimate) {
+ message.suggestedActions = []
+ persistCurrentConversation()
+ }
const committed = await commitApplicationPreviewEditor(message)
syncInlineApplicationPreviewMessageContent(message)
persistCurrentConversation()
@@ -1084,10 +1196,156 @@ function handleInlineApplicationPreviewEditorKeydown(event, message) {
function buildInlineApplicationPreviewFooterText(message) {
const normalized = normalizeApplicationPreview(message?.applicationPreview || {})
+ if (isApplicationPreviewEstimatePending(message)) {
+ return '费用测算正在同步,请稍等,完成后才能保存草稿或直接提交。'
+ }
if (normalized.validationIssues?.length || normalized.missingFields?.length) {
return buildApplicationPreviewFooterMessage(normalized)
}
- return '申请核对表已补齐,费用测算已同步。你仍可点击表格继续修改;确认无误后,可以回复“保存草稿”或“提交申请”。'
+ return '申请核对表已补齐,费用测算已同步。你仍可点击表格继续修改;确认无误后,可以点击下方按钮保存草稿或直接提交,也可以直接回复“保存草稿”或“提交”。'
+}
+
+function buildInlineApplicationPreviewSuggestedActions(applicationPreview = {}, draftPayload = null) {
+ if (isApplicationPreviewEstimatePendingPreview(applicationPreview)) {
+ return []
+ }
+ const normalized = normalizeApplicationPreview(applicationPreview)
+ const actions = [{
+ label: '保存草稿',
+ description: '先保存当前申请表,后续可以继续补充或提交。',
+ icon: 'mdi mdi-content-save-outline',
+ action_type: AI_APPLICATION_ACTION_SAVE_DRAFT,
+ payload: { draftPayload }
+ }]
+ if (normalized.readyToSubmit) {
+ actions.push({
+ label: '直接提交',
+ description: '提交前先核查相同日期申请单,确认通过后进入审批流程。',
+ icon: 'mdi mdi-send-check-outline',
+ action_type: AI_APPLICATION_ACTION_SUBMIT,
+ payload: { draftPayload }
+ })
+ }
+ return actions
+}
+
+function resolveLatestApplicationPreviewMessage() {
+ return [...conversationMessages.value]
+ .reverse()
+ .find((message) => message.role === 'assistant' && message.applicationPreview)
+}
+
+function resolveInlineApplicationPreviewActionFromText(text = '') {
+ const normalized = String(text || '').replace(/\s+/g, '').trim()
+ if (!normalized) {
+ return ''
+ }
+ if (/^(保存草稿|保存|存草稿|先保存)$/.test(normalized)) {
+ return AI_APPLICATION_ACTION_SAVE_DRAFT
+ }
+ if (/^(提交|提交申请|确认提交|提交审批|直接提交)$/.test(normalized)) {
+ return AI_APPLICATION_ACTION_SUBMIT
+ }
+ return ''
+}
+
+function extractInlineApplicationDraftPayload(payload = {}) {
+ const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
+ return result.draft_payload && typeof result.draft_payload === 'object'
+ ? result.draft_payload
+ : payload?.draft_payload && typeof payload.draft_payload === 'object'
+ ? payload.draft_payload
+ : null
+}
+
+function normalizeInlineApplicationResultTableCell(value, fallback = '-') {
+ const text = String(value || '')
+ .replace(/\s*\n+\s*/g, ' ')
+ .replace(/\|/g, '|')
+ .trim()
+ return text || fallback
+}
+
+function buildInlineApplicationActionDetailHref(reference = '') {
+ const value = String(reference || '').trim()
+ return value ? `${AI_APPLICATION_DETAIL_HREF_PREFIX}${encodeURIComponent(value)}` : ''
+}
+
+function resolveInlineApplicationActionDocumentInfo(draftPayload = {}) {
+ const source = draftPayload && typeof draftPayload === 'object' ? draftPayload : {}
+ return {
+ claimNo: String(source.claim_no || source.claimNo || source.document_no || source.documentNo || '').trim(),
+ claimId: String(source.claim_id || source.claimId || source.id || '').trim(),
+ statusLabel: String(source.status_label || source.statusLabel || source.status || '').trim(),
+ approvalStage: String(source.approval_stage || source.approvalStage || '').trim(),
+ documentTypeLabel: String(
+ source.document_type_label ||
+ source.documentTypeLabel ||
+ source.application_type_label ||
+ source.applicationTypeLabel ||
+ source.expense_type_label ||
+ source.expenseTypeLabel ||
+ ''
+ ).trim()
+ }
+}
+
+function buildInlineApplicationResultTable(draftPayload = {}, options = {}) {
+ const info = resolveInlineApplicationActionDocumentInfo(draftPayload)
+ const reference = info.claimNo || info.claimId
+ const href = buildInlineApplicationActionDetailHref(reference)
+ const actionText = href ? `[查看](${href})` : '-'
+ return [
+ '| 单据类型 | 单据编号 | 单据状态 | 当前节点 | 操作 |',
+ '| --- | --- | --- | --- | --- |',
+ `| ${normalizeInlineApplicationResultTableCell(info.documentTypeLabel || options.documentTypeLabel, '出差申请')} | ${normalizeInlineApplicationResultTableCell(reference)} | ${normalizeInlineApplicationResultTableCell(info.statusLabel || options.statusLabel)} | ${normalizeInlineApplicationResultTableCell(info.approvalStage || options.stageLabel)} | ${actionText} |`
+ ].join('\n')
+}
+
+function buildInlineApplicationPreviewActionResultText(actionType, payload = {}) {
+ const draftPayload = extractInlineApplicationDraftPayload(payload) || {}
+ const claimNo = String(draftPayload.claim_no || draftPayload.claimNo || '').trim()
+ const approvalStage = String(draftPayload.approval_stage || draftPayload.approvalStage || '').trim()
+ if (actionType === AI_APPLICATION_ACTION_SUBMIT) {
+ return [
+ '### 申请单据已生成,并已进入审批流程',
+ approvalStage ? `系统已推送到 **${approvalStage}**,当前节点:${approvalStage}。` : '系统已推送到审批流程,当前节点:审批中。',
+ buildInlineApplicationResultTable(draftPayload, {
+ statusLabel: '审批中',
+ stageLabel: approvalStage || '直属领导审批',
+ documentTypeLabel: '出差申请'
+ }),
+ '需要查看完整详情时,请点击列表最后一列的“查看”进入单据详情。'
+ ].filter(Boolean).join('\n\n')
+ }
+ return [
+ '### 申请草稿已保存',
+ claimNo ? `系统已保存当前申请草稿,草稿单号:**${claimNo}**。` : '系统已保存当前申请草稿。',
+ buildInlineApplicationResultTable(draftPayload, {
+ statusLabel: '草稿',
+ stageLabel: '待提交',
+ documentTypeLabel: '出差申请'
+ }),
+ '后续请点击表格最后一列的“查看”进入详情页继续核对。'
+ ].filter(Boolean).join('\n\n')
+}
+
+function buildInlineApplicationDetailAction(draftPayload = {}) {
+ const claimNo = String(draftPayload?.claim_no || draftPayload?.claimNo || '').trim()
+ if (!claimNo) {
+ return []
+ }
+ return [{
+ label: '查看单据详情',
+ description: '打开刚生成的申请单详情。',
+ icon: 'mdi mdi-open-in-new',
+ action_type: 'open_application_detail',
+ payload: {
+ claim_no: claimNo,
+ claim_id: String(draftPayload.claim_id || draftPayload.claimId || '').trim(),
+ document_type: 'application'
+ }
+ }]
}
function resolveInlineThinkingEvents(message) {
@@ -1220,33 +1478,371 @@ function buildAiRequiredApplicationGateAutoMessage(normalizedPlan, flow) {
if (flow?.flowId === 'travel_application') {
return [
contextText || baseText,
- '我会继续在当前对话里为你发起出差申请。'
+ '这类操作需要你手动确认。请点击下方 **确认发起出差申请**,我再在当前对话里生成完整申请表,并把已识别的信息自动预填。'
].filter(Boolean).join('\n\n')
}
if (flow?.flowId === 'travel_reimbursement') {
return [
contextText || baseText,
- '我会继续进入申请单关联步骤,请你确认要关联哪张单据。'
+ '这类操作需要你手动确认。请点击下方 **确认关联已有申请单**,我再继续查询并展示可关联单据。'
].filter(Boolean).join('\n\n')
}
return baseText
}
-function continueAiRequiredApplicationGateFromPlan(normalizedPlan, prompt = '') {
- const flow = resolveRequiredApplicationGateContinuationFlow(normalizedPlan)
- if (!flow) {
- return false
- }
+function buildAiRequiredApplicationGateSuggestedActions(flow, prompt = '') {
if (flow.flowId === 'travel_application') {
- aiExpenseDraft.value = null
- void startAiApplicationPreview('travel', '差旅费', prompt)
- return true
+ return [{
+ label: '确认发起出差申请',
+ description: '确认后生成完整申请表,并预填已识别的时间、地点和事由。',
+ icon: 'mdi mdi-file-plus-outline',
+ action_type: 'ai_application_start_inline',
+ payload: {
+ expense_type: 'travel',
+ expense_type_label: '差旅费',
+ carry_text: prompt
+ }
+ }]
}
if (flow.flowId === 'travel_reimbursement') {
- startAiExpenseDraft('travel', '差旅费', true)
+ return [{
+ label: '确认关联已有申请单',
+ description: '确认后查询你名下可关联的差旅申请单,并进入关联步骤。',
+ icon: 'mdi mdi-link-variant',
+ action_type: 'steward_confirm_flow',
+ payload: {
+ steward_confirm_flow: true,
+ flow_id: 'travel_reimbursement',
+ expense_type: 'travel',
+ expense_type_label: '差旅费',
+ carry_text: prompt
+ }
+ }]
+ }
+ return []
+}
+
+function resolveInlineApplicationDraftIdentity(payload = {}) {
+ const source = payload && typeof payload === 'object' ? payload : {}
+ return {
+ claimId: String(source.claim_id || source.claimId || source.id || '').trim(),
+ claimNo: String(source.claim_no || source.claimNo || source.document_no || source.documentNo || '').trim()
+ }
+}
+
+function isSameInlineApplicationDraftClaim(claim = {}, draftPayload = {}) {
+ const draftIdentity = resolveInlineApplicationDraftIdentity(draftPayload)
+ if (!draftIdentity.claimId && !draftIdentity.claimNo) {
+ return false
+ }
+ const claimIdentity = resolveInlineApplicationDraftIdentity(claim)
+ return Boolean(
+ (draftIdentity.claimId && claimIdentity.claimId && draftIdentity.claimId === claimIdentity.claimId) ||
+ (draftIdentity.claimNo && claimIdentity.claimNo && draftIdentity.claimNo === claimIdentity.claimNo)
+ )
+}
+
+function buildInlineApplicationSubmitPrecheckPayload(claimsPayload, draftPayload = {}) {
+ const items = extractExpenseClaimItems(claimsPayload)
+ .filter((claim) => !isSameInlineApplicationDraftClaim(claim, draftPayload))
+ return { items }
+}
+
+function completeInlineThinkingEvents(events = []) {
+ return events.map((event) => ({
+ ...event,
+ status: event.status === 'failed' ? 'failed' : 'completed'
+ }))
+}
+
+function buildInitialInlineApplicationSubmitThinkingEvents() {
+ return [
+ {
+ eventId: 'application-precheck-overlap',
+ title: '核查同时间段申请单',
+ content: '正在查询你名下可见申请单,检查是否存在相同或重叠日期。',
+ status: 'running'
+ },
+ {
+ eventId: 'application-precheck-budget',
+ title: '评估预算与审批影响',
+ content: '等待单据重叠核查完成后,继续评估预算占用和审批影响。',
+ status: 'pending'
+ },
+ {
+ eventId: 'application-submit',
+ title: '提交申请单据',
+ content: '等待提交前核查完成。',
+ status: 'pending'
+ }
+ ]
+}
+
+function buildInlineApplicationSubmitThinkingEvents(precheck = {}) {
+ const blocked = isAiApplicationPrecheckBlocking(precheck)
+ return buildAiApplicationPrecheckThinkingEvents(precheck).map((event) => {
+ if (event.eventId !== 'application-precheck-form') {
+ return event
+ }
+ return {
+ eventId: 'application-submit',
+ title: blocked ? '暂停提交申请' : '提交申请单据',
+ content: blocked
+ ? '发现相同或重叠日期已有申请单,已暂停本次提交。'
+ : '提交前核查通过,正在生成申请单据并推送审批流程。',
+ status: blocked ? 'completed' : 'running'
+ }
+ })
+}
+
+function buildFailedInlineApplicationSubmitThinkingEvents(error) {
+ return [
+ {
+ eventId: 'application-precheck-overlap',
+ title: '核查同时间段申请单',
+ content: `查询已有申请单失败:${String(error?.message || error || '未知错误')}`,
+ status: 'failed'
+ },
+ {
+ eventId: 'application-submit',
+ title: '暂停提交申请',
+ content: '因为未能完成提交前重复日期核查,系统没有提交本次申请。',
+ status: 'failed'
+ }
+ ]
+}
+
+function requestInlineApplicationSubmitConfirmation(targetMessage, options = {}) {
+ applicationSubmitConfirmContext.value = {
+ messageId: String(targetMessage?.id || '').trim(),
+ draftPayload: targetMessage?.draftPayload || options.draftPayload || null,
+ userText: String(options.userText || '直接提交').trim() || '直接提交'
+ }
+ applicationSubmitConfirmOpen.value = true
+ persistCurrentConversation()
+}
+
+function cancelInlineApplicationSubmitConfirm() {
+ applicationSubmitConfirmOpen.value = false
+ applicationSubmitConfirmContext.value = null
+ focusAiModeInput()
+}
+
+function confirmInlineApplicationSubmit() {
+ const context = applicationSubmitConfirmContext.value || {}
+ applicationSubmitConfirmOpen.value = false
+ applicationSubmitConfirmContext.value = null
+ const sourceMessage = conversationMessages.value.find((message) => message.id === context.messageId)
+ if (!sourceMessage?.applicationPreview) {
+ toast('当前申请表已变化,请重新点击直接提交。')
+ return
+ }
+ void executeInlineApplicationPreviewAction(AI_APPLICATION_ACTION_SUBMIT, sourceMessage, {
+ confirmed: true,
+ skipUserMessage: false,
+ draftPayload: context.draftPayload || null,
+ userText: context.userText || '直接提交'
+ })
+}
+
+async function runInlineApplicationSubmitPrecheck(targetMessage, pendingMessage, normalizedPreview, options = {}) {
+ try {
+ const claimsPayload = await fetchExpenseClaims({ page: 1, pageSize: 100 })
+ const precheck = buildAiApplicationPrecheck(normalizedPreview, {
+ claimsPayload: buildInlineApplicationSubmitPrecheckPayload(
+ claimsPayload,
+ targetMessage.draftPayload || options.draftPayload || null
+ ),
+ currentUser: currentUser.value || {},
+ expenseType: 'travel'
+ })
+ const thinkingEvents = buildInlineApplicationSubmitThinkingEvents(precheck)
+ const blocked = isAiApplicationPrecheckBlocking(precheck)
+
+ if (blocked) {
+ replaceInlineMessage(
+ pendingMessage.id,
+ createInlineMessage('assistant', buildAiApplicationSubmitConflictMessage(normalizedPreview, precheck), {
+ id: pendingMessage.id,
+ stewardPlan: {
+ streamStatus: 'completed',
+ thinkingEvents
+ }
+ })
+ )
+ persistCurrentConversation()
+ scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
+ return false
+ }
+
+ const message = conversationMessages.value.find((item) => item.id === pendingMessage.id) || pendingMessage
+ updateInlineMessageContent(message, '提交前核查通过,正在提交申请并进入审批流程...')
+ message.stewardPlan = {
+ ...(message.stewardPlan || {}),
+ streamStatus: 'streaming',
+ thinkingEvents
+ }
+ await nextTick()
+ scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
+ return true
+ } catch (error) {
+ replaceInlineMessage(
+ pendingMessage.id,
+ createInlineMessage('assistant', [
+ '### 提交前核查失败',
+ '系统未能完成相同日期申请单查询,所以本次申请没有提交。',
+ '请稍后重试;如果仍然失败,请先到单据中心核对是否已有同日期申请单。'
+ ].join('\n\n'), {
+ id: pendingMessage.id,
+ stewardPlan: {
+ streamStatus: 'failed',
+ thinkingEvents: buildFailedInlineApplicationSubmitThinkingEvents(error)
+ }
+ })
+ )
+ toast('提交前核查失败,已暂停提交。')
+ persistCurrentConversation()
+ return false
+ }
+}
+
+async function executeInlineApplicationPreviewAction(actionType, sourceMessage = null, options = {}) {
+ const targetMessage = sourceMessage?.applicationPreview ? sourceMessage : resolveLatestApplicationPreviewMessage()
+ if (!targetMessage?.applicationPreview) {
+ toast('当前没有可提交的申请表。')
+ return false
+ }
+
+ const normalizedPreview = normalizeApplicationPreview(targetMessage.applicationPreview)
+ const isSubmit = actionType === AI_APPLICATION_ACTION_SUBMIT
+ const userText = String(options.userText || (isSubmit ? '直接提交' : '保存草稿')).trim()
+
+ if (isSubmit && !normalizedPreview.readyToSubmit) {
+ if (!options.skipUserMessage) {
+ pushInlineApplicationActionUserMessage(userText)
+ }
+ const missingText = normalizedPreview.missingFields?.length
+ ? `当前还缺少:${normalizedPreview.missingFields.join('、')}。`
+ : ''
+ const validationText = normalizedPreview.validationIssues?.length
+ ? normalizedPreview.validationIssues.map((item) => item.message).join(';')
+ : ''
+ conversationMessages.value.push(createInlineMessage('assistant', [
+ '### 暂不能提交申请',
+ missingText || validationText || '当前申请表还未通过提交校验。',
+ '请先点击表格中的字段补充或修正,补齐后我会开放“直接提交”入口。'
+ ].filter(Boolean).join('\n\n')))
+ persistCurrentConversation()
+ scrollInlineConversationToBottom()
return true
}
- return false
+
+ if (isSubmit && !options.confirmed) {
+ requestInlineApplicationSubmitConfirmation(targetMessage, { ...options, userText })
+ return true
+ }
+
+ if (!options.skipUserMessage) {
+ pushInlineApplicationActionUserMessage(userText)
+ }
+
+ sending.value = true
+ const pendingMessage = createInlineMessage(
+ 'assistant',
+ isSubmit ? '正在提交前核查相同日期申请单...' : '正在保存申请草稿...',
+ {
+ pending: true,
+ stewardPlan: {
+ streamStatus: 'streaming',
+ thinkingEvents: isSubmit
+ ? buildInitialInlineApplicationSubmitThinkingEvents()
+ : [
+ {
+ eventId: 'application-save-draft',
+ title: '保存申请草稿',
+ content: '正在按当前申请表内容保存草稿。',
+ status: 'running'
+ }
+ ]
+ }
+ }
+ )
+ conversationMessages.value.push(pendingMessage)
+ scrollInlineConversationToBottom()
+
+ try {
+ if (isSubmit) {
+ const precheckPassed = await runInlineApplicationSubmitPrecheck(
+ targetMessage,
+ pendingMessage,
+ normalizedPreview,
+ options
+ )
+ if (!precheckPassed) {
+ return true
+ }
+ }
+
+ const payload = await runAiApplicationPreviewAction({
+ actionType,
+ applicationPreview: normalizedPreview,
+ currentUser: currentUser.value || {},
+ conversationId: conversationId.value,
+ draftPayload: targetMessage.draftPayload || options.draftPayload || null
+ })
+ const draftPayload = extractInlineApplicationDraftPayload(payload)
+ if (draftPayload) {
+ targetMessage.draftPayload = draftPayload
+ }
+ targetMessage.suggestedActions = []
+ replaceInlineMessage(
+ pendingMessage.id,
+ createInlineMessage('assistant', buildInlineApplicationPreviewActionResultText(actionType, payload), {
+ id: pendingMessage.id,
+ stewardPlan: {
+ streamStatus: 'completed',
+ thinkingEvents: completeInlineThinkingEvents(resolveInlineThinkingEvents(pendingMessage))
+ },
+ suggestedActions: isSubmit ? buildInlineApplicationDetailAction(draftPayload) : []
+ })
+ )
+ persistCurrentConversation()
+ scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
+ return true
+ } catch (error) {
+ replaceInlineMessage(
+ pendingMessage.id,
+ createInlineMessage('assistant', error?.message || (isSubmit ? '申请提交失败,请稍后重试。' : '申请草稿保存失败,请稍后重试。'), {
+ id: pendingMessage.id,
+ stewardPlan: {
+ streamStatus: 'failed',
+ thinkingEvents: resolveInlineThinkingEvents(pendingMessage).map((item) => ({
+ ...item,
+ status: 'failed'
+ }))
+ }
+ })
+ )
+ toast(error?.message || (isSubmit ? '申请提交失败。' : '申请草稿保存失败。'))
+ persistCurrentConversation()
+ return true
+ } finally {
+ sending.value = false
+ scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
+ }
+}
+
+function handleInlineApplicationPreviewTextAction(prompt) {
+ if (applicationPreviewEstimatePending.value) {
+ toast('请等待费用测算完成后再继续操作。')
+ return true
+ }
+ const actionType = resolveInlineApplicationPreviewActionFromText(prompt)
+ if (!actionType || !resolveLatestApplicationPreviewMessage()) {
+ return false
+ }
+ void executeInlineApplicationPreviewAction(actionType, null, { userText: prompt })
+ return true
}
function normalizeStreamThinkingEvent(event = {}) {
@@ -1340,6 +1936,23 @@ function parseAiDocumentDetailHref(href = '') {
}
}
+function parseAiApplicationDetailHref(href = '') {
+ const value = String(href || '').trim()
+ if (!value.startsWith(AI_APPLICATION_DETAIL_HREF_PREFIX)) {
+ return null
+ }
+ const encodedReference = value.slice(AI_APPLICATION_DETAIL_HREF_PREFIX.length)
+ if (!encodedReference) {
+ return null
+ }
+ try {
+ const reference = decodeURIComponent(encodedReference).trim()
+ return reference ? { reference } : null
+ } catch {
+ return { reference: encodedReference }
+ }
+}
+
function buildAiDocumentDetailRequest(detailReference = {}) {
const reference = String(detailReference.reference || '').trim()
const isApplication = /^APP?-/i.test(reference)
@@ -1350,6 +1963,7 @@ function buildAiDocumentDetailRequest(detailReference = {}) {
documentNo: reference,
documentType: isApplication ? 'application' : 'reimbursement',
documentTypeCode: isApplication ? 'application' : 'reimbursement',
+ detailLookupOnly: true,
source: 'workbench',
returnTo: 'workbench'
}
@@ -1357,11 +1971,12 @@ function buildAiDocumentDetailRequest(detailReference = {}) {
function handleAiAnswerMarkdownClick(event) {
const target = event?.target
- const link = target?.closest?.('a[href^="#ai-open-document-detail:"]')
+ const link = target?.closest?.('a[href^="#ai-open-document-detail:"], a[href^="#ai-open-application-detail:"]')
if (!link) {
return
}
- const detailReference = parseAiDocumentDetailHref(link.getAttribute('href'))
+ const href = link.getAttribute('href')
+ const detailReference = parseAiDocumentDetailHref(href) || parseAiApplicationDetailHref(href)
if (!detailReference) {
return
}
@@ -1586,12 +2201,11 @@ async function requestInlineAssistantReply(prompt, entry = {}, files = []) {
thinkingEvents: nextThinkingEvents,
streamStatus: 'completed'
},
- suggestedActions: requiredApplicationContinuationFlow ? [] : buildStewardSuggestedActions(plan)
+ suggestedActions: requiredApplicationContinuationFlow
+ ? buildAiRequiredApplicationGateSuggestedActions(requiredApplicationContinuationFlow, prompt)
+ : buildStewardSuggestedActions(plan)
})
)
- if (continueAiRequiredApplicationGateFromPlan(normalizedPlan, prompt)) {
- shouldAutoScrollOnFinish = inlineConversationAutoScrollPinned.value
- }
persistCurrentConversation()
} catch (error) {
shouldAutoScrollOnFinish = inlineConversationAutoScrollPinned.value
@@ -1621,6 +2235,10 @@ async function requestInlineAssistantReply(prompt, entry = {}, files = []) {
}
function startInlineConversation(prompt, entry = {}, files = []) {
+ if (isAiModeInputLocked.value) {
+ toast('请等待费用测算完成后再继续操作。')
+ return
+ }
const cleanPrompt = buildInlinePromptText(prompt, files)
if (!cleanPrompt || sending.value) {
return
@@ -1631,6 +2249,10 @@ function startInlineConversation(prompt, entry = {}, files = []) {
return
}
+ if (handleInlineApplicationPreviewTextAction(cleanPrompt)) {
+ return
+ }
+
if (conversationId.value === AI_SEARCH_CONVERSATION_ID) {
conversationId.value = ''
conversationMessages.value = []
@@ -1725,7 +2347,7 @@ function regenerateLastReply() {
void requestInlineAssistantReply(lastUserMessage.content, { source: 'workbench', sessionType: 'steward' }, [])
}
-function handleInlineSuggestedAction(action = {}) {
+function handleInlineSuggestedAction(action = {}, sourceMessage = null) {
const prefillText = resolveSuggestedActionPrefill(action)
if (prefillText) {
assistantDraft.value = mergeComposerPrefill(assistantDraft.value, prefillText)
@@ -1735,6 +2357,23 @@ function handleInlineSuggestedAction(action = {}) {
const actionType = String(action?.action_type || '').trim()
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
+ if ([AI_APPLICATION_ACTION_SAVE_DRAFT, AI_APPLICATION_ACTION_SUBMIT].includes(actionType)) {
+ if (isInlineSuggestedActionDisabled(action, sourceMessage)) {
+ toast('请等待费用测算完成后再继续操作。')
+ return
+ }
+ void executeInlineApplicationPreviewAction(actionType, sourceMessage, {
+ userText: action.label,
+ draftPayload: actionPayload.draftPayload || actionPayload.draft_payload || null
+ })
+ return
+ }
+ if (actionType === 'open_application_detail') {
+ const claimNo = String(actionPayload.claim_no || actionPayload.claimNo || '').trim()
+ const claimId = String(actionPayload.claim_id || actionPayload.claimId || '').trim()
+ emit('open-document', buildAiDocumentDetailRequest({ reference: claimNo || claimId }))
+ return
+ }
if (actionPayload.steward_confirm_flow && actionPayload.flow_id === 'travel_reimbursement') {
const expenseType = String(actionPayload.expense_type || 'travel').trim() || 'travel'
const expenseTypeLabel = String(actionPayload.expense_type_label || '差旅费').trim() || '差旅费'
@@ -1814,6 +2453,14 @@ function pushInlineUserMessage(text) {
conversationMessages.value.push(createInlineMessage('user', String(text || '').trim()))
}
+function pushInlineApplicationActionUserMessage(text) {
+ pushInlineUserMessage(text)
+ assistantDraft.value = ''
+ removeWorkbenchDateTag()
+ closeWorkbenchDatePicker()
+ clearAiModeFiles()
+}
+
function resolveLatestInlineUserPrompt() {
const latestUserMessage = [...conversationMessages.value].reverse().find((message) => message.role === 'user')
return String(latestUserMessage?.content || '').trim()
@@ -1915,8 +2562,8 @@ async function resolveAiExpenseApplicationLink(expenseType, expenseTypeLabel) {
if (!candidates.length) {
conversationMessages.value.push(createInlineMessage('assistant', buildRequiredApplicationMissingText(expenseType), {
suggestedActions: [{
- label: '在当前对话里发起申请',
- description: '逐项收集出差申请要点,整理后你可以提交到申请助手',
+ label: '确认发起出差申请',
+ description: '生成完整申请表,并预填已识别的时间、地点和事由',
icon: 'mdi mdi-file-plus-outline',
action_type: 'ai_application_start_inline',
payload: {
@@ -1982,16 +2629,62 @@ async function startAiApplicationPreview(expenseType, expenseTypeLabel, sourceTe
if (options.pushUserMessage !== false) {
pushInlineUserMessage(options.userMessage || '确认发起出差申请')
}
- const preview = await refreshApplicationPreviewEstimate(
- buildInlineApplicationPreview(expenseTypeLabel || expenseType, previewSourceText)
- )
- const content = buildLocalApplicationPreviewMessage(preview)
- conversationMessages.value.push(createInlineMessage('assistant', content, {
- applicationPreview: preview,
- text: content
- }))
+
+ const pendingMessage = createInlineMessage('assistant', '正在生成申请核对表,请稍等...', {
+ pending: true,
+ stewardPlan: {
+ streamStatus: 'streaming',
+ thinkingEvents: [
+ {
+ eventId: 'application-preview-build',
+ title: '整理申请表字段',
+ content: '正在把已识别的时间、地点、事由和差旅类型整理成可编辑表格。',
+ status: 'running'
+ },
+ {
+ eventId: 'application-preview-estimate',
+ title: '同步费用测算',
+ content: '正在刷新差旅规则和费用测算,完成后会直接展示核对表。',
+ status: 'pending'
+ }
+ ]
+ }
+ })
+ conversationMessages.value.push(pendingMessage)
persistCurrentConversation()
scrollInlineConversationToBottom()
+
+ try {
+ const preview = await refreshApplicationPreviewEstimate(
+ buildInlineApplicationPreview(expenseTypeLabel || expenseType, previewSourceText)
+ )
+ const content = buildLocalApplicationPreviewMessage(preview)
+ replaceInlineMessage(pendingMessage.id, createInlineMessage('assistant', content, {
+ id: pendingMessage.id,
+ applicationPreview: preview,
+ suggestedActions: buildInlineApplicationPreviewSuggestedActions(preview),
+ stewardPlan: {
+ streamStatus: 'completed',
+ thinkingEvents: completeInlineThinkingEvents(resolveInlineThinkingEvents(pendingMessage))
+ },
+ text: content
+ }))
+ } catch (error) {
+ replaceInlineMessage(pendingMessage.id, createInlineMessage('assistant', error?.message || '申请核对表生成失败,请稍后重试。', {
+ id: pendingMessage.id,
+ stewardPlan: {
+ streamStatus: 'failed',
+ thinkingEvents: resolveInlineThinkingEvents(pendingMessage).map((item) => ({
+ ...item,
+ status: 'failed'
+ }))
+ }
+ }))
+ toast(error?.message || '申请核对表生成失败。')
+ } finally {
+ persistCurrentConversation()
+ scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
+ }
}
function requestDeleteCurrentConversation() {
@@ -2038,6 +2731,10 @@ function markInlineMessageFeedback(message, feedback) {
}
function triggerAiModeFileUpload() {
+ if (isAiModeInputLocked.value) {
+ toast('请等待费用测算完成后再继续操作。')
+ return
+ }
fileInputRef.value?.click()
}
@@ -2057,6 +2754,10 @@ function clearAiModeFiles() {
}
function handleVoiceInput() {
+ if (isAiModeInputLocked.value) {
+ toast('请等待费用测算完成后再继续操作。')
+ return
+ }
toast('语音输入正在准备中,您可以先输入文字需求。')
focusAiModeInput()
}
diff --git a/web/src/composables/useAppShell.js b/web/src/composables/useAppShell.js
index 8bc3401..fb25908 100644
--- a/web/src/composables/useAppShell.js
+++ b/web/src/composables/useAppShell.js
@@ -158,6 +158,10 @@ export function useAppShell() {
].some((value) => String(value || '').trim() === normalizedId)
}
+ function isDetailLookupOnlyPayload(payload = {}) {
+ return Boolean(payload?.detailLookupOnly || payload?.detail_lookup_only)
+ }
+
function resolveRequestDetailLookupId(requestOrId = selectedRequestSnapshot.value) {
if (typeof requestOrId === 'string') {
return requestOrId.trim()
@@ -168,6 +172,8 @@ export function useAppShell() {
|| requestOrId?.id
|| requestOrId?.claimNo
|| requestOrId?.claim_no
+ || requestOrId?.documentNo
+ || requestOrId?.document_no
|| ''
).trim()
}
@@ -564,13 +570,18 @@ export function useAppShell() {
}
function openRequestDetail(request, options = {}) {
- selectedRequestSnapshot.value = request || null
+ const requestId = resolveRequestDetailLookupId(request)
+ if (!requestId) {
+ return
+ }
+ const isDetailLookupOnlyRequest = isDetailLookupOnlyPayload(request)
+ selectedRequestSnapshot.value = isDetailLookupOnlyRequest ? null : request || null
router.push({
name: 'app-document-detail',
- params: { requestId: request.claimId || request.id },
+ params: { requestId },
query: buildDocumentDetailQuery(options)
})
- void refreshSelectedRequestDetail(request)
+ void refreshSelectedRequestDetail(isDetailLookupOnlyRequest ? requestId : request)
}
function closeRequestDetail() {
diff --git a/web/src/composables/useSystemState.js b/web/src/composables/useSystemState.js
index 513cf8d..efefa94 100644
--- a/web/src/composables/useSystemState.js
+++ b/web/src/composables/useSystemState.js
@@ -15,7 +15,7 @@ import { resolveDefaultAuthorizedRoute } from '../utils/accessControl.js'
import { useToast } from './useToast.js'
import { fetchSettings } from '../services/settings.js'
import { setThemeSkin } from './useThemeSkin.js'
-import { normalizeAuthUserSnapshot } from '../utils/authUser.js'
+import { normalizeAuthUserSnapshot, resolveAuthUserAdminFlag } from '../utils/authUser.js'
import {
clearAuthSessionMetrics,
finalizeAuthSession,
@@ -142,18 +142,7 @@ function buildLegacyAdminUser(username = '') {
}
function resolvePlatformAdminFlag(payload, roleCodes = []) {
- const username = String(payload?.username || payload?.account || '').trim().toLowerCase()
- const role = String(payload?.role || '').trim().toLowerCase()
- const normalizedRoleCodes = roleCodes.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean)
-
- return (
- Boolean(payload?.isAdmin)
- || username === 'admin'
- || role === 'admin'
- || role === '管理员'
- || role === '系统管理员'
- || normalizedRoleCodes.includes('admin')
- )
+ return resolveAuthUserAdminFlag(payload, roleCodes)
}
function normalizeStoredAuthUser(payload = {}) {
diff --git a/web/src/services/aiApplicationPreviewActions.js b/web/src/services/aiApplicationPreviewActions.js
index 079f87c..f5f35bb 100644
--- a/web/src/services/aiApplicationPreviewActions.js
+++ b/web/src/services/aiApplicationPreviewActions.js
@@ -1,3 +1,4 @@
+import { apiRequest } from './api.js'
import { runOrchestrator } from './orchestrator.js'
import {
buildApplicationPreviewRows,
@@ -126,11 +127,20 @@ export function buildAiApplicationPreviewActionPayload({
}
export function runAiApplicationPreviewAction(params = {}, options = {}) {
- return runOrchestrator(buildAiApplicationPreviewActionPayload(params), {
- timeoutMs: params.actionType === AI_APPLICATION_ACTION_SUBMIT ? 120000 : 75000,
- timeoutMessage: params.actionType === AI_APPLICATION_ACTION_SUBMIT
- ? '申请提交处理超时,请稍后重试。'
- : '申请草稿保存超时,请稍后重试。',
+ const payload = buildAiApplicationPreviewActionPayload(params)
+ if (params.actionType === AI_APPLICATION_ACTION_SUBMIT) {
+ return apiRequest('/reimbursements/application-preview-action', {
+ method: 'POST',
+ body: JSON.stringify(payload),
+ timeoutMs: 45000,
+ timeoutMessage: '申请提交处理超时,请稍后重试。',
+ ...options
+ })
+ }
+
+ return runOrchestrator(payload, {
+ timeoutMs: 75000,
+ timeoutMessage: '申请草稿保存超时,请稍后重试。',
...options
})
}
diff --git a/web/src/services/api.js b/web/src/services/api.js
index 8ed107d..6175e25 100644
--- a/web/src/services/api.js
+++ b/web/src/services/api.js
@@ -1,4 +1,4 @@
-import { normalizeAuthUserSnapshot } from '../utils/authUser.js'
+import { normalizeAuthUserSnapshot, resolveAuthUserAdminFlag } from '../utils/authUser.js'
const API_BASE_STORAGE_KEY = 'x-financial-api-base-url'
const AUTH_USER_STORAGE_KEY = 'x-financial-auth-user'
@@ -49,7 +49,7 @@ function readCurrentUserHeaders() {
const username = user.username
const name = user.name || username
const roleCodes = user.roleCodes
- const isAdmin = resolveStoredUserAdminFlag(payload, roleCodes)
+ const isAdmin = resolveAuthUserAdminFlag(payload, roleCodes)
const department = user.department || user.departmentName
const costCenter = user.costCenter
const position = user.position
@@ -112,22 +112,7 @@ function readCurrentUserHeaders() {
}
}
-function resolveStoredUserAdminFlag(payload, roleCodes = []) {
- const username = String(payload?.username || payload?.account || '').trim().toLowerCase()
- const role = String(payload?.role || '').trim().toLowerCase()
- const normalizedRoleCodes = roleCodes.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean)
-
- return (
- Boolean(payload?.isAdmin)
- || username === 'admin'
- || role === 'admin'
- || role === '管理员'
- || role === '系统管理员'
- || normalizedRoleCodes.includes('admin')
- )
-}
-
-function normalizeApiBaseUrl(value) {
+function normalizeApiBaseUrl(value) {
return String(value || '/api/v1').replace(/\/$/, '')
}
diff --git a/web/src/utils/accessControl.js b/web/src/utils/accessControl.js
index 47cf3a9..563c485 100644
--- a/web/src/utils/accessControl.js
+++ b/web/src/utils/accessControl.js
@@ -1,3 +1,5 @@
+import { resolveAuthUserAdminFlag } from './authUser.js'
+
export const DEFAULT_APP_VIEW_ORDER = [
'workbench',
'documents',
@@ -81,18 +83,7 @@ function hasPlatformAdminIdentity(user) {
return false
}
- const username = String(user.username || user.account || '').trim().toLowerCase()
- const role = String(user.role || '').trim().toLowerCase()
- const roleCodes = normalizedRoleCodes(user)
-
- return (
- Boolean(user.isAdmin)
- || username === 'admin'
- || role === 'admin'
- || role === '管理员'
- || role === '系统管理员'
- || roleCodes.includes('admin')
- )
+ return resolveAuthUserAdminFlag(user, normalizedRoleCodes(user))
}
export function isManagerUser(user) {
diff --git a/web/src/utils/aiApplicationPrecheckModel.js b/web/src/utils/aiApplicationPrecheckModel.js
index 9fdd174..568b169 100644
--- a/web/src/utils/aiApplicationPrecheckModel.js
+++ b/web/src/utils/aiApplicationPrecheckModel.js
@@ -159,6 +159,10 @@ function isBlockingPrecheck(precheck = {}) {
return precheck?.overlap?.status === 'warning'
}
+export function isAiApplicationPrecheckBlocking(precheck = {}) {
+ return isBlockingPrecheck(precheck)
+}
+
function buildOverlapMatchTable(matches = []) {
const rows = Array.isArray(matches) ? matches : []
if (!rows.length) {
@@ -343,3 +347,32 @@ export function buildAiApplicationPrecheckMessage(preview = {}, precheck = {}) {
return lines.join('\n')
}
+
+export function buildAiApplicationSubmitConflictMessage(preview = {}, precheck = {}) {
+ const matchTable = buildOverlapMatchTable(precheck?.overlap?.matches)
+ const normalized = normalizeApplicationPreview(preview)
+ const fields = normalized.fields || {}
+ const currentRange = resolveDateRange(fields.time, fields.days)
+ const currentRangeText = currentRange
+ ? `${currentRange.startText} 至 ${currentRange.endText}`
+ : normalizeText(fields.time) || '待确认'
+ const lines = [
+ '### 发现相同日期已有申请单',
+ '',
+ '**我已完成提交前的单据重叠核查**,发现相同或重叠日期已有差旅申请单,当前不能继续提交。',
+ '',
+ `> **相同日期提醒**:${precheck?.overlap?.summary || '发现相同日期已有申请单,请先核对后再提交。'}`,
+ '',
+ `> **本次申请时间**:${currentRangeText}`,
+ ]
+ if (matchTable) {
+ lines.push('', matchTable)
+ }
+ lines.push(
+ '',
+ '> **请先核对**:请先核对申请时间是否填写正确。若日期填错,请直接回复正确的出发时间和返回时间,我会重新查询;若日期无误,请先查看或处理已有申请单,避免重复申请。',
+ '',
+ '我会先暂停本次提交,不会生成新的审批流。'
+ )
+ return lines.join('\n')
+}
diff --git a/web/src/utils/authUser.js b/web/src/utils/authUser.js
index 43fdf89..ea0d96f 100644
--- a/web/src/utils/authUser.js
+++ b/web/src/utils/authUser.js
@@ -8,10 +8,45 @@ function pickText(payload = {}, keys = [], fallback = '') {
return String(fallback || '').trim()
}
+const PLATFORM_ADMIN_IDENTITIES = new Set(['admin', 'superadmin'])
+const PLATFORM_ADMIN_ROLES = new Set(['admin', 'superadmin', '管理员', '系统管理员'])
+
+function isTruthyAdminFlag(value) {
+ if (value === true) {
+ return true
+ }
+
+ if (typeof value === 'number') {
+ return value === 1
+ }
+
+ return ['1', 'true', 'yes', 'on'].includes(String(value || '').trim().toLowerCase())
+}
+
function normalizeRoleCodes(payload = {}) {
- return Array.isArray(payload.roleCodes)
- ? payload.roleCodes.map((item) => String(item || '').trim()).filter(Boolean)
- : []
+ const rawRoleCodes = Array.isArray(payload.roleCodes)
+ ? payload.roleCodes
+ : Array.isArray(payload.role_codes)
+ ? payload.role_codes
+ : typeof payload.roleCodes === 'string'
+ ? payload.roleCodes.split(',')
+ : []
+
+ return rawRoleCodes.map((item) => String(item || '').trim()).filter(Boolean)
+}
+
+export function resolveAuthUserAdminFlag(payload = {}, roleCodes = []) {
+ const username = String(payload?.username || payload?.account || '').trim().toLowerCase()
+ const role = String(payload?.role || '').trim().toLowerCase()
+ const normalizedRoleCodes = roleCodes.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean)
+
+ return (
+ isTruthyAdminFlag(payload?.isAdmin)
+ || isTruthyAdminFlag(payload?.is_admin)
+ || PLATFORM_ADMIN_IDENTITIES.has(username)
+ || PLATFORM_ADMIN_ROLES.has(role)
+ || normalizedRoleCodes.some((item) => PLATFORM_ADMIN_IDENTITIES.has(item))
+ )
}
export function normalizeAuthUserSnapshot(payload = {}, defaults = {}) {
@@ -47,6 +82,7 @@ export function normalizeAuthUserSnapshot(payload = {}, defaults = {}) {
'leaderName',
'leader_name'
])
+ const roleCodes = normalizeRoleCodes(payload)
return {
username,
@@ -62,9 +98,9 @@ export function normalizeAuthUserSnapshot(payload = {}, defaults = {}) {
costCenter,
financeOwnerName,
riskProfile: payload.riskProfile && typeof payload.riskProfile === 'object' ? payload.riskProfile : {},
- roleCodes: normalizeRoleCodes(payload),
+ roleCodes,
email: pickText(payload, ['email'], username),
avatar: pickText(payload, ['avatar'], name.slice(0, 1).toUpperCase()),
- isAdmin: Boolean(payload.isAdmin)
+ isAdmin: resolveAuthUserAdminFlag(payload, roleCodes)
}
}
diff --git a/web/src/views/AppShellRouteView.vue b/web/src/views/AppShellRouteView.vue
index 9d111cc..f3b55a5 100644
--- a/web/src/views/AppShellRouteView.vue
+++ b/web/src/views/AppShellRouteView.vue
@@ -4,8 +4,7 @@
:class="{
'sidebar-collapsed': sidebarCollapsed,
'workbench-ai-sidebar-active': isAiShellMode,
- 'mobile-sidebar-open': mobileSidebarOpen,
- 'login-entry-active': loginEntryAnimating
+ 'mobile-sidebar-open': mobileSidebarOpen
}"
>
@@ -18,17 +17,6 @@
>
-
-
-
-
-