feat(web): update Vue components and composables
- PersonalWorkbench.vue: update personal workbench component - useAppShell.js: update app shell composable - useChat.js: update chat composable with new features - AppShellRouteView.vue: update route view - ChatView.vue: update chat view with enhanced UI - TravelReimbursementCreateView.vue: update travel reimbursement form - ChatView.js: update chat view script logic - TravelReimbursementCreateView.js: update travel form script logic
This commit is contained in:
@@ -1,164 +1,353 @@
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
|
||||
import { useSystemState } from '../../composables/useSystemState.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 = {
|
||||
workbench: '来自个人工作台',
|
||||
topbar: '来自发起报销',
|
||||
detail: '来自智能录入',
|
||||
upload: '来自附件上传',
|
||||
requests: '来自报销列表'
|
||||
}
|
||||
|
||||
const SCENARIO_LABELS = {
|
||||
expense: '报销',
|
||||
accounts_receivable: '应收',
|
||||
accounts_payable: '应付',
|
||||
knowledge: '知识',
|
||||
unknown: '通用'
|
||||
}
|
||||
|
||||
const INTENT_LABELS = {
|
||||
query: '查询',
|
||||
explain: '解释',
|
||||
compare: '对比',
|
||||
risk_check: '风险检查',
|
||||
draft: '草稿生成',
|
||||
operate: '动作请求'
|
||||
}
|
||||
|
||||
let messageSeed = 0
|
||||
|
||||
function nowTime() {
|
||||
return new Date().toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
})
|
||||
}
|
||||
|
||||
function createMessage(role, text, attachments = [], extras = {}) {
|
||||
messageSeed += 1
|
||||
return {
|
||||
id: `msg-${messageSeed}`,
|
||||
role,
|
||||
text,
|
||||
attachments,
|
||||
time: nowTime(),
|
||||
meta: [],
|
||||
citations: [],
|
||||
suggestedActions: [],
|
||||
draftPayload: null,
|
||||
riskFlags: [],
|
||||
...extras
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeRequest(request) {
|
||||
if (!request) return { ...DEFAULT_REQUEST }
|
||||
return {
|
||||
id: request.id ?? DEFAULT_REQUEST.id,
|
||||
reason: request.reason ?? DEFAULT_REQUEST.reason,
|
||||
city: request.city ?? DEFAULT_REQUEST.city,
|
||||
period: request.period ?? DEFAULT_REQUEST.period,
|
||||
applyTime: request.applyTime ?? DEFAULT_REQUEST.applyTime,
|
||||
amount: request.amount ?? DEFAULT_REQUEST.amount,
|
||||
node: request.node ?? DEFAULT_REQUEST.node,
|
||||
approval: request.approval ?? DEFAULT_REQUEST.approval,
|
||||
travel: request.travel ?? DEFAULT_REQUEST.travel
|
||||
}
|
||||
}
|
||||
|
||||
function resolveStatusLabel(status) {
|
||||
if (status === 'succeeded') return '已完成'
|
||||
if (status === 'blocked') return '已阻断'
|
||||
return '失败'
|
||||
}
|
||||
|
||||
function resolveStatusTone(status) {
|
||||
if (status === 'succeeded') return 'success'
|
||||
if (status === 'blocked') return 'warning'
|
||||
return 'note'
|
||||
}
|
||||
|
||||
function buildMessageMeta(payload, fileNames = []) {
|
||||
const items = []
|
||||
|
||||
if (payload?.selected_agent) {
|
||||
items.push(`Agent: ${payload.selected_agent}`)
|
||||
}
|
||||
|
||||
if (payload?.permission_level) {
|
||||
items.push(`权限: ${payload.permission_level}`)
|
||||
}
|
||||
|
||||
if (payload?.trace_summary?.tool_count) {
|
||||
items.push(`工具: ${payload.trace_summary.tool_count}`)
|
||||
}
|
||||
|
||||
if (payload?.trace_summary?.degraded) {
|
||||
items.push('已降级')
|
||||
}
|
||||
|
||||
if (payload?.requires_confirmation) {
|
||||
items.push('待确认')
|
||||
}
|
||||
|
||||
if (payload?.run_id) {
|
||||
items.push(`Run: ${payload.run_id}`)
|
||||
}
|
||||
|
||||
if (fileNames.length) {
|
||||
items.push(`附件: ${fileNames.length}`)
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
function buildWelcomeInsight(entrySource, linkedRequest) {
|
||||
return {
|
||||
intent: 'welcome',
|
||||
metricLabel: '运行模式',
|
||||
metricValue: 'Ready',
|
||||
title: entrySource === 'detail' ? `已关联 ${linkedRequest.id}` : '已接入真实智能体对话',
|
||||
summary:
|
||||
entrySource === 'detail'
|
||||
? '发送消息后会直接调用 Orchestrator,并返回真实的规则引用、建议动作和草稿结果。'
|
||||
: '这里不再使用前端本地意图模拟,所有发送内容都会进入真实 Orchestrator 调度链路。',
|
||||
agent: null
|
||||
}
|
||||
}
|
||||
|
||||
function buildErrorInsight(error, fileNames = []) {
|
||||
return {
|
||||
intent: 'agent',
|
||||
metricLabel: '运行状态',
|
||||
metricValue: '失败',
|
||||
title: '智能体调用失败',
|
||||
summary: error?.message || '无法连接后端 Orchestrator。',
|
||||
agent: {
|
||||
runId: '未生成',
|
||||
selectedAgent: 'orchestrator',
|
||||
scenario: '未知',
|
||||
intent: '未知',
|
||||
permissionLevel: 'unknown',
|
||||
routeReason: 'request_failed',
|
||||
requiresConfirmation: false,
|
||||
degraded: false,
|
||||
fileNames,
|
||||
citations: [],
|
||||
suggestedActions: [],
|
||||
draftPayload: null,
|
||||
riskFlags: [],
|
||||
toolCount: 0,
|
||||
failedToolCount: 0,
|
||||
selectedCapabilityCodes: [],
|
||||
statusLabel: '失败',
|
||||
statusTone: 'note'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildAgentInsight(payload, fileNames = []) {
|
||||
const trace = payload?.trace_summary || {}
|
||||
const result = payload?.result || {}
|
||||
const statusLabel = resolveStatusLabel(payload?.status)
|
||||
|
||||
return {
|
||||
intent: 'agent',
|
||||
metricLabel: '运行状态',
|
||||
metricValue: statusLabel,
|
||||
title:
|
||||
result?.draft_payload?.title ||
|
||||
`${SCENARIO_LABELS[trace?.scenario] || '通用'}${INTENT_LABELS[trace?.intent] || '处理'}结果`,
|
||||
summary: result?.answer || result?.message || '智能体已完成处理。',
|
||||
agent: {
|
||||
runId: payload?.run_id || '未生成',
|
||||
selectedAgent: payload?.selected_agent || 'orchestrator',
|
||||
scenario: SCENARIO_LABELS[trace?.scenario] || trace?.scenario || '未知',
|
||||
intent: INTENT_LABELS[trace?.intent] || trace?.intent || '未知',
|
||||
permissionLevel: payload?.permission_level || 'unknown',
|
||||
routeReason: payload?.route_reason || 'unknown',
|
||||
requiresConfirmation: Boolean(payload?.requires_confirmation),
|
||||
degraded: Boolean(trace?.degraded),
|
||||
fileNames,
|
||||
citations: Array.isArray(result?.citations) ? result.citations : [],
|
||||
suggestedActions: Array.isArray(result?.suggested_actions) ? result.suggested_actions : [],
|
||||
draftPayload: result?.draft_payload || null,
|
||||
riskFlags: Array.isArray(result?.risk_flags) ? result.risk_flags : [],
|
||||
toolCount: Number(trace?.tool_count || 0),
|
||||
failedToolCount: Number(trace?.failed_tool_count || 0),
|
||||
selectedCapabilityCodes: Array.isArray(trace?.selected_capability_codes)
|
||||
? trace.selected_capability_codes
|
||||
: [],
|
||||
statusLabel,
|
||||
statusTone: resolveStatusTone(payload?.status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'TravelReimbursementCreateView',
|
||||
props: {
|
||||
initialPrompt: {
|
||||
type: String,
|
||||
default: ''
|
||||
initialPrompt: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
initialFiles: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
entrySource: {
|
||||
type: String,
|
||||
default: 'requests'
|
||||
},
|
||||
requestContext: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
entrySource: {
|
||||
type: String,
|
||||
default: 'requests'
|
||||
},
|
||||
requestContext: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
emits: ['close'] ,
|
||||
emits: ['close'],
|
||||
setup(props, { emit }) {
|
||||
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 = {
|
||||
workbench: '来自个人工作台',
|
||||
topbar: '来自发起报销',
|
||||
detail: '来自智能录入',
|
||||
upload: '来自附件上传',
|
||||
requests: '来自报销列表'
|
||||
}
|
||||
|
||||
let messageSeed = 0
|
||||
const { currentUser } = useSystemState()
|
||||
|
||||
const fileInputRef = ref(null)
|
||||
const messageListRef = ref(null)
|
||||
const composerDraft = ref('')
|
||||
const attachedFiles = ref([])
|
||||
const submitting = ref(false)
|
||||
const messages = ref([])
|
||||
const currentInsight = ref({
|
||||
intent: 'welcome',
|
||||
confidence: 0,
|
||||
title: '',
|
||||
summary: '',
|
||||
welcome: { cards: [] }
|
||||
})
|
||||
|
||||
const linkedRequest = computed(() => sanitizeRequest(props.requestContext))
|
||||
|
||||
const currentInsight = ref(buildWelcomeInsight(props.entrySource, linkedRequest.value))
|
||||
const sourceLabel = computed(() => SOURCE_LABELS[props.entrySource] ?? '来自 AI 工作台')
|
||||
const canSubmit = computed(() => Boolean(composerDraft.value.trim() || attachedFiles.value.length))
|
||||
const canSubmit = computed(
|
||||
() => !submitting.value && Boolean(composerDraft.value.trim() || attachedFiles.value.length)
|
||||
)
|
||||
const showInsightPanel = computed(() => currentInsight.value.intent !== 'welcome')
|
||||
const composerPlaceholder = computed(() => {
|
||||
if (props.entrySource === 'detail') {
|
||||
return `例如:帮我看一下 ${linkedRequest.value.id} 现在到哪个审批节点,或者补充超标说明。`
|
||||
return `例如:解释一下 ${linkedRequest.value.id} 的报销风险,或帮我生成处理意见草稿。`
|
||||
}
|
||||
return '例如:帮我发起差旅报销、查一下审批节点,或者识别我刚上传的票据。'
|
||||
return '例如:查一下本周报销金额、解释酒店超标风险,或根据附件生成报销草稿。'
|
||||
})
|
||||
const currentIntentLabel = computed(() => {
|
||||
const labels = {
|
||||
welcome: '等待输入',
|
||||
draft: '报销草稿',
|
||||
approval: '审批查询',
|
||||
recognition: '单据识别',
|
||||
note: '补充说明'
|
||||
agent: '真实智能体'
|
||||
}
|
||||
return labels[currentInsight.value.intent] ?? 'AI 处理中'
|
||||
})
|
||||
|
||||
const shortcuts = computed(() => [
|
||||
{ label: '查审批节点', icon: 'mdi mdi-timeline-clock-outline', prompt: `帮我看一下 ${linkedRequest.value.id} 现在到哪个审批节点了` },
|
||||
{ label: '识别上传单据', icon: 'mdi mdi-file-search-outline', prompt: '我上传了几张票据,帮我识别并给出录入结果' },
|
||||
{ label: '补充报销说明', icon: 'mdi mdi-text-box-edit-outline', prompt: `帮我给 ${linkedRequest.value.id} 补一段费用说明` },
|
||||
{ label: '生成报销草稿', icon: 'mdi mdi-file-document-edit-outline', prompt: '我要发起一笔差旅费申请报销,请帮我先生成草稿' }
|
||||
])
|
||||
const shortcuts = computed(() => {
|
||||
if (props.entrySource === 'detail') {
|
||||
return [
|
||||
{
|
||||
label: '解释风险原因',
|
||||
icon: 'mdi mdi-shield-alert-outline',
|
||||
prompt: `解释一下 ${linkedRequest.value.id} 为什么会被拦截`
|
||||
},
|
||||
{
|
||||
label: '生成处理意见',
|
||||
icon: 'mdi mdi-file-document-edit-outline',
|
||||
prompt: `帮我给 ${linkedRequest.value.id} 生成处理意见草稿`
|
||||
},
|
||||
{
|
||||
label: '列出补件清单',
|
||||
icon: 'mdi mdi-format-list-checks',
|
||||
prompt: `帮我列出 ${linkedRequest.value.id} 还需要补哪些附件`
|
||||
},
|
||||
{
|
||||
label: '引用相关制度',
|
||||
icon: 'mdi mdi-book-open-variant-outline',
|
||||
prompt: `解释一下 ${linkedRequest.value.id} 相关的报销制度依据`
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: '查本周报销金额',
|
||||
icon: 'mdi mdi-cash-multiple',
|
||||
prompt: '查一下本周报销金额'
|
||||
},
|
||||
{
|
||||
label: '解释报销风险',
|
||||
icon: 'mdi mdi-shield-alert-outline',
|
||||
prompt: '为什么酒店超标报销不能直接通过'
|
||||
},
|
||||
{
|
||||
label: '生成报销草稿',
|
||||
icon: 'mdi mdi-file-document-edit-outline',
|
||||
prompt: '帮我生成一份差旅报销草稿'
|
||||
},
|
||||
{
|
||||
label: '查待付款金额',
|
||||
icon: 'mdi mdi-bank-transfer-out',
|
||||
prompt: '供应商B待付款多少'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
messages.value = [
|
||||
createMessage(
|
||||
'assistant',
|
||||
buildGreeting(),
|
||||
[]
|
||||
props.entrySource === 'detail'
|
||||
? `已进入统一对话工作台,当前关联单据 ${linkedRequest.value.id}。你的提问会直接进入真实智能体链路。`
|
||||
: '这里是统一对话入口。你发送的内容会直接进入真实 Orchestrator 和 User Agent。'
|
||||
)
|
||||
]
|
||||
|
||||
onMounted(() => {
|
||||
currentInsight.value = buildWelcomeInsight()
|
||||
if (props.initialPrompt?.trim()) {
|
||||
currentInsight.value = buildWelcomeInsight(props.entrySource, linkedRequest.value)
|
||||
if (props.initialPrompt?.trim() || props.initialFiles.length) {
|
||||
composerDraft.value = props.initialPrompt.trim()
|
||||
attachedFiles.value = Array.from(props.initialFiles)
|
||||
submitComposer()
|
||||
} else {
|
||||
nextTick(scrollToBottom)
|
||||
}
|
||||
})
|
||||
|
||||
function sanitizeRequest(request) {
|
||||
if (!request) return { ...DEFAULT_REQUEST }
|
||||
return {
|
||||
id: request.id ?? DEFAULT_REQUEST.id,
|
||||
reason: request.reason ?? DEFAULT_REQUEST.reason,
|
||||
city: request.city ?? DEFAULT_REQUEST.city,
|
||||
period: request.period ?? DEFAULT_REQUEST.period,
|
||||
applyTime: request.applyTime ?? DEFAULT_REQUEST.applyTime,
|
||||
amount: request.amount ?? DEFAULT_REQUEST.amount,
|
||||
node: request.node ?? DEFAULT_REQUEST.node,
|
||||
approval: request.approval ?? DEFAULT_REQUEST.approval,
|
||||
travel: request.travel ?? DEFAULT_REQUEST.travel
|
||||
}
|
||||
function scrollToBottom() {
|
||||
if (!messageListRef.value) return
|
||||
messageListRef.value.scrollTop = messageListRef.value.scrollHeight
|
||||
}
|
||||
|
||||
function buildGreeting() {
|
||||
if (props.entrySource === 'detail') {
|
||||
return `已进入统一对话工作台,当前关联单据 ${linkedRequest.value.id}。你可以直接问审批节点、补充说明,或继续上传票据。`
|
||||
function replaceMessage(messageId, nextMessage) {
|
||||
const index = messages.value.findIndex((item) => item.id === messageId)
|
||||
if (index === -1) {
|
||||
messages.value.push(nextMessage)
|
||||
return
|
||||
}
|
||||
return '这里是统一对话入口。你可以直接发起报销、查询审批节点,或者上传单据让我识别。'
|
||||
}
|
||||
|
||||
function buildWelcomeInsight() {
|
||||
return {
|
||||
intent: 'welcome',
|
||||
confidence: 86,
|
||||
title: props.entrySource === 'detail' ? `已关联 ${linkedRequest.value.id}` : '先告诉我你要处理什么',
|
||||
summary: props.entrySource === 'detail'
|
||||
? '右侧会跟随你的提问切换成审批状态、识别结果或补充说明界面。'
|
||||
: '无论是发起报销、查审批还是识别票据,这里都共用一个对话入口。',
|
||||
welcome: {
|
||||
cards: [
|
||||
{ icon: 'mdi mdi-timeline-clock-outline', title: '审批查询', desc: '识别到审批、节点、状态等意图时,右侧切到流程状态。' },
|
||||
{ icon: 'mdi mdi-file-search-outline', title: '票据识别', desc: '上传附件后展示识别结果、建议金额和缺失材料。' },
|
||||
{ icon: 'mdi mdi-text-box-check-outline', title: '补充说明', desc: '补充超标、夜间交通、业务招待等说明时,右侧给出结构化备注。' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createMessage(role, text, attachments = []) {
|
||||
messageSeed += 1
|
||||
return {
|
||||
id: `msg-${messageSeed}`,
|
||||
role,
|
||||
text,
|
||||
attachments,
|
||||
time: nowTime()
|
||||
}
|
||||
}
|
||||
|
||||
function nowTime() {
|
||||
return new Date().toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
})
|
||||
messages.value.splice(index, 1, nextMessage)
|
||||
}
|
||||
|
||||
function triggerFileUpload() {
|
||||
if (submitting.value) return
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
@@ -171,18 +360,43 @@ export default {
|
||||
submitComposer()
|
||||
}
|
||||
|
||||
function submitComposer() {
|
||||
function buildBackendMessage(rawText, fileNames) {
|
||||
const parts = []
|
||||
const normalizedText = String(rawText || '').trim()
|
||||
|
||||
if (normalizedText) {
|
||||
parts.push(normalizedText)
|
||||
} else if (fileNames.length) {
|
||||
parts.push(`我上传了 ${fileNames.length} 份票据,请结合附件名称给出报销建议并尽量生成草稿。`)
|
||||
}
|
||||
|
||||
if (fileNames.length) {
|
||||
parts.push(`附件名称:${fileNames.join('、')}`)
|
||||
}
|
||||
|
||||
if (props.entrySource === 'detail') {
|
||||
parts.push(`关联单号:${linkedRequest.value.id}`)
|
||||
}
|
||||
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
async function submitComposer() {
|
||||
if (!canSubmit.value) return
|
||||
|
||||
const rawText = composerDraft.value.trim()
|
||||
const fileNames = attachedFiles.value.map((file) => file.name)
|
||||
const userText = rawText || `我上传了 ${fileNames.length} 份单据,请帮我识别并录入。`
|
||||
const files = Array.from(attachedFiles.value)
|
||||
const fileNames = files.map((file) => file.name)
|
||||
const userText =
|
||||
rawText || `我上传了 ${fileNames.length} 份票据,请帮我识别并给出报销建议。`
|
||||
const backendMessage = buildBackendMessage(rawText, fileNames)
|
||||
|
||||
messages.value.push(createMessage('user', userText, fileNames))
|
||||
|
||||
const insight = analyzeIntent(userText, fileNames)
|
||||
currentInsight.value = insight
|
||||
messages.value.push(createMessage('assistant', insight.reply))
|
||||
const pendingMessage = createMessage('assistant', 'Orchestrator 正在处理中...', [], {
|
||||
meta: ['运行中']
|
||||
})
|
||||
messages.value.push(pendingMessage)
|
||||
|
||||
composerDraft.value = ''
|
||||
attachedFiles.value = []
|
||||
@@ -190,232 +404,57 @@ export default {
|
||||
fileInputRef.value.value = ''
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
nextTick(scrollToBottom)
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
if (!messageListRef.value) return
|
||||
messageListRef.value.scrollTop = messageListRef.value.scrollHeight
|
||||
}
|
||||
|
||||
function analyzeIntent(text, files) {
|
||||
if (isRecognitionIntent(text, files)) return buildRecognitionInsight(text, files)
|
||||
if (isApprovalIntent(text)) return buildApprovalInsight(text)
|
||||
if (isNoteIntent(text)) return buildNoteInsight(text)
|
||||
return buildDraftInsight(text, files)
|
||||
}
|
||||
|
||||
function isRecognitionIntent(text, files) {
|
||||
return files.length > 0 || /(上传|附件|票据|发票|单据|识别|ocr)/i.test(text)
|
||||
}
|
||||
|
||||
function isApprovalIntent(text) {
|
||||
return /(审批|节点|状态|进度|流程|卡在哪|到哪了|通过了吗|驳回)/.test(text)
|
||||
}
|
||||
|
||||
function isNoteIntent(text) {
|
||||
return /(说明|备注|补充|原因|超标|夜间|特殊情况|备注一下)/.test(text)
|
||||
}
|
||||
|
||||
function buildApprovalInsight(text) {
|
||||
const requestId = extractRequestId(text) || linkedRequest.value.id
|
||||
const timeline = [
|
||||
{ label: '提交申请', time: `${linkedRequest.value.applyTime} 09:18`, state: 'done' },
|
||||
{ label: '票据识别', time: `${linkedRequest.value.applyTime} 09:22`, state: 'done' },
|
||||
{ label: '直属主管审批', time: '今天 10:46', state: 'done' },
|
||||
{ label: linkedRequest.value.node, time: '进行中', state: 'current' },
|
||||
{ label: '归档入账', time: '待处理', state: 'pending' }
|
||||
]
|
||||
|
||||
return {
|
||||
intent: 'approval',
|
||||
confidence: 95,
|
||||
title: `${requestId} 的审批状态`,
|
||||
summary: `当前在 ${linkedRequest.value.node},右侧已经切到流程状态界面。`,
|
||||
reply: `我识别到你是在查询审批节点。${requestId} 当前处于 ${linkedRequest.value.node},下一步预计由财务在今天 17:30 前处理。`,
|
||||
status: {
|
||||
requestId,
|
||||
currentStatus: linkedRequest.value.approval,
|
||||
currentNode: linkedRequest.value.node,
|
||||
nextOwner: '财务共享中心 · 王敏',
|
||||
eta: '今天 17:30 前',
|
||||
timeline,
|
||||
actions: [
|
||||
'若 17:30 后仍未推进,可提醒财务共享中心处理。',
|
||||
'当前不建议重复提交,避免流程串单。',
|
||||
'如果要补充说明,直接在当前对话里继续输入即可。'
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildRecognitionInsight(text, files) {
|
||||
const requestId = extractRequestId(text) || linkedRequest.value.id
|
||||
const receipts = buildReceiptItems(text, files)
|
||||
const total = receipts.reduce((sum, item) => sum + parseCurrency(item.amount), 0)
|
||||
const amount = formatCurrency(total || guessAmount(text) || 3680)
|
||||
const completeness = files.length >= 2 ? '资料较完整' : '仍需补件'
|
||||
|
||||
return {
|
||||
intent: 'recognition',
|
||||
confidence: files.length ? 97 : 90,
|
||||
title: '已切换到单据识别视图',
|
||||
summary: `识别到 ${receipts.length} 条候选费用,建议关联到 ${requestId}。`,
|
||||
reply: `我识别到你是在上传或识别单据。右侧已经展示识别结果、建议金额和缺失材料。`,
|
||||
recognition: {
|
||||
state: files.length ? '识别完成' : '待补附件',
|
||||
requestId,
|
||||
fileCount: Math.max(files.length, 1),
|
||||
amount,
|
||||
completeness,
|
||||
receipts,
|
||||
suggestions: [
|
||||
files.length ? '可直接生成费用明细草稿。' : '建议补传票据原件,识别结果会更稳定。',
|
||||
'金额和费用分类已经给出,确认后即可写入报销单。',
|
||||
'如果有多张单据属于同一行程,可以继续上传,右侧会合并结果。'
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildNoteInsight(text) {
|
||||
const requestId = extractRequestId(text) || linkedRequest.value.id
|
||||
const noteType = /超标|夜间/.test(text) ? '特殊场景说明' : '补充报销说明'
|
||||
const generatedNote = /超标|夜间/.test(text)
|
||||
? '因客户会议结束较晚,产生夜间交通费用,已保留行程截图与打车凭证,申请按实际发生金额报销。'
|
||||
: '本次费用与客户现场沟通及方案汇报直接相关,单据与行程已对应关联,请按当前草稿继续流转。'
|
||||
|
||||
return {
|
||||
intent: 'note',
|
||||
confidence: 93,
|
||||
title: `${requestId} 的补充说明`,
|
||||
summary: `识别到你是在补充备注,右侧切到说明整理界面。`,
|
||||
reply: `我识别到你是在补充说明。右侧已经生成结构化备注,可直接作为对应单号的附加说明。`,
|
||||
note: {
|
||||
requestId,
|
||||
state: noteType,
|
||||
generatedNote,
|
||||
impacts: [
|
||||
'会同步显示给当前审批节点处理人。',
|
||||
'若涉及超标或夜间交通,审批意见会优先查看这段说明。',
|
||||
'继续补充金额、参与人或业务背景时,我会自动更新说明版本。'
|
||||
],
|
||||
owner: linkedRequest.value.node,
|
||||
nextAction: '继续补充或提交当前说明'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildDraftInsight(text, files) {
|
||||
const requestId = linkedRequest.value.id
|
||||
const items = buildDraftItems(text, files)
|
||||
const total = items.reduce((sum, item) => sum + parseCurrency(item.amount), 0)
|
||||
|
||||
return {
|
||||
intent: 'draft',
|
||||
confidence: 91,
|
||||
title: '已切换到报销草稿视图',
|
||||
summary: '识别到你是在发起报销或继续填写草稿,右侧展示当前建议明细。',
|
||||
reply: '我识别到你是在发起或继续整理报销。右侧已经切到草稿视图,展示建议费用明细和待补信息。',
|
||||
draft: {
|
||||
state: files.length ? '可继续完善' : '草稿已生成',
|
||||
requestId,
|
||||
type: inferDraftType(text),
|
||||
amount: formatCurrency(total || guessAmount(text) || 3280),
|
||||
progress: files.length ? '已录入基础信息' : '待补票据',
|
||||
items,
|
||||
missing: [
|
||||
'补充至少一份原始票据或行程截图。',
|
||||
'确认出差事由、城市和发生日期是否完整。',
|
||||
'如有业务招待或特殊交通,请补充关联说明。'
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractRequestId(text) {
|
||||
return text.match(/BR\d{6,}/i)?.[0] ?? ''
|
||||
}
|
||||
|
||||
function inferDraftType(text) {
|
||||
if (/招待|客户|用餐/.test(text)) return '业务招待报销'
|
||||
if (/交通|打车|高铁|机票/.test(text)) return '交通费用报销'
|
||||
return '差旅费申请报销'
|
||||
}
|
||||
|
||||
function buildDraftItems(text, files) {
|
||||
const items = []
|
||||
|
||||
if (/高铁|火车|车票/.test(text)) {
|
||||
items.push({ name: '高铁 / 火车票', desc: '建议录入为城际交通', amount: '¥236.00', tag: '交通' })
|
||||
}
|
||||
if (/机票|航班/.test(text)) {
|
||||
items.push({ name: '机票', desc: '建议录入为航空出行', amount: '¥1,280.00', tag: '交通' })
|
||||
}
|
||||
if (/酒店|住宿/.test(text)) {
|
||||
items.push({ name: '酒店住宿', desc: '建议录入为住宿费用', amount: '¥780.00', tag: '住宿' })
|
||||
}
|
||||
if (/打车|出租车|网约车/.test(text)) {
|
||||
items.push({ name: '市内交通', desc: '建议合并同日打车订单', amount: '¥126.00', tag: '交通' })
|
||||
}
|
||||
if (/餐|招待|客户/.test(text)) {
|
||||
items.push({ name: '业务招待', desc: '建议补充参与人和业务目的', amount: '¥860.00', tag: '招待' })
|
||||
}
|
||||
|
||||
if (!items.length) {
|
||||
items.push({
|
||||
name: '差旅综合费用',
|
||||
desc: files.length ? '已根据附件生成候选明细' : '根据描述先生成一版草稿',
|
||||
amount: files.length ? '¥3,280.00' : '¥2,680.00',
|
||||
tag: '草稿'
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
function buildReceiptItems(text, files) {
|
||||
if (files.length) {
|
||||
return files.map((file, index) => {
|
||||
const type = inferFileType(file, text, index)
|
||||
const baseAmount = guessAmount(file) || guessAmount(text) || (index + 1) * 180 + 120
|
||||
return {
|
||||
name: file,
|
||||
type,
|
||||
amount: formatCurrency(baseAmount),
|
||||
confidence: `${92 - index}%`
|
||||
try {
|
||||
const user = currentUser.value || {}
|
||||
const payload = await runOrchestrator({
|
||||
source: 'user_message',
|
||||
user_id: user.username || user.name || 'anonymous',
|
||||
message: backendMessage,
|
||||
context_json: {
|
||||
role_codes: Array.isArray(user.roleCodes) ? user.roleCodes : [],
|
||||
is_admin: Boolean(user.isAdmin),
|
||||
name: user.name || '',
|
||||
role: user.role || '',
|
||||
entry_source: props.entrySource,
|
||||
request_context: linkedRequest.value,
|
||||
attachment_names: fileNames,
|
||||
attachment_count: fileNames.length
|
||||
}
|
||||
})
|
||||
|
||||
replaceMessage(
|
||||
pendingMessage.id,
|
||||
createMessage('assistant', payload?.result?.answer || payload?.result?.message || '智能体已完成处理。', [], {
|
||||
meta: buildMessageMeta(payload, fileNames),
|
||||
citations: Array.isArray(payload?.result?.citations) ? payload.result.citations : [],
|
||||
suggestedActions: Array.isArray(payload?.result?.suggested_actions)
|
||||
? payload.result.suggested_actions
|
||||
: [],
|
||||
draftPayload: payload?.result?.draft_payload || null,
|
||||
riskFlags: Array.isArray(payload?.result?.risk_flags) ? payload.result.risk_flags : []
|
||||
})
|
||||
)
|
||||
currentInsight.value = buildAgentInsight(payload, fileNames)
|
||||
} catch (error) {
|
||||
replaceMessage(
|
||||
pendingMessage.id,
|
||||
createMessage(
|
||||
'assistant',
|
||||
error?.message || '无法连接后端 Orchestrator,请稍后重试。',
|
||||
[],
|
||||
{
|
||||
meta: ['调用失败']
|
||||
}
|
||||
)
|
||||
)
|
||||
currentInsight.value = buildErrorInsight(error, fileNames)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
nextTick(scrollToBottom)
|
||||
}
|
||||
|
||||
return buildDraftItems(text, files).map((item, index) => ({
|
||||
name: item.name,
|
||||
type: item.tag,
|
||||
amount: item.amount,
|
||||
confidence: `${94 - index}%`
|
||||
}))
|
||||
}
|
||||
|
||||
function inferFileType(fileName, text, index) {
|
||||
const name = `${fileName} ${text}`
|
||||
if (/酒店|住宿/.test(name)) return '住宿单据'
|
||||
if (/机票|航班/.test(name)) return '航空出行'
|
||||
if (/高铁|火车|车票/.test(name)) return '城际交通'
|
||||
if (/打车|出租车|网约车/.test(name)) return '市内交通'
|
||||
return index === 0 ? '费用主票据' : '补充附件'
|
||||
}
|
||||
|
||||
function guessAmount(text) {
|
||||
const match = String(text).match(/(\d+(?:\.\d{1,2})?)/)
|
||||
return match ? Number.parseFloat(match[1]) : 0
|
||||
}
|
||||
|
||||
function parseCurrency(value) {
|
||||
return Number.parseFloat(String(value).replace(/[^\d.]/g, '')) || 0
|
||||
}
|
||||
|
||||
function formatCurrency(value) {
|
||||
return `¥${Number(value).toFixed(2)}`
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -424,6 +463,7 @@ export default {
|
||||
messageListRef,
|
||||
composerDraft,
|
||||
attachedFiles,
|
||||
submitting,
|
||||
messages,
|
||||
currentInsight,
|
||||
linkedRequest,
|
||||
@@ -440,4 +480,3 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user