- AppShellRouteView.vue: update app shell route view - AuditView.vue: update audit view - EmployeeManagementView.vue: update employee management view - PoliciesView.vue: update policies view - RequestsView.vue: update requests view - TravelReimbursementCreateView.vue: update travel form view - TravelRequestDetailView.vue: update travel detail view
553 lines
28 KiB
Vue
553 lines
28 KiB
Vue
<template>
|
||
<Teleport to="body">
|
||
<Transition name="assistant-modal">
|
||
<div class="assistant-overlay">
|
||
<section class="assistant-modal">
|
||
<div class="assistant-modal-stage">
|
||
<header class="assistant-header">
|
||
<div class="assistant-header-main">
|
||
<span class="assistant-badge">AI Workspace</span>
|
||
<div>
|
||
<h2>统一对话工作台</h2>
|
||
<p>个人工作台、发起报销、智能录入统一走这里,右侧会根据你的意图实时切换状态视图。</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="assistant-header-actions">
|
||
<span class="source-pill">
|
||
<i class="mdi mdi-arrow-u-left-top"></i>
|
||
{{ sourceLabel }}
|
||
</span>
|
||
<button class="close-btn" type="button" aria-label="关闭对话工作台" @click="emit('close')">
|
||
<i class="mdi mdi-close"></i>
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="assistant-layout" :class="{ 'has-insight': showInsightPanel }">
|
||
<section class="dialog-panel">
|
||
<div class="dialog-toolbar">
|
||
<button
|
||
v-for="shortcut in shortcuts"
|
||
:key="shortcut.label"
|
||
type="button"
|
||
class="shortcut-chip"
|
||
@click="runShortcut(shortcut.prompt)"
|
||
>
|
||
<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' ? 'AI 助手头像' : '用户头像'"
|
||
/>
|
||
</span>
|
||
|
||
<div class="message-bubble">
|
||
<header class="message-meta">
|
||
<strong>{{ message.role === 'assistant' ? 'AI 助手' : '我' }}</strong>
|
||
<time>{{ message.time }}</time>
|
||
</header>
|
||
<p v-if="!(message.role === 'assistant' && message.reviewPayload)">{{ message.text }}</p>
|
||
|
||
<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>
|
||
</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>
|
||
|
||
<div v-if="message.role === 'assistant' && !message.reviewPayload && message.citations?.length" class="message-detail-block">
|
||
<strong>引用依据</strong>
|
||
<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>
|
||
</div>
|
||
|
||
<div v-if="message.role === 'assistant' && !message.reviewPayload && message.suggestedActions?.length" class="message-detail-block">
|
||
<strong>建议动作</strong>
|
||
<div class="message-detail-chip-row">
|
||
<span
|
||
v-for="item in message.suggestedActions"
|
||
:key="`${message.id}-${item.action_type}-${item.label}`"
|
||
class="message-action-chip"
|
||
>
|
||
{{ item.label }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="message.role === 'assistant' && message.reviewPayload" class="message-detail-block review-message-block">
|
||
<div class="review-card-shell">
|
||
<div class="review-card-head">
|
||
<div class="review-card-head-main">
|
||
<span class="review-card-icon">
|
||
<i class="mdi mdi-check-decagram"></i>
|
||
</span>
|
||
<div class="review-card-head-copy">
|
||
<strong>{{ buildReviewHeadline(message.reviewPayload, message.draftPayload) }}</strong>
|
||
<p>{{ buildReviewSubline(message.reviewPayload, message.draftPayload) }}</p>
|
||
</div>
|
||
</div>
|
||
<span class="review-card-state" :class="buildReviewStateTone(message.reviewPayload, message.draftPayload)">
|
||
{{ buildReviewStateLabel(message.reviewPayload, message.draftPayload) }}
|
||
</span>
|
||
</div>
|
||
|
||
<div class="review-flow-card">
|
||
<div v-if="buildReviewAlertChips(message.reviewPayload).length" class="review-alert-chip-row subtle">
|
||
<span
|
||
v-for="item in buildReviewAlertChips(message.reviewPayload)"
|
||
:key="`${message.id}-${item.key}`"
|
||
class="review-alert-chip"
|
||
:class="item.tone"
|
||
>
|
||
<i :class="item.tone === 'danger' ? 'mdi mdi-alert-circle' : item.tone === 'success' ? 'mdi mdi-check-circle' : 'mdi mdi-alert'"></i>
|
||
{{ item.label }}
|
||
</span>
|
||
</div>
|
||
|
||
<div class="review-section-head review-flow-head">
|
||
<strong>待补充内容</strong>
|
||
<span>{{ buildReviewTodoItems(message.reviewPayload).length ? `${buildReviewTodoItems(message.reviewPayload).length} 项` : '已齐全' }}</span>
|
||
</div>
|
||
<div class="review-pending-list plain">
|
||
<article
|
||
v-for="item in buildReviewTodoItems(message.reviewPayload)"
|
||
:key="`${message.id}-${item.key}`"
|
||
class="review-pending-item"
|
||
>
|
||
<span class="review-pending-icon">
|
||
<i :class="item.icon"></i>
|
||
</span>
|
||
<div class="review-pending-copy">
|
||
<strong>{{ item.title }}</strong>
|
||
<p>{{ item.hint }}</p>
|
||
</div>
|
||
<span class="review-pending-status" :class="item.tone">{{ item.status }}</span>
|
||
</article>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="resolveReviewPrimaryAction(message.reviewPayload) || message.draftPayload?.claim_no" class="review-footer-actions">
|
||
<div class="review-footer-btn-row">
|
||
<button
|
||
v-if="resolveReviewPrimaryAction(message.reviewPayload)"
|
||
type="button"
|
||
class="review-footer-btn primary"
|
||
:disabled="reviewActionBusy"
|
||
@click="handleReviewAction(message, resolveReviewPrimaryAction(message.reviewPayload))"
|
||
>
|
||
{{ buildReviewPrimaryButtonLabel(message.reviewPayload, message.draftPayload) }}
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
class="review-footer-btn"
|
||
:disabled="submitting || reviewActionBusy"
|
||
@click="triggerFileUpload"
|
||
>
|
||
{{ message.reviewPayload.document_cards?.length ? '继续上传票据' : '上传票据' }}
|
||
</button>
|
||
|
||
<button
|
||
v-if="message.draftPayload?.claim_no"
|
||
type="button"
|
||
class="review-footer-btn"
|
||
:disabled="submitting || reviewActionBusy"
|
||
@click="queryDraftByClaimNo(message.draftPayload.claim_no)"
|
||
>
|
||
查看草稿 {{ message.draftPayload.claim_no }}
|
||
</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 class="composer-shell">
|
||
<textarea
|
||
v-model="composerDraft"
|
||
rows="3"
|
||
:placeholder="composerPlaceholder"
|
||
:disabled="submitting || reviewActionBusy"
|
||
@keydown.enter.exact.stop
|
||
@keydown.ctrl.enter.prevent="submitComposer"
|
||
/>
|
||
|
||
<div v-if="attachedFiles.length" class="composer-files">
|
||
<span v-for="file in attachedFiles" :key="file.name" class="file-chip active">
|
||
<i class="mdi mdi-paperclip"></i>
|
||
{{ file.name }}
|
||
</span>
|
||
</div>
|
||
|
||
<div class="composer-foot">
|
||
<div class="composer-tools">
|
||
<button type="button" class="tool-btn" :disabled="submitting || reviewActionBusy" aria-label="上传附件" @click="triggerFileUpload">
|
||
<i class="mdi mdi-paperclip"></i>
|
||
</button>
|
||
<span class="composer-tip">Enter 换行,Ctrl + Enter 发送</span>
|
||
</div>
|
||
|
||
<button class="send-btn" type="submit" :disabled="!canSubmit || reviewActionBusy" aria-label="发送">
|
||
<i :class="submitting ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-send'"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</section>
|
||
|
||
<Transition name="insight-panel">
|
||
<aside v-if="showInsightPanel" class="insight-panel">
|
||
<div class="insight-head" :class="{ 'review-mode': activeReviewPayload }">
|
||
<div>
|
||
<div v-if="!activeReviewPayload" class="insight-head-eyebrow">
|
||
<span class="intent-pill" :class="currentInsight.intent">{{ currentIntentLabel }}</span>
|
||
</div>
|
||
<div v-else class="review-insight-title-row">
|
||
<h3>报销识别核对</h3>
|
||
<span class="insight-head-badge">实时意图分析</span>
|
||
</div>
|
||
<h3 v-if="!activeReviewPayload">{{ currentInsight.title }}</h3>
|
||
<p v-if="!activeReviewPayload">{{ currentInsight.summary }}</p>
|
||
</div>
|
||
|
||
<div class="confidence-card" v-if="!activeReviewPayload">
|
||
<span>{{ currentInsight.metricLabel }}</span>
|
||
<strong>{{ currentInsight.metricValue }}</strong>
|
||
</div>
|
||
</div>
|
||
|
||
<Transition name="insight-switch" mode="out-in">
|
||
<div :key="currentInsight.intent + currentInsight.title" class="insight-body">
|
||
<template v-if="currentInsight.intent === 'agent' && currentInsight.agent">
|
||
<template v-if="activeReviewPayload">
|
||
<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])
|
||
}"
|
||
@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 === '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>
|
||
</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>
|
||
|
||
<section class="review-side-card review-side-risk-card">
|
||
<div class="review-side-head">
|
||
<strong>合规提醒 / 风险评分</strong>
|
||
<span class="review-side-risk-score" :class="{ empty: reviewRiskScore === null }">
|
||
{{ reviewRiskScore === null ? '无' : `${reviewRiskScore}/100` }}
|
||
</span>
|
||
</div>
|
||
<p class="review-side-risk-summary">{{ reviewRiskSummary }}</p>
|
||
<ul v-if="reviewRiskItems.length" class="review-side-risk-list">
|
||
<li v-for="item in reviewRiskItems" :key="item">{{ item }}</li>
|
||
</ul>
|
||
<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>
|
||
<button
|
||
v-if="reviewRiskActionAvailable"
|
||
type="button"
|
||
class="review-side-link"
|
||
:disabled="submitting || reviewActionBusy"
|
||
@click="explainCurrentReviewRisk"
|
||
>
|
||
查看全部风险项
|
||
<i class="mdi mdi-chevron-right"></i>
|
||
</button>
|
||
</section>
|
||
|
||
<button
|
||
v-if="reviewInlineDirty"
|
||
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 && !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>
|
||
</Transition>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</Transition>
|
||
|
||
<ConfirmDialog
|
||
:open="reviewCancelDialogOpen"
|
||
badge="取消核对"
|
||
badge-tone="warning"
|
||
title="确认放弃本次识别结果?"
|
||
description="关闭后将退出当前核对窗口,本次尚未确认的修改不会继续保留。"
|
||
cancel-text="返回继续核对"
|
||
confirm-text="确认取消"
|
||
busy-text="处理中..."
|
||
confirm-tone="danger"
|
||
confirm-icon="mdi mdi-close-circle-outline"
|
||
:busy="reviewActionBusy"
|
||
@close="closeCancelReviewDialog"
|
||
@confirm="confirmCancelReview"
|
||
/>
|
||
|
||
<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>
|
||
</template>
|
||
|
||
<script src="./scripts/TravelReimbursementCreateView.js"></script>
|
||
|
||
<style scoped src="../assets/styles/views/travel-reimbursement-create-view.css"></style>
|