feat(web): 工作台 AI 模式与差旅/风险建议交互优化

- 新增 PersonalWorkbenchAiMode 组件、AI 侧边栏与 orb 机器人视觉资源
- 新增 aiApplicationDraftModel / aiExpenseDraftModel / aiWorkbenchConversationStore
  及业务准入 aiSidebarBusinessAccess,支撑 AI 模式下的申请与报销草稿
- 顶栏、侧边栏、工作台样式重构,适配 AI 模式切换与响应式布局
- 同步 steward plan/off_topic、差旅报销引导流、风险建议卡片等测试
This commit is contained in:
caoxiaozhu
2026-06-18 22:12:24 +08:00
parent a6674a1e76
commit 0cde1f8990
65 changed files with 8011 additions and 1608 deletions

View File

@@ -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>

File diff suppressed because it is too large Load Diff