feat(web): update travel reimbursement view
- travel-reimbursement-create-view.css: update form styles - TravelReimbursementCreateView.vue: update view component - scripts/TravelReimbursementCreateView.js: update view logic
This commit is contained in:
@@ -55,6 +55,11 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.assistant-badge.warning {
|
||||||
|
background: rgba(249, 115, 22, 0.12);
|
||||||
|
color: #c2410c;
|
||||||
|
}
|
||||||
|
|
||||||
.assistant-header h2 {
|
.assistant-header h2 {
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
@@ -176,8 +181,7 @@
|
|||||||
|
|
||||||
.message-row.user .message-avatar {
|
.message-row.user .message-avatar {
|
||||||
order: 2;
|
order: 2;
|
||||||
background: #dbeafe;
|
background: transparent;
|
||||||
color: #2563eb;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-row.user .message-bubble {
|
.message-row.user .message-bubble {
|
||||||
@@ -193,9 +197,16 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: #dff7ee;
|
overflow: hidden;
|
||||||
color: #059669;
|
background: transparent;
|
||||||
font-size: 20px;
|
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-avatar img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-bubble {
|
.message-bubble {
|
||||||
@@ -766,6 +777,69 @@
|
|||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.review-inline-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-inline-btn,
|
||||||
|
.primary-dialog-btn,
|
||||||
|
.secondary-dialog-btn,
|
||||||
|
.danger-dialog-btn {
|
||||||
|
min-height: 38px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-inline-btn {
|
||||||
|
border: 1px solid #dbe6f0;
|
||||||
|
background: #fff;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-inline-btn.primary,
|
||||||
|
.primary-dialog-btn {
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.22);
|
||||||
|
background: linear-gradient(135deg, #10b981, #059669);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 10px 22px rgba(16, 185, 129, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-inline-btn.secondary,
|
||||||
|
.secondary-dialog-btn {
|
||||||
|
border: 1px solid #dbe6f0;
|
||||||
|
background: #fff;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-dialog-btn {
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.22);
|
||||||
|
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 10px 22px rgba(239, 68, 68, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-inline-btn:disabled,
|
||||||
|
.primary-dialog-btn:disabled,
|
||||||
|
.secondary-dialog-btn:disabled,
|
||||||
|
.danger-dialog-btn:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.62;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-inline-note {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
.review-mini-grid,
|
.review-mini-grid,
|
||||||
.review-slot-grid,
|
.review-slot-grid,
|
||||||
.review-doc-field-grid {
|
.review-doc-field-grid {
|
||||||
@@ -847,6 +921,30 @@
|
|||||||
background: #f8fbff;
|
background: #f8fbff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.review-slot-meta-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-slot-meta-item {
|
||||||
|
padding: 9px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.82);
|
||||||
|
border: 1px solid rgba(226, 232, 240, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-slot-meta-item span {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-slot-meta-item strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.review-brief-list,
|
.review-brief-list,
|
||||||
.review-claim-list,
|
.review-claim-list,
|
||||||
.review-document-list {
|
.review-document-list {
|
||||||
@@ -952,6 +1050,141 @@
|
|||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.review-conclusion strong {
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-overlay {
|
||||||
|
z-index: 10001;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-confirm-modal,
|
||||||
|
.review-edit-modal {
|
||||||
|
width: min(720px, calc(100vw - 40px));
|
||||||
|
border-radius: 24px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top right, rgba(16, 185, 129, 0.08), transparent 28%),
|
||||||
|
linear-gradient(180deg, #fbfdff 0%, #f6f9fc 100%);
|
||||||
|
box-shadow:
|
||||||
|
0 24px 80px rgba(15, 23, 42, 0.22),
|
||||||
|
0 2px 12px rgba(15, 23, 42, 0.08);
|
||||||
|
border: 1px solid #e7eef6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-confirm-modal {
|
||||||
|
padding: 24px;
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-confirm-modal h3,
|
||||||
|
.review-edit-head h3 {
|
||||||
|
margin-top: 12px;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-confirm-modal p,
|
||||||
|
.review-edit-head p {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-confirm-actions,
|
||||||
|
.review-edit-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-edit-modal {
|
||||||
|
max-height: min(860px, calc(100vh - 48px));
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-edit-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 22px 24px 18px;
|
||||||
|
border-bottom: 1px solid #eef2f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-edit-form {
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
padding: 20px 24px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-edit-field {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-edit-field.attachments,
|
||||||
|
.review-edit-field.business,
|
||||||
|
.review-edit-field.basic {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-edit-field span {
|
||||||
|
color: #334155;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-edit-field span em {
|
||||||
|
margin-left: 4px;
|
||||||
|
color: #dc2626;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-edit-field input,
|
||||||
|
.review-edit-field textarea {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #dbe6f0;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #fff;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
padding: 12px 14px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-edit-field input:focus,
|
||||||
|
.review-edit-field textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #60a5fa;
|
||||||
|
box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-edit-field textarea {
|
||||||
|
min-height: 96px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-edit-field.attachments,
|
||||||
|
.review-edit-field textarea,
|
||||||
|
.review-edit-field .textarea {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-edit-actions {
|
||||||
|
padding: 0 24px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
.welcome-grid {
|
.welcome-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -1092,4 +1325,31 @@
|
|||||||
.review-mini-grid {
|
.review-mini-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.review-edit-modal {
|
||||||
|
width: calc(100vw - 24px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-edit-form {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-edit-field.attachments,
|
||||||
|
.review-edit-field textarea,
|
||||||
|
.review-edit-field .textarea {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-edit-actions,
|
||||||
|
.review-confirm-actions {
|
||||||
|
padding: 0 18px 18px;
|
||||||
|
justify-content: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-dialog-btn,
|
||||||
|
.secondary-dialog-btn,
|
||||||
|
.danger-dialog-btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<Transition name="assistant-modal">
|
<Transition name="assistant-modal">
|
||||||
<div class="assistant-overlay" @click.self="emit('close')">
|
<div class="assistant-overlay">
|
||||||
<section class="assistant-modal">
|
<section class="assistant-modal">
|
||||||
<header class="assistant-header">
|
<header class="assistant-header">
|
||||||
<div class="assistant-header-main">
|
<div class="assistant-header-main">
|
||||||
@@ -43,7 +43,10 @@
|
|||||||
:class="message.role"
|
:class="message.role"
|
||||||
>
|
>
|
||||||
<span class="message-avatar">
|
<span class="message-avatar">
|
||||||
<i :class="message.role === 'assistant' ? 'mdi mdi-robot-excited-outline' : 'mdi mdi-account-circle-outline'"></i>
|
<img
|
||||||
|
:src="message.role === 'assistant' ? aiAvatar : userAvatar"
|
||||||
|
:alt="message.role === 'assistant' ? 'AI 助手头像' : '用户头像'"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div class="message-bubble">
|
<div class="message-bubble">
|
||||||
@@ -53,18 +56,18 @@
|
|||||||
</header>
|
</header>
|
||||||
<p>{{ message.text }}</p>
|
<p>{{ message.text }}</p>
|
||||||
|
|
||||||
<div v-if="message.role === 'assistant' && message.meta?.length" class="message-meta-row">
|
<div v-if="message.role === 'assistant' && !message.reviewPayload && message.meta?.length" class="message-meta-row">
|
||||||
<span v-for="item in message.meta" :key="item" class="message-meta-chip">{{ item }}</span>
|
<span v-for="item in message.meta" :key="item" class="message-meta-chip">{{ item }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="message.role === 'assistant' && message.riskFlags?.length" class="message-detail-block">
|
<div v-if="message.role === 'assistant' && !message.reviewPayload && message.riskFlags?.length" class="message-detail-block">
|
||||||
<strong>风险标签</strong>
|
<strong>风险标签</strong>
|
||||||
<div class="message-detail-chip-row">
|
<div class="message-detail-chip-row">
|
||||||
<span v-for="item in message.riskFlags" :key="item" class="message-risk-chip">{{ item }}</span>
|
<span v-for="item in message.riskFlags" :key="item" class="message-risk-chip">{{ item }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="message.role === 'assistant' && message.citations?.length" class="message-detail-block">
|
<div v-if="message.role === 'assistant' && !message.reviewPayload && message.citations?.length" class="message-detail-block">
|
||||||
<strong>引用依据</strong>
|
<strong>引用依据</strong>
|
||||||
<div class="message-citation-list">
|
<div class="message-citation-list">
|
||||||
<article v-for="item in message.citations" :key="`${message.id}-${item.code}`" class="message-citation-card">
|
<article v-for="item in message.citations" :key="`${message.id}-${item.code}`" class="message-citation-card">
|
||||||
@@ -77,7 +80,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="message.role === 'assistant' && message.suggestedActions?.length" class="message-detail-block">
|
<div v-if="message.role === 'assistant' && !message.reviewPayload && message.suggestedActions?.length" class="message-detail-block">
|
||||||
<strong>建议动作</strong>
|
<strong>建议动作</strong>
|
||||||
<div class="message-detail-chip-row">
|
<div class="message-detail-chip-row">
|
||||||
<span
|
<span
|
||||||
@@ -91,35 +94,27 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="message.role === 'assistant' && message.reviewPayload" class="message-detail-block">
|
<div v-if="message.role === 'assistant' && message.reviewPayload" class="message-detail-block">
|
||||||
<strong>系统识别</strong>
|
<strong>核对提示</strong>
|
||||||
<p class="review-summary">{{ message.reviewPayload.intent_summary }}</p>
|
<p class="review-summary">{{ message.reviewPayload.body_message || '相关识别信息已在右侧展示,请核对。' }}</p>
|
||||||
<div v-if="message.reviewPayload.slot_cards?.length" class="review-mini-grid">
|
<div v-if="message.reviewPayload.confirmation_actions?.length" class="review-inline-actions">
|
||||||
<article
|
<button
|
||||||
v-for="item in message.reviewPayload.slot_cards.slice(0, 4)"
|
|
||||||
:key="`${message.id}-${item.key}`"
|
|
||||||
class="review-slot-card compact"
|
|
||||||
:class="item.status"
|
|
||||||
>
|
|
||||||
<span>{{ item.label }}</span>
|
|
||||||
<strong>{{ item.value || '待补充' }}</strong>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
<div v-if="message.reviewPayload.confirmation_actions?.length" class="action-list compact">
|
|
||||||
<article
|
|
||||||
v-for="item in message.reviewPayload.confirmation_actions"
|
v-for="item in message.reviewPayload.confirmation_actions"
|
||||||
:key="`${message.id}-${item.action_type}-${item.label}`"
|
:key="`${message.id}-${item.action_type}-${item.label}`"
|
||||||
class="action-card"
|
type="button"
|
||||||
|
class="review-inline-btn"
|
||||||
:class="item.emphasis"
|
:class="item.emphasis"
|
||||||
|
:disabled="reviewActionBusy"
|
||||||
|
@click="handleReviewAction(message, item)"
|
||||||
>
|
>
|
||||||
<div>
|
{{ item.label }}
|
||||||
<strong>{{ item.label }}</strong>
|
</button>
|
||||||
<p>{{ item.description }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
<div v-if="message.reviewPayload.missing_slots?.length" class="review-inline-note">
|
||||||
|
当前仍需补充:{{ message.reviewPayload.missing_slots.join('、') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="message.role === 'assistant' && message.draftPayload" class="draft-preview">
|
<div v-if="message.role === 'assistant' && !message.reviewPayload && message.draftPayload" class="draft-preview">
|
||||||
<header>
|
<header>
|
||||||
<strong>{{ message.draftPayload.title }}</strong>
|
<strong>{{ message.draftPayload.title }}</strong>
|
||||||
<span>待人工确认</span>
|
<span>待人工确认</span>
|
||||||
@@ -152,7 +147,8 @@
|
|||||||
v-model="composerDraft"
|
v-model="composerDraft"
|
||||||
rows="3"
|
rows="3"
|
||||||
:placeholder="composerPlaceholder"
|
:placeholder="composerPlaceholder"
|
||||||
:disabled="submitting"
|
:disabled="submitting || reviewActionBusy"
|
||||||
|
@keydown.enter.exact.stop
|
||||||
@keydown.ctrl.enter.prevent="submitComposer"
|
@keydown.ctrl.enter.prevent="submitComposer"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -165,13 +161,13 @@
|
|||||||
|
|
||||||
<div class="composer-foot">
|
<div class="composer-foot">
|
||||||
<div class="composer-tools">
|
<div class="composer-tools">
|
||||||
<button type="button" class="tool-btn" :disabled="submitting" aria-label="上传附件" @click="triggerFileUpload">
|
<button type="button" class="tool-btn" :disabled="submitting || reviewActionBusy" aria-label="上传附件" @click="triggerFileUpload">
|
||||||
<i class="mdi mdi-paperclip"></i>
|
<i class="mdi mdi-paperclip"></i>
|
||||||
</button>
|
</button>
|
||||||
<span class="composer-tip">Ctrl + Enter 发送</span>
|
<span class="composer-tip">Enter 换行,Ctrl + Enter 发送</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="send-btn" type="submit" :disabled="!canSubmit" aria-label="发送">
|
<button class="send-btn" type="submit" :disabled="!canSubmit || reviewActionBusy" aria-label="发送">
|
||||||
<i :class="submitting ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-send'"></i>
|
<i :class="submitting ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-send'"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -184,11 +180,15 @@
|
|||||||
<div class="insight-head">
|
<div class="insight-head">
|
||||||
<div>
|
<div>
|
||||||
<span class="intent-pill" :class="currentInsight.intent">{{ currentIntentLabel }}</span>
|
<span class="intent-pill" :class="currentInsight.intent">{{ currentIntentLabel }}</span>
|
||||||
<h3>{{ currentInsight.title }}</h3>
|
<h3>{{ activeReviewPayload ? '报销识别核对' : currentInsight.title }}</h3>
|
||||||
<p>{{ currentInsight.summary }}</p>
|
<p>{{ activeReviewPayload?.intent_summary || currentInsight.summary }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="confidence-card">
|
<div class="confidence-card" v-if="activeReviewPayload">
|
||||||
|
<span>当前状态</span>
|
||||||
|
<strong>{{ activeReviewPayload.can_proceed ? '可进入下一步' : '待补充' }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="confidence-card" v-else>
|
||||||
<span>{{ currentInsight.metricLabel }}</span>
|
<span>{{ currentInsight.metricLabel }}</span>
|
||||||
<strong>{{ currentInsight.metricValue }}</strong>
|
<strong>{{ currentInsight.metricValue }}</strong>
|
||||||
</div>
|
</div>
|
||||||
@@ -197,71 +197,80 @@
|
|||||||
<Transition name="insight-switch" mode="out-in">
|
<Transition name="insight-switch" mode="out-in">
|
||||||
<div :key="currentInsight.intent + currentInsight.title" class="insight-body">
|
<div :key="currentInsight.intent + currentInsight.title" class="insight-body">
|
||||||
<template v-if="currentInsight.intent === 'agent' && currentInsight.agent">
|
<template v-if="currentInsight.intent === 'agent' && currentInsight.agent">
|
||||||
|
<template v-if="activeReviewPayload">
|
||||||
<section class="insight-card primary">
|
<section class="insight-card primary">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<h4>调度结果</h4>
|
<h4>识别结论</h4>
|
||||||
<span class="status-pill" :class="currentInsight.agent.statusTone">{{ currentInsight.agent.statusLabel }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="metric-grid">
|
<div class="note-block review-conclusion">
|
||||||
<div class="metric-item">
|
<strong>{{ activeReviewPayload.intent_summary }}</strong>
|
||||||
<span>运行 ID</span>
|
<p>{{ activeReviewPayload.body_message }}</p>
|
||||||
<strong>{{ currentInsight.agent.runId }}</strong>
|
<p v-if="activeReviewPayload.missing_slots?.length">
|
||||||
</div>
|
当前仍缺少:{{ activeReviewPayload.missing_slots.join('、') }}
|
||||||
<div class="metric-item">
|
|
||||||
<span>执行 Agent</span>
|
|
||||||
<strong>{{ currentInsight.agent.selectedAgent }}</strong>
|
|
||||||
</div>
|
|
||||||
<div class="metric-item">
|
|
||||||
<span>场景 / 意图</span>
|
|
||||||
<strong>{{ currentInsight.agent.scenario }} / {{ currentInsight.agent.intent }}</strong>
|
|
||||||
</div>
|
|
||||||
<div class="metric-item">
|
|
||||||
<span>权限</span>
|
|
||||||
<strong>{{ currentInsight.agent.permissionLevel }}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section v-if="currentInsight.agent.reviewPayload" class="insight-card">
|
|
||||||
<div class="card-head">
|
|
||||||
<h4>识别结果</h4>
|
|
||||||
</div>
|
|
||||||
<div class="note-block">
|
|
||||||
<span>{{ currentInsight.agent.reviewPayload.intent }}</span>
|
|
||||||
<strong>{{ currentInsight.agent.reviewPayload.intent_summary }}</strong>
|
|
||||||
<p v-if="currentInsight.agent.reviewPayload.missing_slots?.length">
|
|
||||||
当前仍缺少:{{ currentInsight.agent.reviewPayload.missing_slots.join('、') }}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="insight-card">
|
<section v-if="recognizedSlotCards.length" class="insight-card">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<h4>运行明细</h4>
|
<h4>已识别字段</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric-grid">
|
<div class="review-slot-grid">
|
||||||
<div class="metric-item">
|
<article
|
||||||
<span>路由原因</span>
|
v-for="item in recognizedSlotCards"
|
||||||
<strong>{{ currentInsight.agent.routeReason }}</strong>
|
:key="item.key"
|
||||||
|
class="review-slot-card"
|
||||||
|
:class="item.status"
|
||||||
|
>
|
||||||
|
<header>
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
<small>{{ item.source_label || item.source }}</small>
|
||||||
|
</header>
|
||||||
|
<strong>{{ item.value || '待补充' }}</strong>
|
||||||
|
<div class="review-slot-meta-list">
|
||||||
|
<div v-if="item.raw_value && item.raw_value !== item.value" class="review-slot-meta-item">
|
||||||
|
<span>原始表达</span>
|
||||||
|
<strong>{{ item.raw_value }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric-item">
|
<div v-if="item.normalized_value" class="review-slot-meta-item">
|
||||||
<span>工具调用</span>
|
<span>标准值</span>
|
||||||
<strong>{{ currentInsight.agent.toolCount }} / 失败 {{ currentInsight.agent.failedToolCount }}</strong>
|
<strong>{{ item.normalized_value }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric-item">
|
<div class="review-slot-meta-item">
|
||||||
<span>确认要求</span>
|
<span>置信度</span>
|
||||||
<strong>{{ currentInsight.agent.requiresConfirmation ? '需要人工确认' : '无需确认' }}</strong>
|
<strong>{{ Math.round((item.confidence || 0) * 100) }}%</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric-item">
|
|
||||||
<span>降级状态</span>
|
|
||||||
<strong>{{ currentInsight.agent.degraded ? '已降级' : '正常' }}</strong>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p v-if="item.evidence">{{ item.evidence }}</p>
|
||||||
|
<p v-else-if="item.hint">{{ item.hint }}</p>
|
||||||
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section v-if="missingSlotCards.length" class="insight-card">
|
||||||
|
<div class="card-head">
|
||||||
|
<h4>待补字段</h4>
|
||||||
|
</div>
|
||||||
|
<div class="review-slot-grid">
|
||||||
|
<article
|
||||||
|
v-for="item in missingSlotCards"
|
||||||
|
:key="item.key"
|
||||||
|
class="review-slot-card missing"
|
||||||
|
>
|
||||||
|
<header>
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
<small>待用户补充</small>
|
||||||
|
</header>
|
||||||
|
<strong>待补充</strong>
|
||||||
|
<p>{{ item.hint || '请补充该字段后再继续。' }}</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
<section v-if="currentInsight.agent.reviewPayload?.risk_briefs?.length" class="insight-card">
|
<section v-if="currentInsight.agent.reviewPayload?.risk_briefs?.length" class="insight-card">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<h4>历史风险与注意事项</h4>
|
<h4>风险与注意事项</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="review-brief-list">
|
<div class="review-brief-list">
|
||||||
<article
|
<article
|
||||||
@@ -276,27 +285,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="currentInsight.agent.reviewPayload?.slot_cards?.length" class="insight-card">
|
|
||||||
<div class="card-head">
|
|
||||||
<h4>待确认字段</h4>
|
|
||||||
</div>
|
|
||||||
<div class="review-slot-grid">
|
|
||||||
<article
|
|
||||||
v-for="item in currentInsight.agent.reviewPayload.slot_cards"
|
|
||||||
:key="item.key"
|
|
||||||
class="review-slot-card"
|
|
||||||
:class="item.status"
|
|
||||||
>
|
|
||||||
<header>
|
|
||||||
<span>{{ item.label }}</span>
|
|
||||||
<small>{{ item.source }}</small>
|
|
||||||
</header>
|
|
||||||
<strong>{{ item.value || '待补充' }}</strong>
|
|
||||||
<p v-if="item.hint">{{ item.hint }}</p>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section v-if="currentInsight.agent.reviewPayload?.claim_groups?.length" class="insight-card">
|
<section v-if="currentInsight.agent.reviewPayload?.claim_groups?.length" class="insight-card">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<h4>分单建议</h4>
|
<h4>分单建议</h4>
|
||||||
@@ -338,15 +326,15 @@
|
|||||||
<span class="message-action-chip">{{ item.scene_label }}</span>
|
<span class="message-action-chip">{{ item.scene_label }}</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="document-preview" :class="resolveDocumentPreview(currentInsight.agent.filePreviews, item.filename)?.kind || 'file'">
|
<div class="document-preview" :class="resolveDocumentPreview(activeReviewFilePreviews, item.filename)?.kind || 'file'">
|
||||||
<img
|
<img
|
||||||
v-if="resolveDocumentPreview(currentInsight.agent.filePreviews, item.filename)?.kind === 'image'"
|
v-if="resolveDocumentPreview(activeReviewFilePreviews, item.filename)?.kind === 'image'"
|
||||||
:src="resolveDocumentPreview(currentInsight.agent.filePreviews, item.filename)?.url"
|
:src="resolveDocumentPreview(activeReviewFilePreviews, item.filename)?.url"
|
||||||
:alt="item.filename"
|
:alt="item.filename"
|
||||||
/>
|
/>
|
||||||
<div v-else class="document-preview-placeholder">
|
<div v-else class="document-preview-placeholder">
|
||||||
<i class="mdi mdi-file-document-outline"></i>
|
<i class="mdi mdi-file-document-outline"></i>
|
||||||
<span>{{ resolveDocumentPreview(currentInsight.agent.filePreviews, item.filename)?.kind === 'pdf' ? 'PDF' : '附件' }}</span>
|
<span>{{ resolveDocumentPreview(activeReviewFilePreviews, item.filename)?.kind === 'pdf' ? 'PDF' : '附件' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -366,58 +354,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="currentInsight.agent.reviewPayload?.confirmation_actions?.length" class="insight-card">
|
|
||||||
<div class="card-head">
|
|
||||||
<h4>确认动作</h4>
|
|
||||||
</div>
|
|
||||||
<div class="action-list">
|
|
||||||
<article
|
|
||||||
v-for="item in currentInsight.agent.reviewPayload.confirmation_actions"
|
|
||||||
:key="`${item.action_type}-${item.label}`"
|
|
||||||
class="action-card"
|
|
||||||
:class="item.emphasis"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<strong>{{ item.label }}</strong>
|
|
||||||
<p>{{ item.description || item.action_type }}</p>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section v-if="currentInsight.agent.selectedCapabilityCodes?.length" class="insight-card">
|
|
||||||
<div class="card-head">
|
|
||||||
<h4>命中能力</h4>
|
|
||||||
</div>
|
|
||||||
<div class="capability-chip-row">
|
|
||||||
<span v-for="item in currentInsight.agent.selectedCapabilityCodes" :key="item" class="capability-chip">
|
|
||||||
{{ item }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section v-if="currentInsight.agent.fileNames?.length" class="insight-card">
|
|
||||||
<div class="card-head">
|
|
||||||
<h4>附件上下文</h4>
|
|
||||||
</div>
|
|
||||||
<ul class="bullet-list">
|
|
||||||
<li>本次对话已带入 {{ currentInsight.agent.fileNames.length }} 份附件名称。</li>
|
|
||||||
<li v-for="item in currentInsight.agent.fileNames" :key="item">{{ item }}</li>
|
|
||||||
</ul>
|
|
||||||
</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>
|
|
||||||
|
|
||||||
<section v-if="currentInsight.agent.citations?.length" class="insight-card">
|
<section v-if="currentInsight.agent.citations?.length" class="insight-card">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<h4>引用依据</h4>
|
<h4>制度依据</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="citation-stack">
|
<div class="citation-stack">
|
||||||
<article v-for="item in currentInsight.agent.citations" :key="item.code" class="citation-card">
|
<article v-for="item in currentInsight.agent.citations" :key="item.code" class="citation-card">
|
||||||
@@ -430,31 +369,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="currentInsight.agent.suggestedActions?.length" class="insight-card">
|
<template v-if="!activeReviewPayload">
|
||||||
|
<section class="insight-card primary">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<h4>建议动作</h4>
|
<h4>识别结果</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="action-list">
|
<div class="note-block">
|
||||||
<article v-for="item in currentInsight.agent.suggestedActions" :key="item.label" class="action-card">
|
<strong>{{ currentInsight.title }}</strong>
|
||||||
<div>
|
<p>{{ currentInsight.summary }}</p>
|
||||||
<strong>{{ item.label }}</strong>
|
|
||||||
<p>{{ item.description || item.action_type }}</p>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="currentInsight.agent.draftPayload" class="insight-card">
|
<section v-if="currentInsight.agent.riskFlags?.length" class="insight-card">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<h4>草稿内容</h4>
|
<h4>风险标签</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="note-block">
|
<div class="capability-chip-row">
|
||||||
<span>{{ currentInsight.agent.draftPayload.draft_type }}</span>
|
<span v-for="item in currentInsight.agent.riskFlags" :key="item" class="risk-chip">{{ item }}</span>
|
||||||
<strong>{{ currentInsight.agent.draftPayload.title }}</strong>
|
|
||||||
<p>{{ currentInsight.agent.draftPayload.body }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -463,6 +398,71 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
|
<Transition name="assistant-modal">
|
||||||
|
<div v-if="reviewCancelDialogOpen" class="assistant-overlay review-overlay">
|
||||||
|
<section class="review-confirm-modal">
|
||||||
|
<header>
|
||||||
|
<span class="assistant-badge warning">取消核对</span>
|
||||||
|
<h3>确认放弃本次识别结果?</h3>
|
||||||
|
<p>关闭后将退出当前核对窗口,本次尚未确认的修改不会继续保留。</p>
|
||||||
|
</header>
|
||||||
|
<div class="review-confirm-actions">
|
||||||
|
<button type="button" class="secondary-dialog-btn" :disabled="reviewActionBusy" @click="closeCancelReviewDialog">返回继续核对</button>
|
||||||
|
<button type="button" class="danger-dialog-btn" :disabled="reviewActionBusy" @click="confirmCancelReview">确认取消</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<Transition name="assistant-modal">
|
||||||
|
<div v-if="reviewEditDialogOpen" class="assistant-overlay review-overlay">
|
||||||
|
<section class="review-edit-modal">
|
||||||
|
<header class="review-edit-head">
|
||||||
|
<div>
|
||||||
|
<span class="assistant-badge">修改识别信息</span>
|
||||||
|
<h3>请按当前识别结果逐项修改</h3>
|
||||||
|
<p>修改后会重新发送到智能体,右侧识别结果会按新内容刷新。</p>
|
||||||
|
</div>
|
||||||
|
<button class="close-btn" type="button" aria-label="关闭修改面板" :disabled="reviewActionBusy" @click="closeEditReviewDialog">
|
||||||
|
<i class="mdi mdi-close"></i>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="review-edit-form">
|
||||||
|
<label
|
||||||
|
v-for="item in reviewEditFields"
|
||||||
|
:key="item.key"
|
||||||
|
class="review-edit-field"
|
||||||
|
:class="item.group"
|
||||||
|
>
|
||||||
|
<span>{{ item.label }}<em v-if="item.required">*</em></span>
|
||||||
|
<textarea
|
||||||
|
v-if="item.field_type === 'textarea'"
|
||||||
|
v-model="item.value"
|
||||||
|
rows="3"
|
||||||
|
:placeholder="item.placeholder"
|
||||||
|
:disabled="reviewActionBusy"
|
||||||
|
></textarea>
|
||||||
|
<input
|
||||||
|
v-else
|
||||||
|
v-model="item.value"
|
||||||
|
type="text"
|
||||||
|
:placeholder="item.placeholder"
|
||||||
|
:disabled="reviewActionBusy"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="review-edit-actions">
|
||||||
|
<button type="button" class="secondary-dialog-btn" :disabled="reviewActionBusy" @click="closeEditReviewDialog">取消</button>
|
||||||
|
<button type="button" class="primary-dialog-btn" :disabled="reviewActionBusy" @click="applyEditedReview">
|
||||||
|
{{ reviewActionBusy ? '提交中...' : '确认修改' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,11 @@
|
|||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
|
|
||||||
|
import aiAvatar from '../../assets/header.svg'
|
||||||
|
import userAvatar from '../../assets/person.svg'
|
||||||
import { useSystemState } from '../../composables/useSystemState.js'
|
import { useSystemState } from '../../composables/useSystemState.js'
|
||||||
import { recognizeOcrFiles } from '../../services/ocr.js'
|
import { recognizeOcrFiles } from '../../services/ocr.js'
|
||||||
import { runOrchestrator } from '../../services/orchestrator.js'
|
import { runOrchestrator } from '../../services/orchestrator.js'
|
||||||
|
|
||||||
const DEFAULT_REQUEST = {
|
|
||||||
id: 'BR240712001',
|
|
||||||
reason: '客户方案汇报',
|
|
||||||
city: '上海',
|
|
||||||
period: '07-08 ~ 07-11',
|
|
||||||
applyTime: '2024-07-07',
|
|
||||||
amount: '¥3,680.00',
|
|
||||||
node: '财务复核',
|
|
||||||
approval: '主管审批中',
|
|
||||||
travel: '已订酒店 / 机票'
|
|
||||||
}
|
|
||||||
|
|
||||||
const SOURCE_LABELS = {
|
const SOURCE_LABELS = {
|
||||||
workbench: '来自个人工作台',
|
workbench: '来自个人工作台',
|
||||||
topbar: '来自发起报销',
|
topbar: '来自发起报销',
|
||||||
@@ -87,18 +77,21 @@ function formatMessageTime(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeRequest(request) {
|
function sanitizeRequest(request) {
|
||||||
if (!request) return { ...DEFAULT_REQUEST }
|
if (!request || typeof request !== 'object') return null
|
||||||
return {
|
|
||||||
id: request.id ?? DEFAULT_REQUEST.id,
|
const normalized = {
|
||||||
reason: request.reason ?? DEFAULT_REQUEST.reason,
|
id: String(request.id || '').trim(),
|
||||||
city: request.city ?? DEFAULT_REQUEST.city,
|
reason: String(request.reason || request.title || '').trim(),
|
||||||
period: request.period ?? DEFAULT_REQUEST.period,
|
city: String(request.city || request.location || '').trim(),
|
||||||
applyTime: request.applyTime ?? DEFAULT_REQUEST.applyTime,
|
period: String(request.period || '').trim(),
|
||||||
amount: request.amount ?? DEFAULT_REQUEST.amount,
|
applyTime: String(request.applyTime || request.occurredAt || '').trim(),
|
||||||
node: request.node ?? DEFAULT_REQUEST.node,
|
amount: String(request.amount || '').trim(),
|
||||||
approval: request.approval ?? DEFAULT_REQUEST.approval,
|
node: String(request.node || '').trim(),
|
||||||
travel: request.travel ?? DEFAULT_REQUEST.travel
|
approval: String(request.approval || '').trim(),
|
||||||
|
travel: String(request.travel || '').trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return Object.values(normalized).some(Boolean) ? normalized : null
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveStatusLabel(status) {
|
function resolveStatusLabel(status) {
|
||||||
@@ -223,17 +216,32 @@ function resolveDocumentPreview(filePreviews, filename) {
|
|||||||
function buildWelcomeInsight(entrySource, linkedRequest) {
|
function buildWelcomeInsight(entrySource, linkedRequest) {
|
||||||
return {
|
return {
|
||||||
intent: 'welcome',
|
intent: 'welcome',
|
||||||
metricLabel: '运行模式',
|
metricLabel: '当前状态',
|
||||||
metricValue: 'Ready',
|
metricValue: '待识别',
|
||||||
title: entrySource === 'detail' ? `已关联 ${linkedRequest.id}` : '已接入真实智能体对话',
|
title: entrySource === 'detail' && linkedRequest?.id ? `已关联 ${linkedRequest.id}` : '等待识别内容',
|
||||||
summary:
|
summary:
|
||||||
entrySource === 'detail'
|
entrySource === 'detail' && linkedRequest?.id
|
||||||
? '发送消息后会直接调用 Orchestrator,并返回真实的规则引用、建议动作和草稿结果。'
|
? '发送消息后会直接结合当前单据上下文识别报销语义,并在右侧展示可核对字段。'
|
||||||
: '这里不再使用前端本地意图模拟,所有发送内容都会进入真实 Orchestrator 调度链路。',
|
: '请输入费用场景或上传票据,右侧会展示识别出的报销类型、时间、金额和待补字段。',
|
||||||
agent: null
|
agent: null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildInitialInsightFromConversation(conversation) {
|
||||||
|
const rawMessages = Array.isArray(conversation?.messages) ? conversation.messages : []
|
||||||
|
for (let index = rawMessages.length - 1; index >= 0; index -= 1) {
|
||||||
|
const item = rawMessages[index]
|
||||||
|
const messageJson = item?.message_json || item?.messageJson || {}
|
||||||
|
const orchestratorPayload = messageJson?.orchestrator_payload || null
|
||||||
|
if (!orchestratorPayload) continue
|
||||||
|
const attachmentNames = Array.isArray(messageJson?.attachment_names)
|
||||||
|
? messageJson.attachment_names.filter(Boolean)
|
||||||
|
: []
|
||||||
|
return buildAgentInsight(orchestratorPayload, attachmentNames, [])
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
function resolveInitialConversationId(conversation) {
|
function resolveInitialConversationId(conversation) {
|
||||||
return String(conversation?.conversation_id || conversation?.conversationId || '').trim()
|
return String(conversation?.conversation_id || conversation?.conversationId || '').trim()
|
||||||
}
|
}
|
||||||
@@ -269,6 +277,40 @@ function normalizeInitialConversationMessages(conversation) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cloneReviewEditFields(fields) {
|
||||||
|
const items = Array.isArray(fields) ? fields : []
|
||||||
|
return items.map((item) => ({
|
||||||
|
key: String(item?.key || '').trim(),
|
||||||
|
label: String(item?.label || '').trim(),
|
||||||
|
value: String(item?.value || ''),
|
||||||
|
placeholder: String(item?.placeholder || ''),
|
||||||
|
required: Boolean(item?.required),
|
||||||
|
field_type: String(item?.field_type || item?.fieldType || 'text').trim() || 'text',
|
||||||
|
group: String(item?.group || 'basic').trim() || 'basic'
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReviewFormValues(fields) {
|
||||||
|
return cloneReviewEditFields(fields).reduce((result, item) => {
|
||||||
|
if (!item.key) {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
result[item.key] = String(item.value || '').trim()
|
||||||
|
return result
|
||||||
|
}, {})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReviewCorrectionMessage(fields) {
|
||||||
|
const lines = ['请按以下核对后的报销信息更新当前识别结果:']
|
||||||
|
for (const item of cloneReviewEditFields(fields)) {
|
||||||
|
if (!item.label || (!item.value && !item.required)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines.push(`${item.label}:${String(item.value || '').trim() || '待补充'}`)
|
||||||
|
}
|
||||||
|
return lines.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
function buildErrorInsight(error, fileNames = []) {
|
function buildErrorInsight(error, fileNames = []) {
|
||||||
return {
|
return {
|
||||||
intent: 'agent',
|
intent: 'agent',
|
||||||
@@ -376,14 +418,15 @@ export default {
|
|||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const linkedRequest = computed(() => sanitizeRequest(props.requestContext))
|
const linkedRequest = computed(() => sanitizeRequest(props.requestContext))
|
||||||
const restoredMessages = normalizeInitialConversationMessages(props.initialConversation)
|
const restoredMessages = normalizeInitialConversationMessages(props.initialConversation)
|
||||||
|
const initialInsight = buildInitialInsightFromConversation(props.initialConversation)
|
||||||
const messages = ref(
|
const messages = ref(
|
||||||
restoredMessages.length
|
restoredMessages.length
|
||||||
? restoredMessages
|
? restoredMessages
|
||||||
: [
|
: [
|
||||||
createMessage(
|
createMessage(
|
||||||
'assistant',
|
'assistant',
|
||||||
props.entrySource === 'detail'
|
props.entrySource === 'detail' && linkedRequest.value?.id
|
||||||
? `已进入统一对话工作台,当前关联单据 ${linkedRequest.value.id}。你的提问会直接进入真实智能体链路。`
|
? `已进入统一对话工作台,当前关联单据 ${linkedRequest.value.id}。请描述费用场景或补充票据。`
|
||||||
: '这里是统一对话入口。你发送的内容会直接进入真实 Orchestrator 和 User Agent。'
|
: '这里是统一对话入口。你发送的内容会直接进入真实 Orchestrator 和 User Agent。'
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
@@ -392,14 +435,19 @@ export default {
|
|||||||
const draftClaimId = ref(resolveInitialDraftClaimId(props.initialConversation))
|
const draftClaimId = ref(resolveInitialDraftClaimId(props.initialConversation))
|
||||||
const previewRegistry = []
|
const previewRegistry = []
|
||||||
|
|
||||||
const currentInsight = ref(buildWelcomeInsight(props.entrySource, linkedRequest.value))
|
const currentInsight = ref(initialInsight || buildWelcomeInsight(props.entrySource, linkedRequest.value))
|
||||||
|
const reviewCancelDialogOpen = ref(false)
|
||||||
|
const reviewEditDialogOpen = ref(false)
|
||||||
|
const reviewActionBusy = ref(false)
|
||||||
|
const reviewEditFields = ref([])
|
||||||
|
const reviewActionMessageId = ref('')
|
||||||
const sourceLabel = computed(() => SOURCE_LABELS[props.entrySource] ?? '来自 AI 工作台')
|
const sourceLabel = computed(() => SOURCE_LABELS[props.entrySource] ?? '来自 AI 工作台')
|
||||||
const canSubmit = computed(
|
const canSubmit = computed(
|
||||||
() => !submitting.value && Boolean(composerDraft.value.trim() || attachedFiles.value.length)
|
() => !submitting.value && Boolean(composerDraft.value.trim() || attachedFiles.value.length)
|
||||||
)
|
)
|
||||||
const showInsightPanel = computed(() => currentInsight.value.intent !== 'welcome')
|
const showInsightPanel = computed(() => currentInsight.value.intent !== 'welcome')
|
||||||
const composerPlaceholder = computed(() => {
|
const composerPlaceholder = computed(() => {
|
||||||
if (props.entrySource === 'detail') {
|
if (props.entrySource === 'detail' && linkedRequest.value?.id) {
|
||||||
return `例如:解释一下 ${linkedRequest.value.id} 的报销风险,或帮我生成处理意见草稿。`
|
return `例如:解释一下 ${linkedRequest.value.id} 的报销风险,或帮我生成处理意见草稿。`
|
||||||
}
|
}
|
||||||
return '例如:查一下本周报销金额、解释酒店超标风险,或根据附件生成报销草稿。'
|
return '例如:查一下本周报销金额、解释酒店超标风险,或根据附件生成报销草稿。'
|
||||||
@@ -411,9 +459,28 @@ export default {
|
|||||||
}
|
}
|
||||||
return labels[currentInsight.value.intent] ?? 'AI 处理中'
|
return labels[currentInsight.value.intent] ?? 'AI 处理中'
|
||||||
})
|
})
|
||||||
|
const latestReviewMessage = computed(() =>
|
||||||
|
[...messages.value].reverse().find((item) => item.role === 'assistant' && item.reviewPayload) ?? null
|
||||||
|
)
|
||||||
|
const activeReviewPayload = computed(
|
||||||
|
() => currentInsight.value.agent?.reviewPayload || latestReviewMessage.value?.reviewPayload || null
|
||||||
|
)
|
||||||
|
const activeReviewFilePreviews = computed(
|
||||||
|
() => currentInsight.value.agent?.filePreviews || []
|
||||||
|
)
|
||||||
|
const recognizedSlotCards = computed(() =>
|
||||||
|
Array.isArray(activeReviewPayload.value?.slot_cards)
|
||||||
|
? activeReviewPayload.value.slot_cards.filter((item) => item.status !== 'missing')
|
||||||
|
: []
|
||||||
|
)
|
||||||
|
const missingSlotCards = computed(() =>
|
||||||
|
Array.isArray(activeReviewPayload.value?.slot_cards)
|
||||||
|
? activeReviewPayload.value.slot_cards.filter((item) => item.status === 'missing')
|
||||||
|
: []
|
||||||
|
)
|
||||||
|
|
||||||
const shortcuts = computed(() => {
|
const shortcuts = computed(() => {
|
||||||
if (props.entrySource === 'detail') {
|
if (props.entrySource === 'detail' && linkedRequest.value?.id) {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
label: '解释风险原因',
|
label: '解释风险原因',
|
||||||
@@ -463,7 +530,7 @@ export default {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
currentInsight.value = buildWelcomeInsight(props.entrySource, linkedRequest.value)
|
currentInsight.value = initialInsight || buildWelcomeInsight(props.entrySource, linkedRequest.value)
|
||||||
if (props.initialPrompt?.trim() || props.initialFiles.length) {
|
if (props.initialPrompt?.trim() || props.initialFiles.length) {
|
||||||
composerDraft.value = props.initialPrompt.trim()
|
composerDraft.value = props.initialPrompt.trim()
|
||||||
attachedFiles.value = Array.from(props.initialFiles)
|
attachedFiles.value = Array.from(props.initialFiles)
|
||||||
@@ -525,27 +592,32 @@ export default {
|
|||||||
parts.push(`OCR摘要:${ocrSummary}`)
|
parts.push(`OCR摘要:${ocrSummary}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.entrySource === 'detail') {
|
if (props.entrySource === 'detail' && linkedRequest.value?.id) {
|
||||||
parts.push(`关联单号:${linkedRequest.value.id}`)
|
parts.push(`关联单号:${linkedRequest.value.id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return parts.join('\n')
|
return parts.join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitComposer() {
|
async function submitComposer(options = {}) {
|
||||||
if (!canSubmit.value) return
|
const rawText = String(options.rawText ?? composerDraft.value).trim()
|
||||||
|
const files = Array.from(options.files ?? attachedFiles.value)
|
||||||
|
if (!rawText && !files.length) return
|
||||||
|
|
||||||
const rawText = composerDraft.value.trim()
|
|
||||||
const files = Array.from(attachedFiles.value)
|
|
||||||
const fileNames = files.map((file) => file.name)
|
const fileNames = files.map((file) => file.name)
|
||||||
const filePreviews = buildFilePreviews(files, previewRegistry)
|
const filePreviews = buildFilePreviews(files, previewRegistry)
|
||||||
const userText =
|
const userText =
|
||||||
rawText || `我上传了 ${fileNames.length} 份票据,请帮我识别并给出报销建议。`
|
String(options.userText || '').trim() ||
|
||||||
|
rawText ||
|
||||||
|
`我上传了 ${fileNames.length} 份票据,请帮我识别并给出报销建议。`
|
||||||
|
const extraContext = options.extraContext && typeof options.extraContext === 'object'
|
||||||
|
? options.extraContext
|
||||||
|
: {}
|
||||||
|
|
||||||
messages.value.push(createMessage('user', userText, fileNames))
|
messages.value.push(createMessage('user', userText, fileNames))
|
||||||
|
|
||||||
const pendingMessage = createMessage('assistant', 'Orchestrator 正在处理中...', [], {
|
const pendingMessage = createMessage('assistant', options.pendingText || '正在识别并更新右侧核对信息...', [], {
|
||||||
meta: ['运行中']
|
meta: ['处理中']
|
||||||
})
|
})
|
||||||
messages.value.push(pendingMessage)
|
messages.value.push(pendingMessage)
|
||||||
|
|
||||||
@@ -586,12 +658,13 @@ export default {
|
|||||||
name: user.name || '',
|
name: user.name || '',
|
||||||
role: user.role || '',
|
role: user.role || '',
|
||||||
entry_source: props.entrySource,
|
entry_source: props.entrySource,
|
||||||
request_context: linkedRequest.value,
|
|
||||||
attachment_names: fileNames,
|
attachment_names: fileNames,
|
||||||
attachment_count: fileNames.length,
|
attachment_count: fileNames.length,
|
||||||
draft_claim_id: draftClaimId.value || undefined,
|
draft_claim_id: draftClaimId.value || undefined,
|
||||||
ocr_summary: ocrSummary,
|
ocr_summary: ocrSummary,
|
||||||
ocr_documents: ocrDocuments
|
ocr_documents: ocrDocuments,
|
||||||
|
...(linkedRequest.value ? { request_context: linkedRequest.value } : {}),
|
||||||
|
...extraContext
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -632,8 +705,99 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openCancelReviewDialog(message) {
|
||||||
|
reviewActionMessageId.value = String(message?.id || '')
|
||||||
|
reviewCancelDialogOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCancelReviewDialog() {
|
||||||
|
if (reviewActionBusy.value) return
|
||||||
|
reviewCancelDialogOpen.value = false
|
||||||
|
reviewActionMessageId.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmCancelReview() {
|
||||||
|
if (reviewActionBusy.value) return
|
||||||
|
reviewCancelDialogOpen.value = false
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditReviewDialog(message) {
|
||||||
|
reviewEditFields.value = cloneReviewEditFields(message?.reviewPayload?.edit_fields)
|
||||||
|
reviewActionMessageId.value = String(message?.id || '')
|
||||||
|
reviewEditDialogOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditReviewDialog() {
|
||||||
|
if (reviewActionBusy.value) return
|
||||||
|
reviewEditDialogOpen.value = false
|
||||||
|
reviewEditFields.value = []
|
||||||
|
reviewActionMessageId.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyEditedReview() {
|
||||||
|
if (reviewActionBusy.value) return
|
||||||
|
|
||||||
|
reviewActionBusy.value = true
|
||||||
|
try {
|
||||||
|
const fields = cloneReviewEditFields(reviewEditFields.value)
|
||||||
|
await submitComposer({
|
||||||
|
rawText: buildReviewCorrectionMessage(fields),
|
||||||
|
userText: '我已修改识别信息,请按最新内容更新。',
|
||||||
|
pendingText: '正在根据修改内容重新识别...',
|
||||||
|
extraContext: {
|
||||||
|
review_action: 'edit_review',
|
||||||
|
review_form_values: buildReviewFormValues(fields)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
reviewActionBusy.value = false
|
||||||
|
}
|
||||||
|
closeEditReviewDialog()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReviewAction(message, action) {
|
||||||
|
const actionType = String(action?.action_type || '').trim()
|
||||||
|
if (!actionType || reviewActionBusy.value) return
|
||||||
|
|
||||||
|
if (actionType === 'cancel_review') {
|
||||||
|
openCancelReviewDialog(message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actionType === 'edit_review') {
|
||||||
|
openEditReviewDialog(message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['save_draft', 'next_step'].includes(actionType)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reviewActionBusy.value = true
|
||||||
|
try {
|
||||||
|
const fields = cloneReviewEditFields(message?.reviewPayload?.edit_fields)
|
||||||
|
await submitComposer({
|
||||||
|
rawText:
|
||||||
|
actionType === 'save_draft'
|
||||||
|
? '请按当前已识别信息先保存草稿,缺失字段后续再补。'
|
||||||
|
: '我已核对右侧识别结果,请进入下一步。',
|
||||||
|
userText: actionType === 'save_draft' ? '我先按当前信息保存草稿。' : '我确认当前识别结果,继续下一步。',
|
||||||
|
pendingText: actionType === 'save_draft' ? '正在保存当前草稿...' : '正在进入下一步...',
|
||||||
|
extraContext: {
|
||||||
|
review_action: actionType,
|
||||||
|
review_form_values: buildReviewFormValues(fields)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
reviewActionBusy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
emit,
|
emit,
|
||||||
|
aiAvatar,
|
||||||
|
userAvatar,
|
||||||
fileInputRef,
|
fileInputRef,
|
||||||
messageListRef,
|
messageListRef,
|
||||||
composerDraft,
|
composerDraft,
|
||||||
@@ -647,12 +811,26 @@ export default {
|
|||||||
showInsightPanel,
|
showInsightPanel,
|
||||||
composerPlaceholder,
|
composerPlaceholder,
|
||||||
currentIntentLabel,
|
currentIntentLabel,
|
||||||
|
latestReviewMessage,
|
||||||
|
activeReviewPayload,
|
||||||
|
activeReviewFilePreviews,
|
||||||
|
recognizedSlotCards,
|
||||||
|
missingSlotCards,
|
||||||
|
reviewCancelDialogOpen,
|
||||||
|
reviewEditDialogOpen,
|
||||||
|
reviewActionBusy,
|
||||||
|
reviewEditFields,
|
||||||
shortcuts,
|
shortcuts,
|
||||||
resolveDocumentPreview,
|
resolveDocumentPreview,
|
||||||
triggerFileUpload,
|
triggerFileUpload,
|
||||||
handleFilesChange,
|
handleFilesChange,
|
||||||
runShortcut,
|
runShortcut,
|
||||||
submitComposer
|
submitComposer,
|
||||||
|
handleReviewAction,
|
||||||
|
closeCancelReviewDialog,
|
||||||
|
confirmCancelReview,
|
||||||
|
closeEditReviewDialog,
|
||||||
|
applyEditedReview
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user