feat(web): 工作台 AI 模式与差旅/风险建议交互优化
- 新增 PersonalWorkbenchAiMode 组件、AI 侧边栏与 orb 机器人视觉资源 - 新增 aiApplicationDraftModel / aiExpenseDraftModel / aiWorkbenchConversationStore 及业务准入 aiSidebarBusinessAccess,支撑 AI 模式下的申请与报销草稿 - 顶栏、侧边栏、工作台样式重构,适配 AI 模式切换与响应式布局 - 同步 steward plan/off_topic、差旅报销引导流、风险建议卡片等测试
This commit is contained in:
@@ -7,168 +7,35 @@
|
||||
note="把费用申请、报销进度、制度问答和待办处理集中到一个入口。"
|
||||
/>
|
||||
|
||||
<article class="panel assistant-hero" :style="{ '--assistant-bg-image': `url(${workbenchHeroBackground})` }">
|
||||
<div class="assistant-copy">
|
||||
<h1 class="assistant-hero-title">
|
||||
{{ typedTitlePrefix }}<span v-if="titleTypingDone">小财管家</span><span v-if="!titleTypingDone" class="typing-cursor">|</span>
|
||||
</h1>
|
||||
<article class="panel workbench-trend-hero">
|
||||
<div class="workbench-trend-card" aria-label="报销趋势同比">
|
||||
<div class="trend-summary-panel">
|
||||
<h1>报销趋势</h1>
|
||||
<p>{{ reimbursementTrendRangeLabel }}</p>
|
||||
<strong class="trend-total">{{ reimbursementTrendTotalLabel }}</strong>
|
||||
<span class="trend-change" :class="reimbursementTrendGrowthTone">
|
||||
<i :class="reimbursementTrendGrowthIcon" aria-hidden="true"></i>
|
||||
{{ reimbursementTrendGrowthLabel }} 同比去年同期
|
||||
</span>
|
||||
<small>{{ displayUserName }} · {{ reimbursementTrendSignalLabel }}</small>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
class="assistant-file-input"
|
||||
type="file"
|
||||
multiple
|
||||
accept=".pdf,.jpg,.jpeg,.png,.webp,.doc,.docx,.xls,.xlsx"
|
||||
@change="handleWorkbenchFilesChange"
|
||||
/>
|
||||
<div class="trend-chart-panel">
|
||||
<div class="trend-chart-head">
|
||||
<strong>月度报销明细</strong>
|
||||
<span class="trend-chart-source">与分析看板同源</span>
|
||||
</div>
|
||||
|
||||
<div class="assistant-composer">
|
||||
<textarea
|
||||
ref="assistantInputRef"
|
||||
v-model="assistantDraft"
|
||||
maxlength="1000"
|
||||
rows="2"
|
||||
placeholder="一次性描述申请、报销和附件处理事项,小财管家会先拆解再执行..."
|
||||
:readonly="isComposerPending"
|
||||
@keydown.enter.prevent="handleWorkbenchEnter"
|
||||
<TrendChart
|
||||
class="workbench-trend-chart"
|
||||
mode="compareAmount"
|
||||
:labels="reimbursementTrendLabels"
|
||||
:claim-amount="reimbursementTrendAmounts"
|
||||
:comparison-amount="reimbursementTrendPreviousAmounts"
|
||||
primary-label="本期"
|
||||
comparison-label="去年同期"
|
||||
compact
|
||||
/>
|
||||
|
||||
<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>
|
||||
@@ -303,29 +170,21 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import PanelHead from '../shared/PanelHead.vue'
|
||||
import WorkbenchListIcon from '../shared/WorkbenchListIcon.vue'
|
||||
import TrendChart from '../charts/TrendChart.vue'
|
||||
import ExpenseStatsDetailModal from './ExpenseStatsDetailModal.vue'
|
||||
import ExpenseProfileDetailModal from './ExpenseProfileDetailModal.vue'
|
||||
import PersonalWorkbenchProgressPanel from './PersonalWorkbenchProgressPanel.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,
|
||||
@@ -344,35 +203,6 @@ const props = defineProps({
|
||||
|
||||
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)
|
||||
@@ -380,59 +210,13 @@ 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 heroTitleText = computed(() => `嗨,${displayUserName.value},我是您的 `)
|
||||
const typedTitlePrefix = ref('')
|
||||
const titleTypingDone = ref(false)
|
||||
let typingInterval = null
|
||||
|
||||
const startTypewriter = () => {
|
||||
typedTitlePrefix.value = ''
|
||||
titleTypingDone.value = false
|
||||
clearInterval(typingInterval)
|
||||
let i = 0
|
||||
const text = heroTitleText.value
|
||||
typingInterval = setInterval(() => {
|
||||
if (i < text.length) {
|
||||
typedTitlePrefix.value += text.charAt(i)
|
||||
i++
|
||||
} else {
|
||||
clearInterval(typingInterval)
|
||||
titleTypingDone.value = true
|
||||
}
|
||||
}, 60)
|
||||
}
|
||||
|
||||
watch(displayUserName, (newVal, oldVal) => {
|
||||
if (oldVal !== newVal && titleTypingDone.value) {
|
||||
typedTitlePrefix.value = `嗨,${newVal},我是您的 `
|
||||
}
|
||||
})
|
||||
|
||||
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))
|
||||
@@ -468,133 +252,100 @@ 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 buildSelectedFileKey(file) {
|
||||
return [file?.name, file?.size, file?.lastModified, file?.type].join('__')
|
||||
|
||||
function formatCurrencyValue(value) {
|
||||
return new Intl.NumberFormat('zh-CN', {
|
||||
style: 'currency',
|
||||
currency: 'CNY',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0
|
||||
}).format(Number(value) || 0)
|
||||
}
|
||||
|
||||
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
|
||||
function normalizeTrendRows(rows = []) {
|
||||
return rows.map((row, index) => {
|
||||
const amount = Number(row?.amount || 0)
|
||||
const previousAmount = Number(row?.previousAmount || row?.previous_amount || 0)
|
||||
return {
|
||||
key: String(row?.key || `trend-${index}`),
|
||||
label: String(row?.label || `${index + 1}月`),
|
||||
amount,
|
||||
amountLabel: String(row?.amountLabel || row?.amount_label || formatCurrencyValue(amount)),
|
||||
previousKey: String(row?.previousKey || row?.previous_key || `previous-${index}`),
|
||||
previousAmount,
|
||||
previousAmountLabel: String(
|
||||
row?.previousAmountLabel || row?.previous_amount_label || formatCurrencyValue(previousAmount)
|
||||
)
|
||||
}
|
||||
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'
|
||||
})
|
||||
}
|
||||
|
||||
const sourceReimbursementTrendRows = computed(() => normalizeTrendRows(props.workbenchSummary.reimbursementTrendRows || []))
|
||||
const reimbursementTrendHasSignal = computed(() =>
|
||||
sourceReimbursementTrendRows.value.some((item) => item.amount > 0 || item.previousAmount > 0)
|
||||
)
|
||||
const reimbursementTrendRows = computed(() => sourceReimbursementTrendRows.value)
|
||||
const reimbursementTrendSignalLabel = computed(() =>
|
||||
reimbursementTrendHasSignal.value ? '来自你的真实单据' : '暂无单据时展示空走势'
|
||||
)
|
||||
const reimbursementTrendLabels = computed(() => reimbursementTrendRows.value.map((item) => item.label))
|
||||
const reimbursementTrendAmounts = computed(() => reimbursementTrendRows.value.map((item) => item.amount))
|
||||
const reimbursementTrendPreviousAmounts = computed(() => reimbursementTrendRows.value.map((item) => item.previousAmount))
|
||||
const reimbursementTrendTotal = computed(() =>
|
||||
reimbursementTrendRows.value.reduce((total, item) => total + item.amount, 0)
|
||||
)
|
||||
const reimbursementTrendPreviousTotal = computed(() =>
|
||||
reimbursementTrendRows.value.reduce((total, item) => total + item.previousAmount, 0)
|
||||
)
|
||||
const reimbursementTrendTotalLabel = computed(() => formatCurrencyValue(reimbursementTrendTotal.value))
|
||||
const reimbursementTrendRangeLabel = computed(() => {
|
||||
const rows = reimbursementTrendRows.value
|
||||
const first = rows[0]
|
||||
const last = rows[rows.length - 1]
|
||||
if (!first || !last) {
|
||||
return '近 6 个月'
|
||||
}
|
||||
return `${first.label} - ${last.label}`
|
||||
})
|
||||
const reimbursementTrendGrowthRate = computed(() => {
|
||||
const previousTotal = reimbursementTrendPreviousTotal.value
|
||||
if (previousTotal > 0) {
|
||||
return ((reimbursementTrendTotal.value - previousTotal) / previousTotal) * 100
|
||||
}
|
||||
return reimbursementTrendTotal.value > 0 ? 100 : 0
|
||||
})
|
||||
const reimbursementTrendGrowthLabel = computed(() => {
|
||||
const value = reimbursementTrendGrowthRate.value
|
||||
const prefix = value >= 0 ? '+' : ''
|
||||
return `${prefix}${value.toFixed(1)}%`
|
||||
})
|
||||
const reimbursementTrendGrowthTone = computed(() =>
|
||||
reimbursementTrendGrowthRate.value >= 0 ? 'is-up' : 'is-down'
|
||||
)
|
||||
const reimbursementTrendGrowthIcon = computed(() =>
|
||||
reimbursementTrendGrowthRate.value >= 0 ? 'mdi mdi-arrow-up-right' : 'mdi mdi-arrow-down-right'
|
||||
)
|
||||
function buildAssistantPayload() {
|
||||
return {
|
||||
prompt: buildWorkbenchPromptText(),
|
||||
prompt: '',
|
||||
source: 'workbench',
|
||||
sessionType: SESSION_TYPE_STEWARD,
|
||||
files: Array.from(selectedFiles.value)
|
||||
files: []
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
emitAssistant({
|
||||
prompt: String(prompt || '').trim(),
|
||||
source: 'workbench',
|
||||
sessionType,
|
||||
files: Array.from(selectedFiles.value),
|
||||
files: [],
|
||||
conversation: null
|
||||
}
|
||||
if (shouldShowIntentPending(payload)) {
|
||||
startPendingAction('intent')
|
||||
}
|
||||
emitAssistant(payload)
|
||||
})
|
||||
}
|
||||
|
||||
function openWorkbenchTarget(item) {
|
||||
@@ -614,10 +365,6 @@ function openWorkbenchTarget(item) {
|
||||
}
|
||||
|
||||
function openCapabilityAssistant(item) {
|
||||
if (pendingAction.value) {
|
||||
return
|
||||
}
|
||||
|
||||
emitAssistant(buildWorkbenchCapabilityAssistantPayload(item, buildAssistantPayload()))
|
||||
}
|
||||
|
||||
@@ -669,122 +416,10 @@ 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(() => {
|
||||
startTypewriter()
|
||||
refreshLocalExpenseSnapshot()
|
||||
refreshLatestExpenseConversation()
|
||||
loadCurrentEmployeeProfile()
|
||||
document.addEventListener('click', handleWorkbenchDatePickerOutside)
|
||||
window.addEventListener(ASSISTANT_SESSION_SNAPSHOT_EVENT, handleAssistantSessionSnapshotChange)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearInterval(typingInterval)
|
||||
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()
|
||||
@@ -794,6 +429,5 @@ watch(currentUserProfileKey, (nextKey, previousKey) => {
|
||||
|
||||
<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>
|
||||
|
||||
1666
web/src/components/business/PersonalWorkbenchAiMode.vue
Normal file
1666
web/src/components/business/PersonalWorkbenchAiMode.vue
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user