840 lines
27 KiB
Vue
840 lines
27 KiB
Vue
<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>AI 费用助手</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)"
|
||
>
|
||
<span class="capability-icon"><i :class="item.icon"></i></span>
|
||
<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>
|
||
<button type="button" class="link-action">全部进度 <i class="mdi mdi-chevron-right"></i></button>
|
||
</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">
|
||
<time :datetime="item.updatedAt || ''">{{ item.displayTime }}</time>
|
||
<small>更新</small>
|
||
</span>
|
||
|
||
<span class="progress-identity">
|
||
<strong>{{ item.id }}</strong>
|
||
<small>{{ item.title }}</small>
|
||
</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">
|
||
<span class="progress-status" :class="`progress-status--${item.statusTone}`">{{ item.status }}</span>
|
||
<strong>{{ item.amount }}</strong>
|
||
</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"
|
||
@click="openPromptAssistant('查看我的费用统计详情,并说明本月报销金额、审批中和待付款的主要变化。')"
|
||
>
|
||
<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-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>
|
||
|
||
<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 ExpenseProfileDetailModal from './ExpenseProfileDetailModal.vue'
|
||
import workbenchHeroBackground from '../../assets/personal-workbench-hero-bg-theme-base.webp'
|
||
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 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 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 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('|')
|
||
})
|
||
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) => ({
|
||
...item,
|
||
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',
|
||
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) {
|
||
if (pendingAction.value) {
|
||
return
|
||
}
|
||
|
||
const payload = {
|
||
prompt: buildWorkbenchPromptText(prompt),
|
||
source: 'workbench',
|
||
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', {
|
||
claimId: target.id,
|
||
id: target.id || target.claimNo,
|
||
claimNo: target.claimNo
|
||
})
|
||
return
|
||
}
|
||
|
||
openPromptAssistant(item?.prompt || `查询 ${item?.id || ''} 的费用进度`)
|
||
}
|
||
|
||
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 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>
|