feat(web): AI 工作台文件预览/附件关联任务与草稿分支

- 新增 WorkbenchAiFilePreviewDialog 附件预览对话框及 useWorkbenchAiFilePreview,附件支持点击预览
- 新增 attachmentAssociationJobs/linkedReimbursementDraftJobs 前端服务与对应 composable,接入后台任务轮询与状态展示
- 新增 travelReimbursementDraftBranchModel 草稿分支模型,报销关联门控支持跳过/选择草稿
- PersonalWorkbenchAiMode 及各 composable(expense/document/steward/application-preview/attachment-association)重构适配,WorkbenchAiComposer/FileStrip 样式与交互完善
- DocumentsCenter/ReceiptFolder/TravelReimbursementCreate 等视图及 scripts 重构,风险/差旅规划/审批等工具适配
- 新增/更新前端测试:application-result-card、reimbursement-list-preview-fetch、guided-flow、composer-components 等
This commit is contained in:
caoxiaozhu
2026-06-24 10:42:50 +08:00
parent 0264a4b5b4
commit ee730aa31c
73 changed files with 2528 additions and 379 deletions

View File

@@ -286,7 +286,7 @@ const reimbursementTrendHasSignal = computed(() =>
)
const reimbursementTrendRows = computed(() => sourceReimbursementTrendRows.value)
const reimbursementTrendSignalLabel = computed(() =>
reimbursementTrendHasSignal.value ? '来自的真实单据' : '暂无单据时展示空走势'
reimbursementTrendHasSignal.value ? '来自的真实单据' : '暂无单据时展示空走势'
)
const reimbursementTrendLabels = computed(() => reimbursementTrendRows.value.map((item) => item.label))
const reimbursementTrendAmounts = computed(() => reimbursementTrendRows.value.map((item) => item.amount))

View File

@@ -405,6 +405,8 @@
</div>
</Transition>
<WorkbenchAiFilePreviewDialog :runtime="workbenchAiRuntime" />
<Transition name="workbench-ai-confirm-fade">
<div v-if="deleteDialogOpen" class="workbench-ai-confirm-mask" role="presentation" @click.self="cancelDeleteConversation">
<div

View File

@@ -5,6 +5,7 @@ import { proxyRefs } from 'vue'
import orbIcon from '../../assets/workbench-ai-mode-orb-icon.gif'
import WorkbenchAiComposer from './workbench-ai/WorkbenchAiComposer.vue'
import WorkbenchAiFileStrip from './workbench-ai/WorkbenchAiFileStrip.vue'
import WorkbenchAiFilePreviewDialog from './workbench-ai/WorkbenchAiFilePreviewDialog.vue'
import { usePersonalWorkbenchAiMode } from '../../composables/workbenchAiMode/usePersonalWorkbenchAiMode.js'
const props = defineProps({

View File

@@ -23,7 +23,7 @@
v-model="runtime.assistantDraft"
maxlength="1000"
rows="3"
:placeholder="runtime.isAiModeInputLocked ? '费用测算中,请稍等...' : placeholder"
:placeholder="runtime.isAiModeInputLocked ? runtime.aiModeInputLockMessage : placeholder"
:disabled="runtime.isAiModeInputLocked"
@keydown.enter.exact.prevent="runtime.submitAiModePrompt"
></textarea>

View File

@@ -0,0 +1,109 @@
<template>
<Transition name="workbench-ai-preview-fade">
<div
v-if="preview"
class="workbench-ai-file-preview-mask"
role="presentation"
@click.self="runtime.closeAiModeFilePreview"
>
<section class="workbench-ai-file-preview-dialog" role="dialog" aria-modal="true" @click.stop>
<header class="workbench-ai-file-preview-head">
<div>
<span class="workbench-ai-file-preview-badge">附件预览</span>
<h3>{{ preview.name }}</h3>
</div>
<button
type="button"
class="workbench-ai-file-preview-close"
aria-label="关闭附件预览"
@click="runtime.closeAiModeFilePreview"
>
<i class="mdi mdi-close"></i>
</button>
</header>
<div class="workbench-ai-file-preview-body">
<div class="workbench-ai-file-preview-source">
<img
v-if="preview.sourceKind === 'image'"
:src="preview.sourceUrl"
:alt="preview.name"
class="workbench-ai-file-preview-image"
/>
<iframe
v-else-if="preview.sourceKind === 'pdf'"
:src="preview.sourceUrl"
class="workbench-ai-file-preview-frame"
title="附件 PDF 预览"
></iframe>
<div v-else class="workbench-ai-file-preview-state">
<i class="mdi mdi-file-eye-outline"></i>
<span>当前附件暂不支持直接预览</span>
</div>
</div>
<aside class="workbench-ai-file-preview-insight">
<div class="workbench-ai-file-preview-insight-head">
<span>识别信息</span>
<strong>{{ preview.documentTypeLabel }}</strong>
</div>
<div
class="workbench-ai-file-preview-status"
:class="`is-${preview.recognitionStatus}`"
:title="preview.recognitionStatusTitle"
>
<i v-if="preview.recognitionStatus === 'recognizing'" class="mdi mdi-loading"></i>
<i v-else-if="preview.recognitionStatus === 'recognized'" class="mdi mdi-text-recognition"></i>
<i v-else-if="preview.recognitionStatus === 'failed'" class="mdi mdi-alert-circle-outline"></i>
<i v-else class="mdi mdi-file-search-outline"></i>
<span>{{ preview.recognitionStatusLabel }}</span>
</div>
<section class="workbench-ai-file-preview-section">
<span>文件信息</span>
<dl>
<template v-for="row in preview.fileInfoRows" :key="row.label">
<dt>{{ row.label }}</dt>
<dd>{{ row.value }}</dd>
</template>
</dl>
</section>
<section v-if="preview.ocrFields.length" class="workbench-ai-file-preview-section">
<span>字段结果</span>
<dl>
<template v-for="field in preview.ocrFields" :key="`${field.label}-${field.value}`">
<dt>{{ field.label }}</dt>
<dd>{{ field.value }}</dd>
</template>
</dl>
</section>
<section v-if="preview.ocrSummary" class="workbench-ai-file-preview-section">
<span>识别摘要</span>
<p>{{ preview.ocrSummary }}</p>
</section>
<section v-if="preview.rawText" class="workbench-ai-file-preview-section">
<span>OCR 原文</span>
<p>{{ preview.rawText }}</p>
</section>
</aside>
</div>
</section>
</div>
</Transition>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
runtime: { type: Object, required: true }
})
const preview = computed(() => props.runtime.activeAiModeFilePreview || null)
</script>
<style scoped src="../../../assets/styles/components/workbench-ai-file-preview-dialog.css"></style>

View File

@@ -5,7 +5,17 @@
:class="{ inline }"
aria-label="已选择附件"
>
<article v-for="file in runtime.selectedFileCards" :key="file.key" class="workbench-ai-file-card">
<article
v-for="file in runtime.selectedFileCards"
:key="file.key"
class="workbench-ai-file-card"
role="button"
tabindex="0"
:aria-label="`预览附件 ${file.name}`"
@click="runtime.openAiModeFilePreview(file.key)"
@keydown.enter.prevent="runtime.openAiModeFilePreview(file.key)"
@keydown.space.prevent="runtime.openAiModeFilePreview(file.key)"
>
<span class="workbench-ai-file-card__icon" :class="`type-${file.tone}`" aria-hidden="true">
<i :class="file.icon"></i>
</span>
@@ -29,7 +39,7 @@
class="workbench-ai-file-card__remove"
:disabled="runtime.isAiModeInputLocked"
:aria-label="`移除附件 ${file.name}`"
@click="runtime.removeAiModeFile(file.key)"
@click.stop="runtime.removeAiModeFile(file.key)"
>
<i class="mdi mdi-close"></i>
</button>

View File

@@ -741,7 +741,7 @@ function buildFieldStepDescription() {
if (activeSimulationResult.value?.recognized_fields?.length) {
return `已确认 ${activeSimulationResult.value.recognized_fields.length} 个字段。`
}
if (draft.value.trim()) return '将使用输入的文字抽取测试字段。'
if (draft.value.trim()) return '将使用输入的文字抽取测试字段。'
return '识别完成或补充字段后进入确认。'
}
@@ -756,7 +756,7 @@ function buildWelcomeMessage() {
if (requiresAttachment.value) {
return '这条规则要求测试附件。请先上传临时票据并填写测试意图,点击发送后我会统一识别附件和文字,再交给规则执行器判断。'
}
return '这条规则不需要上传附件。可以直接输入测试事实,我只会执行风险识别,不创建单据、不写入主工作台会话。'
return '这条规则不需要上传附件。可以直接输入测试事实,我只会执行风险识别,不创建单据、不写入主工作台会话。'
}
function clearFileInput() {

View File

@@ -505,7 +505,7 @@
<i class="mdi mdi-text-recognition"></i>
</span>
<strong>暂无结构化字段</strong>
<p>当前只返回了摘要信息仍然可以直接修改上面的票据摘要</p>
<p>当前只返回了摘要信息仍然可以直接修改上面的票据摘要</p>
</div>
<div v-if="ui.activeReviewDocument.warnings?.length" class="review-document-warning-list">

View File

@@ -549,7 +549,7 @@
{{ line }}
</p>
<p v-if="ui.canUseInlineSaveDraft(message)" class="review-inline-save-copy">
请核查上面的关键信息您也可以暂时不处理上述的这些内容我可以帮先保存为
请核查上面的关键信息您也可以暂时不处理上述的这些内容我可以帮先保存为
<button
type="button"
class="review-inline-draft-link"