Files
X-Financial/web/src/components/business/PersonalWorkbench.vue
caoxiaozhu 1cbf3fee44 feat: 报销预审会话状态管理与工作台交互增强
- 新增差旅报销会话状态管理与对话模型重构
- 增强风险观测服务与运行时聊天上下文作用域
- 优化工作台图标资源、助理意图识别与摘要工具
- 完善报销创建视图样式与差旅详情页标准调整交互
- 补充风险观测、运行时聊天与报销端点测试覆盖
2026-06-04 11:03:29 +08:00

901 lines
30 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<section class="workbench" aria-label="个人工作台">
<PanelHead
v-if="showHeader"
eyebrow="Personal Workspace"
title="个人工作台"
note="把费用申请、报销进度、制度问答和待办处理集中到一个入口。"
/>
<article class="panel assistant-hero" :style="{ '--assistant-bg-image': `url(${workbenchHeroBackground})` }">
<div class="assistant-copy">
<h1>{{ displayUserName }}我是您的 <span>小财管家</span></h1>
<input
ref="fileInputRef"
class="assistant-file-input"
type="file"
multiple
accept=".pdf,.jpg,.jpeg,.png,.webp,.doc,.docx,.xls,.xlsx"
@change="handleWorkbenchFilesChange"
/>
<div class="assistant-composer">
<textarea
ref="assistantInputRef"
v-model="assistantDraft"
maxlength="1000"
rows="2"
placeholder="一次性描述申请、报销和附件处理事项,小财管家会先拆解再执行..."
:readonly="isComposerPending"
@keydown.enter.prevent="handleWorkbenchEnter"
/>
<div
v-if="composerPendingLabel"
class="assistant-intent-status"
role="status"
aria-live="polite"
>
<i class="mdi mdi-loading mdi-spin"></i>
<span>{{ composerPendingLabel }}</span>
</div>
<div v-if="workbenchDateTagLabel" class="workbench-date-chip-row">
<span class="workbench-date-chip">
<i class="mdi mdi-calendar-check"></i>
<span>{{ workbenchDateTagLabel }}</span>
<button
type="button"
aria-label="移除日期"
:disabled="Boolean(pendingAction)"
@click="removeWorkbenchDateTag"
>
<i class="mdi mdi-close"></i>
</button>
</span>
</div>
<div class="composer-toolbar">
<button
type="button"
class="composer-icon-button"
title="上传附件"
aria-label="上传附件"
:disabled="Boolean(pendingAction)"
@click="triggerFileUpload"
>
<i class="mdi mdi-paperclip"></i>
</button>
<div class="workbench-date-anchor">
<button
type="button"
class="composer-icon-button"
:class="{ active: workbenchDatePickerOpen }"
title="选择日期"
aria-label="选择日期"
:aria-expanded="workbenchDatePickerOpen"
:disabled="Boolean(pendingAction)"
@click.stop="toggleWorkbenchDatePicker"
>
<i class="mdi mdi-calendar-range"></i>
</button>
<div
v-if="workbenchDatePickerOpen"
class="composer-date-popover"
role="dialog"
aria-label="日期选择"
@click.stop
>
<div class="composer-date-mode-tabs">
<button
type="button"
class="composer-date-mode-btn"
:class="{ active: workbenchDateMode === 'single' }"
@click="setWorkbenchDateMode('single')"
>
当天
</button>
<button
type="button"
class="composer-date-mode-btn"
:class="{ active: workbenchDateMode === 'range' }"
@click="setWorkbenchDateMode('range')"
>
时间段
</button>
</div>
<div v-if="workbenchDateMode === 'single'" class="composer-date-fields">
<label class="composer-date-field">
<span>日期</span>
<input v-model="workbenchSingleDate" type="date" @change="handleWorkbenchDateInputChange('single')" />
</label>
</div>
<div v-else class="composer-date-fields composer-date-fields-range">
<label class="composer-date-field">
<span>开始</span>
<input v-model="workbenchRangeStartDate" type="date" @change="handleWorkbenchDateInputChange('range-start')" />
</label>
<span class="composer-date-range-sep"></span>
<label class="composer-date-field">
<span>结束</span>
<input v-model="workbenchRangeEndDate" type="date" :min="workbenchRangeStartDate" @change="handleWorkbenchDateInputChange('range-end')" />
</label>
</div>
<p v-if="workbenchDateMode === 'range' && !workbenchCanApplyDateSelection" class="composer-date-hint">
请确认结束日期不早于开始日期
</p>
</div>
</div>
<span class="composer-count">{{ assistantDraft.length }}/1000</span>
<button
type="button"
class="composer-send-button"
:disabled="Boolean(pendingAction)"
:aria-label="composerPendingLabel || expenseActionLabel"
@click="handleExpenseConversationAction"
>
<i :class="pendingAction ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-send'"></i>
</button>
</div>
</div>
<div v-if="selectedFiles.length" class="assistant-file-strip">
<span class="assistant-file-note">已带入 {{ selectedFiles.length }} 份附件</span>
<span v-for="file in selectedFiles" :key="file.name" class="assistant-file-chip">{{ file.name }}</span>
<button type="button" class="assistant-file-clear" @click="clearSelectedFiles">清空</button>
</div>
<div class="quick-prompts" aria-label="常用提问">
<span>常用提问</span>
<button
v-for="prompt in quickPromptItems"
:key="prompt"
type="button"
@click="applyQuickPrompt(prompt)"
>
{{ prompt }}
</button>
<button type="button" class="quick-more" @click="emit('open-assistant')">
更多
<i class="mdi mdi-chevron-right"></i>
</button>
</div>
</div>
</article>
<div :class="['capability-grid', capabilityGridClass]" aria-label="AI 财务助手能力">
<button
v-for="item in visibleAssistantCapabilities"
:key="item.key"
type="button"
class="capability-card panel"
:class="`capability-card--${item.tone}`"
@click="openCapabilityAssistant(item)"
>
<WorkbenchListIcon
class="capability-icon"
:icon-key="item.key"
color="var(--capability-color)"
accent="var(--capability-soft)"
/>
<span class="capability-copy">
<strong>{{ item.title }}</strong>
<small>{{ item.primary }}</small>
<small>{{ item.secondary }}</small>
</span>
<i class="mdi mdi-chevron-right capability-arrow"></i>
</button>
</div>
<div class="workbench-content-grid">
<article class="panel workbench-card progress-panel">
<div class="section-head">
<h2>费用进度</h2>
</div>
<div class="progress-list">
<button
v-for="item in visibleProgressItems"
:key="item.id"
type="button"
class="progress-row"
:class="{ 'has-long-duration-divider': item.hasLongDurationDivider }"
@click="openWorkbenchTarget(item)"
>
<span class="progress-time-wrapper">
<span class="expense-type-icon" :class="`expense-type-icon--${item.expenseTypeTone}`">
<i :class="item.expenseTypeIcon"></i>
</span>
<span class="progress-time">
<time :datetime="item.updatedAt || ''">{{ item.displayTime }}</time>
<small v-if="item.showTimeCapsule" class="time-capsule">更新时间</small>
<small v-if="item.showUpdateText">更新</small>
</span>
</span>
<span class="progress-identity">
<strong>{{ item.title }}</strong>
<small>{{ item.id }}</small>
</span>
<span class="progress-type" :title="`${item.documentTypeLabel} · ${item.expenseTypeLabel || '其他费用'}`">
<strong>{{ item.documentTypeLabel }} · {{ item.expenseTypeLabel || '其他费用' }}</strong>
</span>
<span class="progress-steps" aria-hidden="true">
<span
v-for="step in item.steps"
:key="step.label"
class="progress-step"
:class="{
'is-done': step.done,
'is-current': step.current,
'is-future': !step.done && !step.current
}"
>
<i :class="step.done || step.current ? 'mdi mdi-check' : 'mdi mdi-minus'"></i>
<small>{{ step.label }}</small>
</span>
</span>
<span class="progress-result">
<strong>{{ item.amount }}</strong>
<span class="progress-status" :class="`progress-status--${item.statusTone}`">{{ item.status }}</span>
</span>
</button>
</div>
</article>
<aside class="side-column">
<article class="panel workbench-card side-panel expense-stats-panel">
<div class="section-head side-card-head">
<h2>费用统计</h2>
<button
type="button"
class="detail-action"
aria-haspopup="dialog"
:aria-expanded="expenseStatsModalOpen"
@click="openExpenseStatsModal"
>
<span>查看详情</span>
<i class="mdi mdi-chevron-right"></i>
</button>
</div>
<div class="insight-metric-list" aria-label="费用统计">
<div
v-for="item in visibleExpenseStatItems"
:key="item.key"
class="insight-metric-row"
:class="`insight-metric-row--${item.tone}`"
>
<span class="insight-metric-icon" aria-hidden="true">
<i :class="item.icon"></i>
</span>
<span class="insight-metric-label">{{ item.label }}</span>
<strong class="insight-metric-value">
{{ item.value }}<small v-if="item.unit">{{ item.unit }}</small>
</strong>
</div>
</div>
</article>
<article class="panel workbench-card side-panel usage-profile-panel">
<div class="section-head side-card-head">
<h2>用户画像</h2>
<button
type="button"
class="detail-action"
aria-haspopup="dialog"
:aria-expanded="expenseProfileModalOpen"
@click="openExpenseProfileModal"
>
<span>查看详情</span>
<i :class="employeeProfileLoading ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-chevron-right'"></i>
</button>
</div>
<div class="insight-profile-list" aria-label="用户画像">
<div
v-for="metric in visibleUsageProfileMetrics"
:key="metric.key"
class="insight-profile-card"
:class="`insight-profile-card--${metric.tone}`"
>
<span class="insight-profile-icon" aria-hidden="true">
<i :class="metric.icon"></i>
</span>
<div class="insight-profile-copy">
<span class="insight-profile-label">{{ metric.label }}</span>
<strong class="insight-profile-value">
{{ metric.value }}<small>{{ metric.unit }}</small>
</strong>
<span class="insight-profile-hint">{{ metric.hint }}</span>
</div>
</div>
</div>
</article>
</aside>
</div>
<ExpenseStatsDetailModal
:visible="expenseStatsModalOpen"
:user-name="displayUserName"
:summary="workbenchSummary"
:detail="expenseStatsDetail"
@close="closeExpenseStatsModal"
/>
<ExpenseProfileDetailModal
:visible="expenseProfileModalOpen"
:user-name="displayUserName"
:metrics="expenseProfileModalMetrics"
:tags="expenseProfileTags"
:radar-dimensions="expenseProfileRadarDimensions"
:radar-default-view="expenseProfileRadarDefaultView"
:operations="expenseProfileOperations"
:loading="employeeProfileLoading"
:error-message="employeeProfileError"
:empty-reason="expenseProfileEmptyReason"
@close="closeExpenseProfileModal"
/>
</section>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import PanelHead from '../shared/PanelHead.vue'
import WorkbenchListIcon from '../shared/WorkbenchListIcon.vue'
import ExpenseStatsDetailModal from './ExpenseStatsDetailModal.vue'
import ExpenseProfileDetailModal from './ExpenseProfileDetailModal.vue'
import workbenchHeroBackground from '../../assets/images/hero-3d-banner.png'
import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js'
import { useWorkbenchComposerDate } from '../../composables/useWorkbenchComposerDate.js'
import {
buildExpenseStatItems,
filterAssistantCapabilitiesForUser,
quickPromptItems,
resolveWorkbenchCapabilityGridClass,
} from '../../data/personalWorkbench.js'
import { fetchAgentRuns } from '../../services/agentAssets.js'
import { clearUserConversations, fetchLatestConversation } from '../../services/orchestrator.js'
import { fetchCurrentEmployeeLatestProfile } from '../../services/reimbursements.js'
import {
ASSISTANT_SESSION_SNAPSHOT_EVENT,
hasAssistantSessionSnapshot
} from '../../utils/assistantSessionSnapshot.js'
import { buildWorkbenchCapabilityAssistantPayload } from '../../utils/personalWorkbenchAssistantEntry.js'
import {
buildProfileOperationsFromAgentRuns,
buildUserProfileMetricCards,
buildUserProfileSummaryMetrics,
normalizeUserProfileRadarDimensions,
normalizeUserProfileTags,
resolveUserProfileDefaultRadarView,
resolveCurrentUserProfileError
} from '../../utils/employeeProfileViewModel.js'
const props = defineProps({
showHeader: { type: Boolean, default: true },
assistantModalOpen: { type: Boolean, default: false },
workbenchSummary: { type: Object, default: () => ({}) }
})
const emit = defineEmits(['open-assistant', 'open-document'])
const { currentUser } = useSystemState()
const { toast } = useToast()
const assistantDraft = ref('')
const assistantInputRef = ref(null)
const fileInputRef = ref(null)
const selectedFiles = ref([])
const pendingAction = ref('')
let pendingActionTimer = 0
const {
workbenchDatePickerOpen,
workbenchDateMode,
workbenchSingleDate,
workbenchRangeStartDate,
workbenchRangeEndDate,
workbenchDateTagLabel,
workbenchCanApplyDateSelection,
clearWorkbenchDateSelection,
toggleWorkbenchDatePicker,
closeWorkbenchDatePicker,
setWorkbenchDateMode,
handleWorkbenchDatePickerOutside,
handleWorkbenchDateInputChange,
removeWorkbenchDateTag,
buildWorkbenchPromptText
} = useWorkbenchComposerDate({
draft: assistantDraft,
focusInput: focusAssistantInput
})
const latestExpenseConversation = ref(null)
const hasLocalExpenseSnapshot = ref(false)
const expenseStatsModalOpen = ref(false)
const expenseProfileModalOpen = ref(false)
const employeeProfile = ref(null)
const employeeProfileRuns = ref([])
const employeeProfileLoading = ref(false)
const employeeProfileError = ref('')
let employeeProfileLoadSeq = 0
const MAX_ATTACHMENTS = 10
const SESSION_TYPE_EXPENSE = 'expense'
const SESSION_TYPE_KNOWLEDGE = 'knowledge'
const SESSION_TYPE_STEWARD = 'steward'
const hasExpenseConversation = computed(() =>
Boolean(latestExpenseConversation.value?.conversation_id || latestExpenseConversation.value?.conversationId)
|| hasLocalExpenseSnapshot.value
)
const displayUserName = computed(() => {
const user = currentUser.value || {}
return String(user.name || user.username || '同事').trim() || '同事'
})
const expenseActionLabel = computed(() => (hasExpenseConversation.value ? '继续报销' : '新建报销'))
const isComposerPending = computed(() => Boolean(pendingAction.value))
const composerPendingLabel = computed(() => {
if (pendingAction.value === 'intent') {
return '正在识别意图,准备进入对应助手...'
}
if (pendingAction.value === 'expense') {
return '正在恢复最近报销会话...'
}
return ''
})
const visibleAssistantCapabilities = computed(() => filterAssistantCapabilitiesForUser(currentUser.value))
const capabilityGridClass = computed(() => resolveWorkbenchCapabilityGridClass(currentUser.value))
const expenseStatItems = computed(() => buildExpenseStatItems(props.workbenchSummary))
const visibleExpenseStatItems = computed(() => {
const preferredKeys = ['monthly-amount', 'monthly-count', 'in-review', 'pending-payment']
return preferredKeys
.map((key) => expenseStatItems.value.find((item) => item.key === key))
.filter(Boolean)
})
const visibleUsageProfileMetrics = computed(() => {
return buildUserProfileMetricCards(
employeeProfile.value,
employeeProfileRuns.value,
currentUser.value
).slice(0, 4)
})
const expenseProfileModalMetrics = computed(() => {
return buildUserProfileSummaryMetrics(
employeeProfile.value,
employeeProfileRuns.value,
currentUser.value
)
})
const expenseProfileTags = computed(() => normalizeUserProfileTags(employeeProfile.value))
const expenseProfileRadarDimensions = computed(() => normalizeUserProfileRadarDimensions(employeeProfile.value))
const expenseProfileRadarDefaultView = computed(() => resolveUserProfileDefaultRadarView(employeeProfile.value))
const expenseProfileOperations = computed(() =>
buildProfileOperationsFromAgentRuns(employeeProfileRuns.value, currentUser.value)
)
const expenseProfileEmptyReason = computed(() => String(employeeProfile.value?.empty_reason || '').trim())
const expenseStatsDetail = computed(() => props.workbenchSummary.expenseStatsDetail || {})
const currentUserProfileKey = computed(() => {
const user = currentUser.value || {}
return [
user.username,
user.email,
user.name,
user.employeeNo,
user.employee_no
].map((item) => String(item || '').trim()).filter(Boolean).join('|')
})
function resolveExpenseTypeStyle(label) {
if (label === '差旅交通') return { icon: 'mdi mdi-airplane', tone: 'blue' }
if (label === '业务招待') return { icon: 'mdi mdi-silverware-fork-knife', tone: 'amber' }
if (label === '办公采购') return { icon: 'mdi mdi-cart-outline', tone: 'emerald' }
if (label === '培训会议') return { icon: 'mdi mdi-projector', tone: 'violet' }
if (label === '市场活动') return { icon: 'mdi mdi-bullhorn-outline', tone: 'cyan' }
return { icon: 'mdi mdi-receipt-text-outline', tone: 'muted' }
}
const visibleProgressItems = computed(() => {
const rows = Array.isArray(props.workbenchSummary.progressItems)
? props.workbenchSummary.progressItems
: []
const progressRows = rows.slice(0, 5).map((item) => ({
...item,
displayTime: formatProgressTime(item?.updatedAt),
isLongDuration: isLongDurationProgress(item?.updatedAt)
}))
return progressRows.map((item, index) => {
const isCompleted = item.statusTone === 'muted';
const expenseStyle = resolveExpenseTypeStyle(item.expenseTypeLabel);
return {
...item,
expenseTypeIcon: expenseStyle.icon,
expenseTypeTone: expenseStyle.tone,
showTimeCapsule: !item.isLongDuration,
showUpdateText: item.isLongDuration && !isCompleted,
hasLongDurationDivider: item.isLongDuration && !progressRows[index - 1]?.isLongDuration
}
})
})
const LONG_DURATION_DAYS = 10
const DAY_MS = 24 * 60 * 60 * 1000
function formatProgressTime(value) {
const text = String(value || '').trim()
if (!text) {
return '最近更新'
}
const match = /^(\d{4})-(\d{2})-(\d{2})(?:[T\s](\d{2}):(\d{2}))?/.exec(text)
if (match) {
return match[4] ? `${match[2]}-${match[3]} ${match[4]}:${match[5]}` : `${match[2]}-${match[3]}`
}
return text
}
function parseProgressDate(value) {
const text = String(value || '').trim()
if (!text) {
return null
}
const localDateMatch = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text)
if (localDateMatch) {
return new Date(
Number(localDateMatch[1]),
Number(localDateMatch[2]) - 1,
Number(localDateMatch[3])
)
}
const date = new Date(text)
return Number.isNaN(date.getTime()) ? null : date
}
function isLongDurationProgress(value) {
const date = parseProgressDate(value)
if (!date) {
return false
}
return (Date.now() - date.getTime()) / DAY_MS >= LONG_DURATION_DAYS
}
function buildSelectedFileKey(file) {
return [file?.name, file?.size, file?.lastModified, file?.type].join('__')
}
function mergeSelectedFiles(existingFiles, incomingFiles) {
const nextFiles = []
const seen = new Set()
for (const file of existingFiles) {
const key = buildSelectedFileKey(file)
if (seen.has(key)) continue
seen.add(key)
nextFiles.push(file)
}
let overflowCount = 0
for (const file of incomingFiles) {
const key = buildSelectedFileKey(file)
if (seen.has(key)) continue
if (nextFiles.length >= MAX_ATTACHMENTS) {
overflowCount += 1
continue
}
seen.add(key)
nextFiles.push(file)
}
return {
files: nextFiles,
overflowCount
}
}
function resolveCurrentUserId() {
const user = currentUser.value || {}
return String(user.username || user.name || 'anonymous').trim() || 'anonymous'
}
function buildAssistantPayload() {
return {
prompt: buildWorkbenchPromptText(),
source: 'workbench',
sessionType: SESSION_TYPE_STEWARD,
files: Array.from(selectedFiles.value)
}
}
function clearSelectedFiles() {
selectedFiles.value = []
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
function resetWorkbenchDraft() {
assistantDraft.value = ''
clearSelectedFiles()
clearWorkbenchDateSelection()
}
function clearPendingAction() {
pendingAction.value = ''
if (pendingActionTimer) {
window.clearTimeout(pendingActionTimer)
pendingActionTimer = 0
}
}
function startPendingAction(action) {
clearPendingAction()
pendingAction.value = action
pendingActionTimer = window.setTimeout(() => {
if (pendingAction.value !== action) {
return
}
clearPendingAction()
toast('进入助手耗时较长,请稍后重试。')
}, 16000)
}
function shouldShowIntentPending(payload = {}) {
return !props.assistantModalOpen
&& String(payload.prompt || '').trim()
&& String(payload.source || 'workbench').trim() === 'workbench'
&& !String(payload.sessionType || '').trim()
}
function emitAssistant(payload) {
emit('open-assistant', payload)
resetWorkbenchDraft()
}
async function loadLatestConversation() {
const payload = await fetchLatestConversation(resolveCurrentUserId(), SESSION_TYPE_EXPENSE, {
preferRecoverable: true
})
return payload?.found ? payload.conversation || null : null
}
function focusAssistantInput() {
nextTick(() => {
assistantInputRef.value?.focus()
})
}
function applyQuickPrompt(prompt) {
assistantDraft.value = String(prompt || '').trim()
focusAssistantInput()
}
function openPromptAssistant(prompt, sessionType = SESSION_TYPE_STEWARD) {
if (pendingAction.value) {
return
}
const payload = {
prompt: buildWorkbenchPromptText(prompt),
source: 'workbench',
sessionType,
files: Array.from(selectedFiles.value),
conversation: null
}
if (shouldShowIntentPending(payload)) {
startPendingAction('intent')
}
emitAssistant(payload)
}
function openWorkbenchTarget(item) {
const target = item?.target || {}
if (target.type === 'document' && (target.id || target.claimNo)) {
emit('open-document', {
source: 'workbench',
returnTo: 'workbench',
claimId: target.id,
id: target.id || target.claimNo,
claimNo: target.claimNo
})
return
}
openPromptAssistant(item?.prompt || `查询 ${item?.id || ''} 的费用进度`, SESSION_TYPE_EXPENSE)
}
function openCapabilityAssistant(item) {
if (pendingAction.value) {
return
}
emitAssistant(buildWorkbenchCapabilityAssistantPayload(item, buildAssistantPayload()))
}
async function loadCurrentEmployeeProfile() {
const sequence = ++employeeProfileLoadSeq
employeeProfileLoading.value = true
employeeProfileError.value = ''
const [profileResult, runsResult] = await Promise.allSettled([
fetchCurrentEmployeeLatestProfile({
scene: 'operations',
window_days: 90,
expense_type_scope: 'overall'
}),
fetchAgentRuns({ limit: 100 })
])
if (sequence !== employeeProfileLoadSeq) {
return
}
if (profileResult.status === 'fulfilled') {
employeeProfile.value = profileResult.value || null
} else {
employeeProfile.value = null
employeeProfileError.value = resolveCurrentUserProfileError(profileResult.reason)
}
employeeProfileRuns.value = runsResult.status === 'fulfilled' ? runsResult.value || [] : []
employeeProfileLoading.value = false
}
function openExpenseStatsModal() {
expenseStatsModalOpen.value = true
}
function closeExpenseStatsModal() {
expenseStatsModalOpen.value = false
}
function openExpenseProfileModal() {
expenseProfileModalOpen.value = true
if (!employeeProfile.value && !employeeProfileLoading.value) {
void loadCurrentEmployeeProfile()
}
}
function closeExpenseProfileModal() {
expenseProfileModalOpen.value = false
}
function handleWorkbenchEnter(event) {
if (event.isComposing) {
return
}
handleExpenseConversationAction()
}
function triggerFileUpload() {
fileInputRef.value?.click()
}
function handleWorkbenchFilesChange(event) {
const mergeResult = mergeSelectedFiles(selectedFiles.value, Array.from(event.target.files ?? []))
selectedFiles.value = mergeResult.files
if (mergeResult.overflowCount > 0) {
toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`)
}
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
async function refreshLatestExpenseConversation() {
refreshLocalExpenseSnapshot()
try {
latestExpenseConversation.value = await loadLatestConversation()
} catch (error) {
console.warn('Failed to refresh latest expense conversation:', error)
latestExpenseConversation.value = null
}
}
function refreshLocalExpenseSnapshot() {
hasLocalExpenseSnapshot.value = hasAssistantSessionSnapshot(resolveCurrentUserId(), SESSION_TYPE_EXPENSE)
}
function handleAssistantSessionSnapshotChange(event) {
const sessionType = String(event?.detail?.sessionType || '').trim()
if (!sessionType || sessionType === SESSION_TYPE_EXPENSE) {
refreshLocalExpenseSnapshot()
}
}
async function clearKnowledgeHistoryBeforeExpense() {
await clearUserConversations(resolveCurrentUserId(), SESSION_TYPE_KNOWLEDGE)
}
async function handleExpenseConversationAction() {
if (pendingAction.value) {
return
}
const nextPayload = buildAssistantPayload()
const shouldOpenImmediately = Boolean(nextPayload.prompt || nextPayload.files.length)
if (shouldOpenImmediately) {
if (shouldShowIntentPending(nextPayload)) {
startPendingAction('intent')
}
emitAssistant({
...nextPayload,
conversation: null
})
void clearKnowledgeHistoryBeforeExpense().catch((error) => {
console.warn('Failed to clear knowledge history before expense:', error)
})
return
}
startPendingAction('expense')
try {
await clearKnowledgeHistoryBeforeExpense()
const conversation = await loadLatestConversation()
latestExpenseConversation.value = conversation
emitAssistant({
...nextPayload,
conversation
})
} catch (error) {
console.warn('Failed to open expense conversation:', error)
toast(error?.message || '打开报销会话失败,请稍后重试。')
} finally {
clearPendingAction()
}
}
onMounted(() => {
refreshLocalExpenseSnapshot()
refreshLatestExpenseConversation()
loadCurrentEmployeeProfile()
document.addEventListener('click', handleWorkbenchDatePickerOutside)
window.addEventListener(ASSISTANT_SESSION_SNAPSHOT_EVENT, handleAssistantSessionSnapshotChange)
})
onBeforeUnmount(() => {
clearPendingAction()
document.removeEventListener('click', handleWorkbenchDatePickerOutside)
window.removeEventListener(ASSISTANT_SESSION_SNAPSHOT_EVENT, handleAssistantSessionSnapshotChange)
})
watch(
() => props.assistantModalOpen,
(open, previous) => {
if (open) {
clearPendingAction()
}
if (previous && !open) {
refreshLatestExpenseConversation()
}
}
)
watch(currentUserProfileKey, (nextKey, previousKey) => {
if (nextKey && nextKey !== previousKey) {
loadCurrentEmployeeProfile()
}
})
</script>
<style scoped src="../../assets/styles/components/personal-workbench.css"></style>
<style scoped src="../../assets/styles/components/personal-workbench-glass.css"></style>
<style scoped src="../../assets/styles/components/personal-workbench-composer-date.css"></style>
<style scoped src="../../assets/styles/components/personal-workbench-insights.css"></style>
<style scoped src="../../assets/styles/components/personal-workbench-responsive.css"></style>