Files
X-Financial/web/src/views/TravelReimbursementCreateView.vue
caoxiaozhu e7bef0883d feat: 新增预算后端服务与差旅风险规则库
后端新增预算模型、端点和服务模块,支持预算 CRUD 和余额
查询,清理旧生成规则文件并替换为按严重等级分类的差旅风
险规则库,优化认证权限和报销单访问策略,新增财务规则目
录和演示数据构建脚本,前端预算中心增加对话框交互,完善
审计页面运行时模型和元数据展示,补充单元测试。
2026-05-26 17:29:35 +08:00

1479 lines
78 KiB
Vue
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.
<template>
<Teleport to="body">
<Transition name="assistant-modal" @after-enter="handleAssistantModalAfterEnter" @after-leave="emitCloseAfterLeave">
<div v-if="workbenchVisible" class="assistant-overlay">
<section class="assistant-modal">
<div class="assistant-header-actions">
<button
type="button"
class="assistant-toggle-btn"
:class="{ disabled: !hasInsightPanelContent }"
:disabled="!hasInsightPanelContent || sessionSwitchBusy"
:title="insightPanelToggleLabel"
:aria-label="insightPanelToggleLabel"
@click="toggleInsightPanel"
>
<i :class="showInsightPanel ? 'mdi mdi-arrow-collapse-right' : 'mdi mdi-arrow-expand-right'"></i>
</button>
<button
type="button"
class="session-trash-btn"
:disabled="!canDeleteCurrentSession || submitting || reviewActionBusy || deleteSessionBusy || sessionSwitchBusy"
title="删除当前会话"
aria-label="删除当前会话"
@click="openDeleteSessionDialog"
>
<i class="mdi mdi-delete-outline"></i>
</button>
<button
class="assistant-close-btn"
type="button"
title="关闭工作台"
aria-label="关闭对话工作台"
@click="requestCloseWorkbench"
>
<i class="mdi mdi-close"></i>
</button>
</div>
<div class="assistant-modal-stage">
<header class="assistant-header">
<div class="assistant-header-main">
<div>
<h2>{{ assistantHeaderTitle }}</h2>
<p>{{ assistantHeaderDescription }}</p>
</div>
</div>
</header>
<div
class="assistant-layout"
:class="{
'can-show-insight': hasInsightPanelContent,
'has-insight': hasInsightPanelContent && showInsightPanel
}"
>
<section class="dialog-panel">
<div v-if="shortcuts.length" class="dialog-toolbar">
<button
v-for="shortcut in shortcuts"
:key="shortcut.label"
type="button"
class="shortcut-chip"
:class="{ active: shortcut.active }"
:aria-pressed="shortcut.active ? 'true' : 'false'"
:disabled="shortcut.active || submitting || reviewActionBusy || deleteSessionBusy || sessionSwitchBusy"
@click="runShortcut(shortcut)"
>
<i :class="shortcut.icon"></i>
<span>{{ shortcut.label }}</span>
</button>
</div>
<div ref="messageListRef" class="message-list" aria-live="polite">
<article
v-for="message in messages"
:key="message.id"
class="message-row"
:class="message.role"
>
<span class="message-avatar">
<img
:src="message.role === 'assistant' ? aiAvatar : userAvatar"
:alt="message.role === 'assistant' ? '财务助手头像' : '用户头像'"
/>
</span>
<div class="message-bubble" :class="buildMessageBubbleClass(message)">
<header class="message-meta">
<strong>{{ message.role === 'assistant' ? (message.assistantName || ASSISTANT_DISPLAY_NAME) : '我' }}</strong>
<time>{{ message.time }}</time>
</header>
<div
v-if="message.text && message.role === 'assistant' && message.reviewPayload && buildReviewMainMessageText(message)"
class="review-summary message-answer-content message-answer-markdown"
v-html="renderMarkdown(buildReviewMainMessageText(message))"
@click="handleAssistantMarkdownClick($event, message)"
></div>
<div
v-else-if="message.text && message.role !== 'assistant'"
class="message-answer-content message-answer-markdown message-rich-text"
v-html="renderMarkdown(message.text)"
></div>
<div
v-else-if="message.text && message.role === 'assistant'"
class="message-answer-content message-answer-markdown"
v-html="renderMarkdown(message.text)"
@click="handleAssistantMarkdownClick($event, message)"
></div>
<div
v-if="message.role === 'assistant' && message.applicationPreview"
class="application-preview-table"
role="table"
aria-label="申请信息核对表"
>
<div class="application-preview-row head" role="row">
<span role="columnheader">字段</span>
<span role="columnheader">内容</span>
</div>
<div
v-for="row in resolveApplicationPreviewRows(message)"
:key="`${message.id}-${row.key}`"
class="application-preview-row"
:class="{
missing: row.missing,
editable: row.editable && !submitting && !reviewActionBusy && !sessionSwitchBusy,
highlight: row.highlight
}"
role="row"
:tabindex="row.editable && !submitting && !reviewActionBusy && !sessionSwitchBusy ? 0 : -1"
:aria-label="row.editable ? `编辑${row.label}` : row.label"
@click="row.editable && !isApplicationPreviewEditing(message, row.key) && !submitting && !reviewActionBusy && !sessionSwitchBusy && openApplicationPreviewEditor(message, row.key, row.value)"
@keydown.enter.prevent="row.editable && !isApplicationPreviewEditing(message, row.key) && !submitting && !reviewActionBusy && !sessionSwitchBusy && openApplicationPreviewEditor(message, row.key, row.value)"
@keydown.space.prevent="row.editable && !isApplicationPreviewEditing(message, row.key) && !submitting && !reviewActionBusy && !sessionSwitchBusy && openApplicationPreviewEditor(message, row.key, row.value)"
>
<span class="application-preview-label" role="cell">{{ row.label }}</span>
<span class="application-preview-value" role="cell">
<input
v-if="isApplicationPreviewEditing(message, row.key) && resolveApplicationPreviewEditorControl(row.key) !== 'select'"
v-model="applicationPreviewEditor.draftValue"
class="application-preview-input"
type="text"
autofocus
@click.stop
@keydown.stop="handleApplicationPreviewEditorKeydown($event, message)"
@blur="commitApplicationPreviewEditor(message)"
/>
<select
v-else-if="isApplicationPreviewEditing(message, row.key)"
v-model="applicationPreviewEditor.draftValue"
class="application-preview-input application-preview-select"
autofocus
@click.stop
@change="commitApplicationPreviewEditor(message)"
@keydown.stop="handleApplicationPreviewEditorKeydown($event, message)"
@blur="commitApplicationPreviewEditor(message)"
>
<option value="">请选择</option>
<option
v-for="option in resolveApplicationPreviewEditorOptions(row.key)"
:key="option"
:value="option"
>
{{ option }}
</option>
</select>
<template v-else>
<span class="application-preview-text">{{ row.value }}</span>
<button
v-if="row.editable"
type="button"
class="application-preview-edit-btn"
title="修改内容"
aria-label="修改内容"
:disabled="submitting || reviewActionBusy || sessionSwitchBusy"
@click.stop="openApplicationPreviewEditor(message, row.key, row.value)"
>
<i class="mdi mdi-pencil-outline"></i>
</button>
</template>
</span>
</div>
</div>
<div
v-if="message.role === 'assistant' && message.applicationPreview && buildApplicationPreviewFooterText(message)"
class="application-preview-footer message-answer-content message-answer-markdown"
v-html="renderMarkdown(buildApplicationPreviewFooterText(message))"
@click="handleAssistantMarkdownClick($event, message)"
></div>
<div
v-if="message.role === 'assistant' && message.welcomeQuickActions?.length"
class="welcome-quick-actions"
>
<p class="welcome-quick-actions-title">您可以对我进行以下操作</p>
<div class="welcome-quick-action-grid">
<button
v-for="action in message.welcomeQuickActions"
:key="`${message.id}-${action.label}`"
type="button"
class="welcome-quick-action-btn"
:disabled="submitting || reviewActionBusy || sessionSwitchBusy"
@click="runWelcomeQuickAction(action)"
>
<i :class="action.icon"></i>
<span>{{ action.label }}</span>
</button>
</div>
</div>
<div v-if="message.role === 'assistant' && !message.reviewPayload && !message.queryPayload && message.meta?.length" class="message-meta-row">
<span
v-for="item in message.meta"
:key="item"
class="message-meta-chip"
:class="message.metaTone"
>
{{ item }}
</span>
</div>
<div
v-if="message.role === 'assistant' && !message.reviewPayload && !message.queryPayload && message.suggestedActions?.length"
class="message-suggested-actions"
>
<button
v-for="action in message.suggestedActions"
:key="`${message.id}-${action.action_type}-${action.label}`"
type="button"
class="message-suggested-action-btn"
:class="{
selected: isSuggestedActionSelected(message, action),
locked: message.suggestedActionsLocked
}"
:disabled="message.suggestedActionsLocked || submitting || reviewActionBusy || sessionSwitchBusy"
@click="handleSuggestedAction(message, action)"
>
<span class="message-suggested-action-icon" aria-hidden="true">
<i :class="action.icon || 'mdi mdi-shape-outline'"></i>
</span>
<span class="message-suggested-action-copy">
<span class="message-suggested-action-title">{{ action.label }}</span>
<small v-if="action.description">{{ action.description }}</small>
</span>
<i class="message-suggested-action-arrow mdi mdi-arrow-right" aria-hidden="true"></i>
</button>
</div>
<div v-if="message.role === 'assistant' && !message.reviewPayload && message.riskFlags?.length" class="message-detail-block">
<strong>风险标签</strong>
<div class="message-detail-chip-row">
<span v-for="item in message.riskFlags" :key="item" class="message-risk-chip">{{ item }}</span>
</div>
</div>
<details
v-if="message.role === 'assistant' && !message.reviewPayload && !message.queryPayload && message.citations?.length"
class="message-detail-block message-citation-disclosure"
>
<summary>
<strong>引用依据</strong>
<span>{{ message.citations.length }} </span>
<i class="mdi mdi-chevron-down"></i>
</summary>
<div class="message-citation-list">
<article v-for="item in message.citations" :key="`${message.id}-${item.code}`" class="message-citation-card">
<header>
<span>{{ item.title }}</span>
<small>{{ item.version || item.source_type }}</small>
</header>
<p>{{ item.excerpt || item.code }}</p>
</article>
</div>
</details>
<div
v-if="message.role === 'assistant' && !message.reviewPayload && message.queryPayload?.resultType === 'expense_claim_list'"
class="message-detail-block expense-query-block"
>
<strong>
{{ message.queryPayload.title || (message.queryPayload.selectionMode === 'draft_association' ? '选择关联草稿' : '最近 5 条筛选结果') }}
</strong>
<p v-if="buildExpenseQueryWindowLabel(message.queryPayload)" class="expense-query-window-label">
{{ buildExpenseQueryWindowLabel(message.queryPayload) }}
</p>
<div v-if="message.queryPayload.statusGroups?.length" class="expense-query-summary-row">
<span
v-for="item in message.queryPayload.statusGroups"
:key="`${message.id}-${item.key}`"
class="expense-query-summary-chip"
:class="item.key"
>
{{ item.label }} {{ item.count }}
</span>
</div>
<div v-if="message.queryPayload.records?.length" class="expense-query-record-list compact">
<button
v-for="record in getExpenseQueryVisibleRecords(message.queryPayload)"
:key="`${message.id}-${record.claimId}`"
type="button"
class="expense-query-record-card"
:class="{
selectable: message.queryPayload.selectionMode === 'draft_association',
selected: message.selectedQueryRecordId === record.claimId || message.queryPayload.selectedClaimId === record.claimId,
locked: message.querySelectionLocked || message.queryPayload.selectionLocked
}"
:disabled="message.queryPayload.selectionMode === 'draft_association' && (message.querySelectionLocked || message.queryPayload.selectionLocked)"
@click="handleExpenseQueryRecordClick(message, record)"
>
<div class="expense-query-record-main">
<div class="expense-query-record-top">
<strong>{{ record.claimNo }}</strong>
<span class="expense-query-record-status" :class="record.statusGroup || 'other'">
{{ record.statusLabel }}
</span>
</div>
<p>{{ record.summary }}</p>
<div class="expense-query-record-meta">
<span>{{ record.expenseTypeLabel }}</span>
<span>{{ record.dateDisplay }}</span>
<span>{{ record.amountDisplay }}</span>
</div>
<div v-if="record.riskItems?.length" class="expense-query-risk-row">
<button
v-for="risk in record.riskItems"
:key="`${message.id}-${record.claimId}-${risk.key}`"
type="button"
class="expense-query-risk-chip"
:class="risk.level"
@click.stop="appendExpenseQueryRiskToConversation(record, risk)"
>
<span>{{ record.claimNo }}</span>
<strong>{{ risk.levelLabel }}</strong>
<em>{{ risk.title }}</em>
</button>
</div>
</div>
<i class="mdi mdi-chevron-right"></i>
</button>
<div
v-if="getExpenseQueryTotalPages(message.queryPayload) > 1"
class="expense-query-pager"
>
<button
type="button"
class="expense-query-pager-btn"
:disabled="getExpenseQueryActivePage(message.queryPayload) === 1"
aria-label="上一页"
@click="shiftExpenseQueryPage(message, -1)"
>
<i class="mdi mdi-chevron-left"></i>
</button>
<div class="expense-query-pager-dots" aria-label="单据分页">
<button
v-for="page in getExpenseQueryTotalPages(message.queryPayload)"
:key="`${message.id}-query-page-${page}`"
type="button"
class="expense-query-pager-dot"
:class="{ active: getExpenseQueryActivePage(message.queryPayload) === page }"
:aria-label="` ${page} `"
@click="setExpenseQueryPage(message, page)"
></button>
</div>
<button
type="button"
class="expense-query-pager-btn"
:disabled="getExpenseQueryActivePage(message.queryPayload) === getExpenseQueryTotalPages(message.queryPayload)"
aria-label="下一页"
@click="shiftExpenseQueryPage(message, 1)"
>
<i class="mdi mdi-chevron-right"></i>
</button>
</div>
</div>
<div v-else class="expense-query-empty">
<i class="mdi mdi-file-search-outline"></i>
<span>{{ message.queryPayload.emptyText || '当前没有可直接展开的近期待办单据。' }}</span>
</div>
<p
v-if="buildExpenseQueryHint(message.queryPayload)"
class="expense-query-hint message-answer-markdown"
v-html="renderMarkdown(buildExpenseQueryHint(message.queryPayload))"
@click="handleAssistantMarkdownClick($event, message)"
>
</p>
</div>
<div v-if="message.role === 'assistant' && message.reviewPayload" class="message-detail-block review-message-block">
<div class="review-plain-followup">
<template
v-for="followup in [buildReviewPlainFollowupForMessage(message)]"
:key="`${message.id}-review-followup`"
>
<h3
class="review-plain-lead"
:class="{ danger: followup.tone === 'danger' }"
>
{{ followup.lead }}
</h3>
<p v-if="followup.summary" class="review-plain-summary">
{{ followup.summary }}
</p>
<ul v-if="followup.items.length" class="review-plain-list">
<li
v-for="item in followup.items"
:key="`${message.id}-${item.key}`"
>
<span class="review-plain-label">{{ item.label }}</span>
<span>{{ item.text }}</span>
</li>
</ul>
<p
v-for="line in followup.notes"
:key="`${message.id}-note-${line}`"
class="review-plain-note"
>
{{ line }}
</p>
<p v-if="canUseInlineSaveDraft(message)" class="review-inline-save-copy">
请核查上面的关键信息您也可以暂时不处理上述的这些内容我可以帮你先保存为
<button
type="button"
class="review-inline-draft-link"
:disabled="submitting || reviewActionBusy || sessionSwitchBusy"
@click="handleInlineSaveDraft(message)"
>
草稿
</button>
</p>
</template>
<div
v-if="buildReviewNextStepRichCopyForMessage(message)"
class="review-next-step-rich-copy message-answer-markdown"
v-html="renderMarkdown(buildReviewNextStepRichCopyForMessage(message))"
@click="handleAssistantMarkdownClick($event, message)"
></div>
<div
v-if="resolveReviewFooterActions(message.reviewPayload).length"
class="review-footer-actions"
>
<div class="review-footer-btn-row">
<button
v-for="action in resolveReviewFooterActions(message.reviewPayload)"
:key="`${message.id}-${action.action_type}`"
type="button"
:class="['review-footer-btn', action.emphasis === 'primary' ? 'primary' : '']"
:disabled="submitting || reviewActionBusy || sessionSwitchBusy"
@click="handleReviewAction(message, action)"
>
{{ action.label || buildReviewPrimaryButtonLabel(message.reviewPayload, message.draftPayload) }}
</button>
</div>
</div>
</div>
</div>
<div v-if="message.role === 'assistant' && !message.reviewPayload && message.draftPayload" class="draft-preview">
<header>
<strong>{{ message.draftPayload.title }}</strong>
<span>待人工确认</span>
</header>
<pre>{{ message.draftPayload.body }}</pre>
</div>
<div v-if="message.attachments?.length" class="message-files">
<span v-for="file in message.attachments" :key="file" class="file-chip">
<i class="mdi mdi-paperclip"></i>
{{ file }}
</span>
</div>
</div>
</article>
</div>
<form class="composer" @submit.prevent="submitComposer">
<input
ref="fileInputRef"
class="hidden-file-input"
type="file"
multiple
accept=".pdf,.jpg,.jpeg,.png,.webp,.doc,.docx,.xls,.xlsx"
@change="handleFilesChange"
/>
<div v-if="!isKnowledgeSession && attachedFiles.length" class="composer-files-panel">
<div class="composer-files-head">
<strong>已添加 {{ attachedFiles.length }} 份附件</strong>
<div class="composer-files-actions">
<button
v-if="hiddenAttachedFileCount"
type="button"
class="composer-file-link"
:disabled="submitting || reviewActionBusy"
@click="toggleAttachedFilesExpanded"
>
<i :class="composerFilesExpanded ? 'mdi mdi-chevron-up' : 'mdi mdi-chevron-down'"></i>
{{ composerFilesExpanded ? '收起附件' : `展开其余 ${hiddenAttachedFileCount}` }}
</button>
<button
type="button"
class="composer-file-link danger"
:disabled="submitting || reviewActionBusy"
@click="clearAttachedFiles"
>
清空
</button>
</div>
</div>
<div class="composer-file-chip-row">
<span v-for="file in visibleAttachedFiles" :key="file.name" class="file-chip active composer-file-chip">
<i class="mdi mdi-paperclip"></i>
<span class="file-chip-label" :title="file.name">{{ file.name }}</span>
<button
type="button"
class="file-chip-remove"
:disabled="submitting || reviewActionBusy"
:aria-label="`移除 ${file.name}`"
@click="removeAttachedFile(file)"
>
<i class="mdi mdi-close"></i>
</button>
</span>
<button
v-if="hiddenAttachedFileCount"
type="button"
class="file-chip active summary"
:disabled="submitting || reviewActionBusy"
@click="toggleAttachedFilesExpanded"
>
<i class="mdi mdi-file-multiple-outline"></i>
另外 {{ hiddenAttachedFileCount }}
</button>
</div>
<div v-if="composerFilesExpanded && hiddenAttachedFileCount" class="composer-files-expanded">
<article
v-for="file in attachedFiles.slice(visibleAttachedFiles.length)"
:key="`${file.name}-expanded`"
class="composer-expanded-file"
>
<div class="composer-expanded-file-copy">
<i class="mdi mdi-file-document-outline"></i>
<span :title="file.name">{{ file.name }}</span>
</div>
<button
type="button"
class="composer-expanded-file-remove"
:disabled="submitting || reviewActionBusy"
:aria-label="`移除 ${file.name}`"
@click="removeAttachedFile(file)"
>
<i class="mdi mdi-close"></i>
</button>
</article>
</div>
</div>
<div class="composer-row" :class="{ 'knowledge-mode': isKnowledgeSession }">
<div v-if="!isKnowledgeSession" class="composer-leading-actions">
<button
type="button"
class="tool-btn composer-side-btn"
:disabled="submitting || reviewActionBusy || sessionSwitchBusy"
aria-label="上传附件"
@click="triggerFileUpload"
>
<i class="mdi mdi-paperclip"></i>
</button>
<div class="composer-date-anchor">
<button
type="button"
class="tool-btn composer-side-btn"
:class="{ active: composerDatePickerOpen }"
:disabled="submitting || reviewActionBusy || sessionSwitchBusy"
aria-label="选择业务发生时间"
:aria-expanded="composerDatePickerOpen"
@click.stop="toggleComposerDatePicker"
>
<i class="mdi mdi-calendar-range"></i>
</button>
<div
v-if="composerDatePickerOpen"
class="composer-date-popover"
role="dialog"
aria-label="业务发生时间"
@click.stop
>
<div class="composer-date-mode-tabs">
<button
type="button"
class="composer-date-mode-btn"
:class="{ active: composerDateMode === 'single' }"
@click="setComposerDateMode('single')"
>
当天
</button>
<button
type="button"
class="composer-date-mode-btn"
:class="{ active: composerDateMode === 'range' }"
@click="setComposerDateMode('range')"
>
时间段
</button>
</div>
<div v-if="composerDateMode === 'single'" class="composer-date-fields">
<label class="composer-date-field">
<span>日期</span>
<input v-model="composerSingleDate" type="date" @change="handleComposerDateInputChange" />
</label>
</div>
<div v-else class="composer-date-fields composer-date-fields-range">
<label class="composer-date-field">
<span>开始</span>
<input v-model="composerRangeStartDate" type="date" @change="handleComposerDateInputChange" />
</label>
<span class="composer-date-range-sep"></span>
<label class="composer-date-field">
<span>结束</span>
<input
v-model="composerRangeEndDate"
type="date"
:min="composerRangeStartDate"
@change="handleComposerDateInputChange"
/>
</label>
</div>
<p v-if="composerDateMode === 'range' && !composerCanApplyDateSelection" class="composer-date-hint">
请确认结束日期不早于开始日期
</p>
<div class="composer-date-popover-actions">
<button type="button" class="composer-date-cancel-btn" @click="closeComposerDatePicker">
取消
</button>
<button
type="button"
class="composer-date-apply-btn"
:disabled="!composerCanApplyDateSelection"
@click="applyComposerDateSelection"
>
插入标签
</button>
</div>
</div>
</div>
<div v-if="canShowTravelCalculator" class="travel-calculator-anchor">
<button
type="button"
class="tool-btn composer-side-btn travel-calculator-trigger"
:class="{ active: travelCalculatorOpen }"
:disabled="submitting || reviewActionBusy || sessionSwitchBusy"
aria-label="差旅计算器"
title="差旅计算器"
:aria-expanded="travelCalculatorOpen"
@click.stop="toggleTravelCalculator"
>
<i class="mdi mdi-calculator"></i>
</button>
<div
v-if="travelCalculatorOpen"
class="travel-calculator-popover"
role="dialog"
aria-label="差旅计算器"
@click.stop
>
<div class="travel-calculator-mini-head">
<strong>差旅计算器</strong>
<span>按规则中心差旅表测算</span>
</div>
<div class="travel-calculator-form">
<label class="travel-calculator-field">
<span>实际天数</span>
<input
v-model="travelCalculatorForm.days"
type="number"
min="1"
step="1"
inputmode="numeric"
:disabled="travelCalculatorBusy"
@keydown.enter.prevent="submitTravelCalculator"
/>
</label>
<label class="travel-calculator-field">
<span>出差地点</span>
<input
v-model="travelCalculatorForm.location"
type="text"
placeholder="例如:北京、成都"
:disabled="travelCalculatorBusy"
@keydown.enter.prevent="submitTravelCalculator"
/>
</label>
</div>
<p v-if="travelCalculatorError" class="travel-calculator-error">
{{ travelCalculatorError }}
</p>
<div class="composer-date-popover-actions">
<button type="button" class="composer-date-cancel-btn" :disabled="travelCalculatorBusy" @click="closeTravelCalculator">
取消
</button>
<button
type="button"
class="composer-date-apply-btn"
:disabled="!travelCalculatorCanSubmit"
@click="submitTravelCalculator"
>
{{ travelCalculatorBusy ? '计算中...' : 'AI计算' }}
</button>
</div>
</div>
</div>
</div>
<div class="composer-shell">
<div class="composer-shell-body">
<span
v-for="tag in composerBusinessTimeTags"
:key="tag.id"
class="composer-biz-time-tag"
>
<i class="mdi mdi-calendar-check"></i>
<span class="composer-biz-time-tag-label">{{ tag.label }}</span>
<button
type="button"
class="composer-biz-time-tag-remove"
:disabled="submitting || reviewActionBusy || sessionSwitchBusy"
aria-label="移除业务发生时间"
@click="removeComposerBusinessTimeTag(tag.id)"
>
<i class="mdi mdi-close"></i>
</button>
</span>
<textarea
ref="composerTextareaRef"
v-model="composerDraft"
rows="1"
:placeholder="composerPlaceholder"
:disabled="submitting || reviewActionBusy || sessionSwitchBusy"
@input="handleComposerInput"
@keydown.enter.exact.prevent="handleComposerEnter"
@keydown.ctrl.enter.prevent="submitComposer"
/>
</div>
</div>
<button class="send-btn composer-side-btn" type="submit" :disabled="!canSubmit || reviewActionBusy || sessionSwitchBusy" aria-label="发送">
<i :class="submitting ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-send'"></i>
</button>
</div>
</form>
</section>
<div
v-if="hasInsightPanelContent"
class="insight-panel-shell"
:class="{ collapsed: !showInsightPanel }"
:aria-hidden="(!showInsightPanel).toString()"
>
<aside class="insight-panel">
<div
v-if="!isKnowledgeSession"
class="insight-head"
:class="{ 'review-mode': activeReviewPayload || isReviewFlowDrawer }"
>
<div>
<div v-if="!activeReviewPayload && !isReviewFlowDrawer" class="insight-head-eyebrow">
<span class="intent-pill" :class="currentInsight.intent">{{ currentIntentLabel }}</span>
</div>
<div v-else class="review-insight-title-row">
<div class="review-insight-title-copy">
<h3>{{ reviewDrawerTitle }}</h3>
</div>
</div>
<h3 v-if="!activeReviewPayload && !isReviewFlowDrawer">{{ currentInsight.title }}</h3>
<p v-if="!activeReviewPayload && !isReviewFlowDrawer">{{ currentInsight.summary }}</p>
</div>
<div v-if="activeReviewPayload || isReviewFlowDrawer" class="review-insight-tools">
<button
v-if="activeReviewPayload && reviewOverviewDrawerAvailable"
type="button"
class="review-insight-switch-icon-btn"
:class="{
available: true,
active: isReviewOverviewDrawer
}"
:disabled="submitting || reviewActionBusy"
title="报销识别核对"
aria-label="报销识别核对"
@click="switchToReviewOverviewDrawer"
>
<i :class="isReviewOverviewDrawer ? 'mdi mdi-clipboard-check' : 'mdi mdi-clipboard-check-outline'"></i>
</button>
<button
v-if="activeReviewPayload && reviewDocumentDrawerAvailable"
type="button"
class="review-insight-switch-icon-btn"
:class="{
available: reviewDocumentDrawerAvailable,
active: reviewDocumentDrawerAvailable && isReviewDocumentDrawer
}"
:disabled="submitting || reviewActionBusy"
title="单据识别"
aria-label="单据识别"
@click="toggleReviewDocumentDrawer"
>
<i :class="reviewDocumentDrawerIcon"></i>
</button>
<button
v-if="activeReviewPayload && reviewRiskDrawerAvailable"
type="button"
class="review-insight-switch-icon-btn risk"
:class="{
available: reviewRiskDrawerAvailable,
active: reviewRiskDrawerAvailable && isReviewRiskDrawer
}"
:disabled="submitting || reviewActionBusy"
title="显示风险"
aria-label="显示风险"
@click="toggleReviewRiskDrawer"
>
<i :class="reviewRiskDrawerIcon"></i>
</button>
<button
type="button"
class="review-insight-switch-icon-btn flow"
:class="{
available: reviewFlowDrawerAvailable,
active: reviewFlowDrawerAvailable && isReviewFlowDrawer,
running: flowOverallStatusTone === 'running'
}"
:disabled="!reviewFlowDrawerAvailable || submitting || reviewActionBusy"
title="调用流程"
aria-label="调用流程"
@click="toggleReviewFlowDrawer"
>
<i :class="reviewFlowDrawerIcon"></i>
</button>
</div>
<div class="confidence-card" v-if="!activeReviewPayload && !isReviewFlowDrawer">
<span>{{ currentInsight.metricLabel }}</span>
<strong>{{ currentInsight.metricValue }}</strong>
</div>
</div>
<Transition name="insight-switch" mode="out-in">
<div
:key="`${activeSessionType}-${currentInsight.intent}-${currentInsight.title}-${reviewDrawerMode}`"
class="insight-body"
:class="{ 'document-review-body': isReviewDocumentDrawer }"
>
<template v-if="isKnowledgeSession">
<section class="insight-card knowledge-hot-card">
<div class="card-head">
<h4>热门问题 Top 10</h4>
</div>
<div class="knowledge-question-list">
<button
v-for="(item, index) in hotKnowledgeQuestions"
:key="item"
type="button"
class="knowledge-question-btn"
:disabled="submitting || reviewActionBusy || deleteSessionBusy || sessionSwitchBusy"
@click="askHotKnowledgeQuestion(item)"
>
<span
class="knowledge-question-index"
:class="resolveKnowledgeRankTone(index)"
>
{{ resolveKnowledgeRankLabel(index) }}
</span>
<span class="knowledge-question-copy">{{ item }}</span>
<i class="mdi mdi-arrow-top-right"></i>
</button>
</div>
</section>
</template>
<template v-else-if="isReviewFlowDrawer">
<section class="review-flow-panel">
<div class="review-flow-summary">
<span class="flow-status-chip" :class="flowOverallStatusTone">{{ flowOverallStatusText }}</span>
<span>总耗时 {{ flowTotalDurationText }}</span>
<button
type="button"
class="flow-icon-btn"
:disabled="!flowRunId || flowRefreshBusy"
title="刷新流程"
aria-label="刷新流程"
@click="refreshFlowRunDetail"
>
<i :class="flowRefreshBusy ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-refresh'"></i>
</button>
</div>
<div v-if="flowSteps.length" class="review-flow-list">
<article
v-for="(step, index) in flowSteps"
:key="step.key"
class="flow-step-item"
:class="step.status"
>
<div class="flow-step-rail">
<span>{{ index + 1 }}</span>
</div>
<div class="flow-step-card">
<header>
<strong>{{ step.title }}</strong>
<div class="flow-step-side">
<span class="flow-step-status" :class="step.status">{{ resolveFlowStepStatusLabel(step) }}</span>
<time>{{ formatFlowStepDuration(step) }}</time>
</div>
</header>
<p class="flow-step-tool">工具{{ step.tool }}</p>
<p class="flow-step-detail">{{ resolveFlowStepDetail(step) }}</p>
<p v-if="step.error" class="flow-step-error">{{ step.error }}</p>
</div>
</article>
</div>
<div v-else class="flow-empty-state compact">
<i class="mdi mdi-timeline-question-outline"></i>
<strong>暂无识别流程</strong>
<p>发起识别后这里会显示调用步骤和耗时</p>
</div>
</section>
</template>
<template v-else-if="currentInsight.intent === 'agent' && currentInsight.agent">
<template v-if="activeReviewPayload">
<template v-if="reviewOverviewDrawerAvailable && !isReviewDocumentDrawer && !isReviewRiskDrawer && !isReviewFlowDrawer">
<section class="review-side-card review-side-overview-card">
<div class="review-side-intent-row">
<i class="mdi mdi-account-outline"></i>
<span>用户意图</span>
<strong>{{ reviewIntentText }}</strong>
</div>
<section class="review-side-grid compact">
<article
v-for="item in reviewFactCards"
:key="item.key"
class="review-side-metric-card"
:class="{
editable: item.editor,
editing: reviewInlineEditorKey === item.key,
invalid: Boolean(reviewInlineErrors[item.key]),
wide: item.wide
}"
@click="openInlineReviewEditor(item.key)"
>
<span class="review-side-metric-icon">
<i :class="item.icon"></i>
</span>
<div class="review-side-metric-copy">
<small>{{ item.label }}</small>
<template v-if="reviewInlineEditorKey === item.key && item.editor === 'date'">
<input
v-model="reviewInlineForm[item.modelKey]"
class="review-inline-input"
:class="{ invalid: Boolean(reviewInlineErrors[item.key]) }"
type="text"
:placeholder="`仅支持 ${DATE_INPUT_FORMAT}`"
@click.stop
@input="clearInlineReviewFieldError(item.key)"
@blur="commitInlineReviewEditor"
@keydown.enter.prevent="commitInlineReviewEditor"
/>
</template>
<template v-else-if="reviewInlineEditorKey === item.key && item.editor === 'amount'">
<input
v-model="reviewInlineForm[item.modelKey]"
class="review-inline-input"
:class="{ invalid: Boolean(reviewInlineErrors[item.key]) }"
type="text"
:placeholder="item.placeholder"
@click.stop
@input="clearInlineReviewFieldError(item.key)"
@blur="commitInlineReviewEditor"
@keydown.enter.prevent="commitInlineReviewEditor"
/>
</template>
<template v-else-if="reviewInlineEditorKey === item.key && item.editor === 'text'">
<input
v-model="reviewInlineForm[item.modelKey]"
class="review-inline-input"
:class="{ invalid: Boolean(reviewInlineErrors[item.key]) }"
type="text"
:placeholder="item.placeholder"
@click.stop
@input="clearInlineReviewFieldError(item.key)"
@blur="commitInlineReviewEditor"
@keydown.enter.prevent="commitInlineReviewEditor"
/>
</template>
<template v-else-if="reviewInlineEditorKey === item.key && item.editor === 'textarea'">
<textarea
v-model="reviewInlineForm[item.modelKey]"
class="review-inline-input review-inline-textarea"
:class="{ invalid: Boolean(reviewInlineErrors[item.key]) }"
:placeholder="item.placeholder"
rows="3"
@click.stop
@input="clearInlineReviewFieldError(item.key)"
@blur="commitInlineReviewEditor"
@keydown.enter.stop
></textarea>
</template>
<template v-else-if="reviewInlineEditorKey === item.key && item.editor === 'select'">
<div class="review-inline-select-list" @click.stop>
<button
v-for="scene in REVIEW_SCENE_OPTIONS"
:key="scene"
type="button"
class="review-inline-select-option"
:class="{ active: reviewInlineForm.scene_label === scene }"
@click.stop="selectInlineScene(scene)"
>
{{ scene }}
</button>
<input
v-if="reviewInlineForm.scene_label === REVIEW_SCENE_OTHER_OPTION"
v-model="reviewInlineForm.reason_value"
class="review-inline-input review-inline-select-custom"
:class="{ invalid: Boolean(reviewInlineErrors[item.key]) }"
type="text"
placeholder="请输入具体事由"
@click.stop
@input="clearInlineReviewFieldError(item.key)"
@blur="commitInlineReviewEditor"
@keydown.enter.prevent="commitInlineReviewEditor"
/>
</div>
</template>
<strong v-else :title="item.value">{{ item.value }}</strong>
<span v-if="reviewInlineErrors[item.key]" class="review-inline-error">
{{ reviewInlineErrors[item.key] }}
</span>
</div>
<span v-if="item.key !== 'attachments'" class="review-side-edit-hint">修改</span>
<span v-else class="review-side-edit-hint upload">{{ reviewInlinePendingFiles.length ? '已选择' : '上传' }}</span>
</article>
</section>
</section>
<section class="review-side-card">
<div class="review-side-head">
<strong>报销分类</strong>
<span class="review-side-confidence">置信度 {{ reviewPanelConfidence }}</span>
</div>
<div class="review-side-category-grid">
<button
v-for="item in reviewCategoryOptions"
:key="item.key"
type="button"
class="review-side-category-card"
:class="{ active: item.active }"
@click="selectReviewCategory(item)"
>
<div class="review-side-category-copy">
<strong>{{ item.label }}</strong>
<p>{{ item.is_other && reviewSelectedOtherCategory ? reviewSelectedOtherCategory : item.caption }}</p>
</div>
<i v-if="item.active" class="mdi mdi-check-circle review-side-group-check"></i>
</button>
</div>
<div v-if="reviewOtherCategoryOpen" class="review-other-category-popover">
<button
v-for="item in reviewOtherCategoryOptions"
:key="item.key"
type="button"
class="review-other-category-option"
:class="{ active: reviewSelectedOtherCategory === item.label }"
@click="selectReviewOtherCategory(item)"
>
{{ item.label }} · {{ item.confidenceLabel }}
</button>
</div>
</section>
</template>
<template v-else-if="isReviewFlowDrawer">
<section class="review-flow-panel">
<div class="review-flow-summary">
<span class="flow-status-chip" :class="flowOverallStatusTone">{{ flowOverallStatusText }}</span>
<span>总耗时 {{ flowTotalDurationText }}</span>
<button
type="button"
class="flow-icon-btn"
:disabled="!flowRunId || flowRefreshBusy"
title="刷新流程"
aria-label="刷新流程"
@click="refreshFlowRunDetail"
>
<i :class="flowRefreshBusy ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-refresh'"></i>
</button>
</div>
<div v-if="flowSteps.length" class="review-flow-list">
<article
v-for="(step, index) in flowSteps"
:key="step.key"
class="flow-step-item"
:class="step.status"
>
<div class="flow-step-rail">
<span>{{ index + 1 }}</span>
</div>
<div class="flow-step-card">
<header>
<strong>{{ step.title }}</strong>
<div class="flow-step-side">
<span class="flow-step-status" :class="step.status">{{ resolveFlowStepStatusLabel(step) }}</span>
<time>{{ formatFlowStepDuration(step) }}</time>
</div>
</header>
<p class="flow-step-tool">工具{{ step.tool }}</p>
<p class="flow-step-detail">{{ resolveFlowStepDetail(step) }}</p>
<p v-if="step.error" class="flow-step-error">{{ step.error }}</p>
</div>
</article>
</div>
<div v-else class="flow-empty-state compact">
<i class="mdi mdi-timeline-question-outline"></i>
<strong>暂无识别流程</strong>
<p>发起识别后这里会显示调用步骤和耗时</p>
</div>
</section>
</template>
<template v-else-if="isReviewDocumentDrawer">
<section class="review-side-card review-document-switch-card review-ticket-drawer">
<div class="review-side-head review-document-switch-head">
<div class="review-side-head-copy">
<strong>票据识别结果卡片</strong>
<p>逐张查看 OCR 结果可直接修正后再切回核对滑窗</p>
</div>
<div class="review-document-nav">
<button
type="button"
class="review-document-nav-btn"
:disabled="activeReviewDocumentIndex === 0"
aria-label="上一张票据"
@click="goReviewDocument(-1)"
>
<i class="mdi mdi-chevron-left"></i>
</button>
<span>{{ activeReviewDocumentIndex + 1 }} / {{ reviewDocumentCount }}</span>
<button
type="button"
class="review-document-nav-btn"
:disabled="activeReviewDocumentIndex >= reviewDocumentCount - 1"
aria-label="下一张票据"
@click="goReviewDocument(1)"
>
<i class="mdi mdi-chevron-right"></i>
</button>
</div>
</div>
<div v-if="activeReviewDocument" class="review-document-stage">
<div class="review-document-stage-head">
<div class="review-document-stage-copy">
<strong :title="activeReviewDocument.filename">{{ activeReviewDocument.filename }}</strong>
</div>
</div>
<div class="review-document-meta-chip-row">
<span class="review-document-meta-chip">{{ activeReviewDocument.documentTypeLabel }}</span>
<span class="review-document-meta-chip">{{ activeReviewDocument.expenseTypeLabel }}</span>
<span class="review-document-meta-chip confidence">{{ activeReviewDocument.confidenceLabel }}</span>
</div>
<div class="review-document-scroll">
<div
class="review-document-preview-card"
:class="[
activeReviewDocumentPreview?.kind || 'file',
{ clickable: canPreviewActiveReviewDocument }
]"
:role="canPreviewActiveReviewDocument ? 'button' : null"
:tabindex="canPreviewActiveReviewDocument ? 0 : null"
@click="canPreviewActiveReviewDocument ? openActiveReviewDocumentPreview() : null"
@keydown.enter.prevent="canPreviewActiveReviewDocument ? openActiveReviewDocumentPreview() : null"
@keydown.space.prevent="canPreviewActiveReviewDocument ? openActiveReviewDocumentPreview() : null"
>
<img
v-if="activeReviewDocumentPreview?.kind === 'image' && activeReviewDocumentPreview?.url"
:src="activeReviewDocumentPreview.url"
:alt="activeReviewDocument.filename"
/>
<div v-else-if="activeReviewDocumentPreview?.kind === 'pdf'" class="review-document-preview-placeholder">
<i class="mdi mdi-file-pdf-box"></i>
<strong>PDF 票据文件</strong>
<p>当前文件还没有生成图片预览可先核对下方识别字段</p>
</div>
<div v-else class="review-document-preview-placeholder">
<i class="mdi mdi-file-search-outline"></i>
<strong>当前无可预览票据</strong>
<p>这张票据还没有可用预览可先核对下方识别字段</p>
</div>
</div>
<label class="review-document-edit-field summary">
<span>票据摘要</span>
<textarea
v-model="activeReviewDocument.summary"
rows="3"
:disabled="submitting || reviewActionBusy"
placeholder="可根据票据图片修正 OCR 摘要"
></textarea>
</label>
<label class="review-document-edit-field">
<span>票据场景</span>
<input
v-model="activeReviewDocument.scene_label"
type="text"
:disabled="submitting || reviewActionBusy"
placeholder="例如:出租车/网约车票据 / 火车/高铁票"
/>
</label>
<div v-if="activeReviewDocument.fields.length" class="review-document-edit-grid">
<label
v-for="field in activeReviewDocument.fields"
:key="`${activeReviewDocument.filename}-${field.label}`"
class="review-document-edit-field"
>
<span>{{ field.label }}</span>
<input
v-model="field.value"
type="text"
:disabled="submitting || reviewActionBusy"
:placeholder="`修正 ${field.label}`"
/>
</label>
</div>
<div v-else class="review-side-empty compact">
<span class="review-side-empty-icon">
<i class="mdi mdi-text-recognition"></i>
</span>
<strong>暂无结构化字段</strong>
<p>当前只返回了摘要信息你仍然可以直接修改上面的票据摘要</p>
</div>
<div v-if="activeReviewDocument.warnings?.length" class="review-document-warning-list">
<article
v-for="warning in activeReviewDocument.warnings"
:key="`${activeReviewDocument.filename}-${warning}`"
class="review-document-warning-item"
>
<i class="mdi mdi-alert-circle-outline"></i>
<span>{{ warning }}</span>
</article>
</div>
</div>
</div>
</section>
</template>
<template v-else-if="isReviewRiskDrawer">
<section class="review-side-card review-side-risk-card">
<div class="review-side-head">
<div class="review-side-head-copy">
<strong>差旅合规提示</strong>
<p>结合票据识别结果与差旅规则逐项查看需要处理的风险点</p>
</div>
</div>
<p class="review-side-risk-summary">{{ reviewRiskSummary }}</p>
<div v-if="reviewRiskItems.length" class="review-side-risk-list">
<button
v-for="item in reviewRiskItems"
:key="item.key"
type="button"
class="review-side-risk-item"
:class="item.level"
@click="appendReviewRiskBriefToConversation(item)"
>
<span class="review-side-risk-icon" :title="item.levelLabel">
<i :class="item.icon"></i>
</span>
<span class="review-side-risk-copy">
<strong>{{ item.title }}</strong>
<p>{{ item.summary }}</p>
</span>
<span class="review-side-risk-meta">
<i class="mdi mdi-chevron-right"></i>
</span>
</button>
</div>
<div v-else-if="reviewRiskEmpty" class="review-side-empty">
<span class="review-side-empty-icon">
<i class="mdi mdi-shield-check-outline"></i>
</span>
<strong>暂无风险提示</strong>
<p>当前没有需要额外处理的结构化风险点</p>
</div>
</section>
</template>
<button
v-if="reviewHasUnsavedChanges"
type="button"
class="review-side-save-pill"
:disabled="reviewActionBusy || submitting"
@click="saveInlineReviewChanges"
>
<i class="mdi mdi-content-save-outline"></i>
保存右侧修改
</button>
</template>
<section v-if="currentInsight.agent.citations?.length && !currentInsight.agent.queryPayload && !activeReviewPayload" class="insight-card">
<div class="card-head">
<h4>制度依据</h4>
</div>
<div class="citation-stack">
<article v-for="item in currentInsight.agent.citations" :key="item.code" class="citation-card">
<header>
<strong>{{ item.title }}</strong>
<span>{{ item.version || item.source_type }}</span>
</header>
<p>{{ item.excerpt || item.code }}</p>
</article>
</div>
</section>
<template v-if="!activeReviewPayload">
<section class="insight-card primary">
<div class="card-head">
<h4>识别结果</h4>
</div>
<div class="note-block">
<strong>{{ currentInsight.title }}</strong>
<p>{{ currentInsight.summary }}</p>
</div>
</section>
<section v-if="currentInsight.agent.riskFlags?.length" class="insight-card">
<div class="card-head">
<h4>风险标签</h4>
</div>
<div class="capability-chip-row">
<span v-for="item in currentInsight.agent.riskFlags" :key="item" class="risk-chip">{{ item }}</span>
</div>
</section>
</template>
</template>
</div>
</Transition>
</aside>
</div>
</div>
</div>
</section>
</div>
</Transition>
<ConfirmDialog
:open="deleteSessionDialogOpen"
badge="删除会话"
badge-tone="danger"
title="确认删除当前会话内容?"
description="删除后将清空当前类型会话下的全部对话内容,且无法恢复。"
cancel-text="取消"
confirm-text="确认删除"
busy-text="删除中..."
confirm-tone="danger"
confirm-icon="mdi mdi-delete-outline"
:busy="deleteSessionBusy"
@close="closeDeleteSessionDialog"
@confirm="confirmDeleteCurrentSession"
/>
<ConfirmDialog
:open="applicationSubmitConfirmDialog.open"
badge="提交确认"
badge-tone="primary"
title="确认提交当前费用申请?"
description="提交后申请将进入领导审核流程,请确认关键申请信息和预计费用已经核对无误。"
cancel-text="再检查一下"
confirm-text="确认提交"
busy-text="提交中..."
confirm-tone="primary"
confirm-icon="mdi mdi-send-check-outline"
:busy="reviewActionBusy"
@close="closeApplicationSubmitConfirm"
@confirm="confirmApplicationSubmit"
/>
<ConfirmDialog
:open="nextStepConfirmDialog.open"
badge="提交确认"
badge-tone="primary"
title="确认提交当前单据?"
description="提交后单据将进入审批流程,请确认关键信息、票据和风险提示已经核对无误。"
cancel-text="再检查一下"
confirm-text="确认提交"
busy-text="提交中..."
confirm-tone="primary"
confirm-icon="mdi mdi-send-check-outline"
:busy="reviewActionBusy"
@close="closeReviewNextStepConfirm"
@confirm="confirmReviewNextStepSubmit"
/>
<Transition name="assistant-modal">
<div v-if="documentPreviewDialog.open" class="assistant-overlay review-overlay">
<section class="review-preview-modal">
<header class="review-preview-head">
<div>
<span class="assistant-badge">票据原图</span>
<h3>{{ documentPreviewDialog.filename }}</h3>
</div>
<button class="close-btn" type="button" aria-label="关闭原图预览" @click="closeDocumentPreview">
<i class="mdi mdi-close"></i>
</button>
</header>
<div class="review-preview-body" :class="documentPreviewDialog.kind">
<img
v-if="documentPreviewDialog.kind === 'image'"
:key="documentPreviewDialog.renderKey"
:src="documentPreviewDialog.url"
:alt="documentPreviewDialog.filename"
/>
<iframe
v-else-if="documentPreviewDialog.kind === 'pdf'"
:key="documentPreviewDialog.renderKey"
:src="documentPreviewDialog.url"
title="票据 PDF 原图预览"
></iframe>
<div v-else class="review-side-empty">
<span class="review-side-empty-icon">
<i class="mdi mdi-file-outline"></i>
</span>
<strong>当前文件暂不支持内置预览</strong>
<p>请重新上传图片或 PDF 票据以便在这里查看原图</p>
</div>
</div>
</section>
</div>
</Transition>
</Teleport>
</template>
<script src="./scripts/TravelReimbursementCreateView.js"></script>
<style scoped src="../assets/styles/views/travel-reimbursement-create-view.css"></style>
<style scoped src="../assets/styles/views/travel-reimbursement-create-view-part2.css"></style>
<style scoped src="../assets/styles/views/travel-reimbursement-create-view-part3.css"></style>
<style scoped src="../assets/styles/views/travel-reimbursement-create-view-part4.css"></style>