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
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="trend-chart">
|
||||
<div class="trend-chart" :class="{ 'trend-chart-compact': compact, 'trend-chart-dark': dark }">
|
||||
<div class="chart-toolbar">
|
||||
<div class="chart-legend">
|
||||
<span
|
||||
@@ -39,6 +39,10 @@ const props = defineProps({
|
||||
claimCount: { type: Array, default: () => [] },
|
||||
claimAmount: { type: Array, default: () => [] },
|
||||
categoryAmountSeries: { type: Array, default: () => [] },
|
||||
comparisonAmount: { type: Array, default: () => [] },
|
||||
primaryLabel: { type: String, default: '报销金额' },
|
||||
comparisonLabel: { type: String, default: '去年同期' },
|
||||
compact: { type: Boolean, default: false },
|
||||
applications: { type: Array, default: () => [] },
|
||||
approved: { type: Array, default: () => [] }
|
||||
})
|
||||
@@ -46,6 +50,7 @@ const props = defineProps({
|
||||
const chartElement = shallowRef(null)
|
||||
const themeColors = useThemeColors()
|
||||
const isCountMode = computed(() => props.mode === 'count')
|
||||
const isComparisonMode = computed(() => props.mode === 'compareAmount')
|
||||
const chartColors = computed(() => ({
|
||||
primary: themeColors.value.chartPrimary,
|
||||
blue: themeColors.value.chartBlue,
|
||||
@@ -93,14 +98,30 @@ const stackedAmountData = computed(() => props.labels.map((_, index) => [
|
||||
index,
|
||||
...amountCategorySeries.value.map((item) => Number(item.data?.[index] || 0))
|
||||
]))
|
||||
const activeColor = computed(() => (
|
||||
isCountMode.value ? chartColors.value.primary : chartColors.value.blue
|
||||
))
|
||||
const activeColor = computed(() => {
|
||||
return isCountMode.value ? chartColors.value.primary : chartColors.value.blue
|
||||
})
|
||||
const comparisonColor = computed(() => '#cbd5e1')
|
||||
const legendLabel = computed(() => (
|
||||
isCountMode.value ? '报销数量' : '报销金额'
|
||||
isCountMode.value ? '报销数量' : (isComparisonMode.value ? props.primaryLabel : '报销金额')
|
||||
))
|
||||
const unitLabel = computed(() => (isCountMode.value ? '单位:单' : '单位:元'))
|
||||
const legendItems = computed(() => {
|
||||
if (isComparisonMode.value) {
|
||||
return [
|
||||
{
|
||||
name: props.primaryLabel,
|
||||
color: activeColor.value,
|
||||
title: `${props.primaryLabel} ${unitLabel.value}`
|
||||
},
|
||||
{
|
||||
name: props.comparisonLabel,
|
||||
color: comparisonColor.value,
|
||||
title: `${props.comparisonLabel} ${unitLabel.value}`
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
if (amountCategorySeries.value.length) {
|
||||
return amountCategorySeries.value.map((item, index) => ({
|
||||
name: item.name || `费用类型 ${index + 1}`,
|
||||
@@ -114,23 +135,144 @@ const legendItems = computed(() => {
|
||||
title: `${legendLabel.value} ${unitLabel.value}`
|
||||
}]
|
||||
})
|
||||
const maxValue = computed(() => Math.max(...activeSeries.value.map((value) => Number(value || 0)), 1))
|
||||
const comparisonSeries = computed(() => (
|
||||
Array.isArray(props.comparisonAmount) ? props.comparisonAmount : []
|
||||
))
|
||||
const maxValue = computed(() => {
|
||||
const values = [
|
||||
...activeSeries.value.map((value) => Number(value || 0)),
|
||||
...(isComparisonMode.value ? comparisonSeries.value.map((value) => Number(value || 0)) : [])
|
||||
]
|
||||
const rawMax = Math.max(...values, 0)
|
||||
if (isCountMode.value) {
|
||||
return Math.max(rawMax, 5)
|
||||
}
|
||||
return Math.max(rawMax, 100)
|
||||
})
|
||||
const compactScale = computed(() => ({
|
||||
axisLabelSize: props.compact ? 12 : 11,
|
||||
comparisonLineWidth: props.compact ? 3 : 2.5,
|
||||
comparisonSymbolSize: props.compact ? 7.5 : 6,
|
||||
defaultLineWidth: props.compact ? 3 : 2.5,
|
||||
defaultSymbolSize: props.compact ? 8 : 7,
|
||||
gridBottom: props.compact ? 18 : 22,
|
||||
gridLeft: props.compact ? 42 : 36,
|
||||
gridRight: props.compact ? 28 : 24,
|
||||
gridTop: props.compact ? 10 : 12,
|
||||
primaryLineWidth: props.compact ? 3.8 : 3,
|
||||
primarySymbolSize: props.compact ? 8.5 : 7
|
||||
}))
|
||||
const chartGrid = computed(() => ({
|
||||
top: compactScale.value.gridTop,
|
||||
right: compactScale.value.gridRight,
|
||||
bottom: compactScale.value.gridBottom,
|
||||
left: compactScale.value.gridLeft,
|
||||
containLabel: true
|
||||
}))
|
||||
const stackedMaxValue = computed(() => {
|
||||
if (!amountCategorySeries.value.length) {
|
||||
if (isComparisonMode.value || !amountCategorySeries.value.length) {
|
||||
return maxValue.value
|
||||
}
|
||||
const dailyTotals = props.labels.map((_, index) => amountCategorySeries.value
|
||||
.reduce((sum, item) => sum + Number(item.data?.[index] || 0), 0))
|
||||
return Math.max(...dailyTotals, 1)
|
||||
const rawMax = Math.max(...dailyTotals, 0)
|
||||
if (isCountMode.value) {
|
||||
return Math.max(rawMax, 5)
|
||||
}
|
||||
return Math.max(rawMax, 100)
|
||||
})
|
||||
function getFormattedMax(val, isCount) {
|
||||
if (isCount) {
|
||||
const base = Math.max(val, 4)
|
||||
if (base <= 4) return 4
|
||||
if (base <= 6) return 6
|
||||
if (base <= 10) return 10
|
||||
return Math.ceil(base / 2) * 2
|
||||
} else {
|
||||
const base = Math.max(val, 100)
|
||||
if (base <= 100) return 100
|
||||
if (base <= 200) return 200
|
||||
if (base <= 500) return 500
|
||||
if (base <= 1000) return 1000
|
||||
if (base <= 2000) return 2000
|
||||
if (base <= 5000) return 5000
|
||||
return Math.ceil(base / 1000) * 1000
|
||||
}
|
||||
}
|
||||
const yAxisMax = computed(() => {
|
||||
const calculatedMax = Math.ceil(stackedMaxValue.value * 1.18)
|
||||
return getFormattedMax(calculatedMax, isCountMode.value)
|
||||
})
|
||||
const ariaLabel = computed(() =>
|
||||
props.labels.map((label, index) => (
|
||||
isCountMode.value
|
||||
isComparisonMode.value
|
||||
? `${label}${props.primaryLabel}${formatCurrency(claimAmountSeries.value[index] || 0)},${props.comparisonLabel}${formatCurrency(comparisonSeries.value[index] || 0)}`
|
||||
: isCountMode.value
|
||||
? `${label}报销${claimCountSeries.value[index] || 0}单`
|
||||
: `${label}报销金额${formatCurrency(claimAmountSeries.value[index] || 0)}`
|
||||
)).join(',')
|
||||
)
|
||||
const chartSeries = computed(() => {
|
||||
if (isComparisonMode.value) {
|
||||
return [
|
||||
{
|
||||
name: props.primaryLabel,
|
||||
type: 'line',
|
||||
data: claimAmountSeries.value,
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: compactScale.value.primarySymbolSize,
|
||||
lineStyle: {
|
||||
width: compactScale.value.primaryLineWidth,
|
||||
color: activeColor.value
|
||||
},
|
||||
itemStyle: {
|
||||
color: '#ffffff',
|
||||
borderColor: activeColor.value,
|
||||
borderWidth: props.compact ? 3 : 2.5
|
||||
},
|
||||
areaStyle: {
|
||||
opacity: 1,
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: toRgba(activeColor.value, 0.12) },
|
||||
{ offset: 1, color: toRgba(activeColor.value, 0.01) }
|
||||
]
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
valueFormatter: (value) => formatCurrency(value)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: props.comparisonLabel,
|
||||
type: 'line',
|
||||
data: comparisonSeries.value,
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: compactScale.value.comparisonSymbolSize,
|
||||
lineStyle: {
|
||||
width: compactScale.value.comparisonLineWidth,
|
||||
color: comparisonColor.value,
|
||||
type: 'dashed'
|
||||
},
|
||||
itemStyle: {
|
||||
color: '#ffffff',
|
||||
borderColor: comparisonColor.value,
|
||||
borderWidth: props.compact ? 2.5 : 2
|
||||
},
|
||||
tooltip: {
|
||||
valueFormatter: (value) => formatCurrency(value)
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
if (!isCountMode.value && amountCategorySeries.value.length) {
|
||||
return [{
|
||||
name: '费用类型占比',
|
||||
@@ -151,15 +293,15 @@ const chartSeries = computed(() => {
|
||||
barWidth: 16,
|
||||
smooth: isCountMode.value,
|
||||
symbol: isCountMode.value ? 'circle' : 'none',
|
||||
symbolSize: 7,
|
||||
symbolSize: compactScale.value.defaultSymbolSize,
|
||||
lineStyle: {
|
||||
width: 2.5,
|
||||
width: compactScale.value.defaultLineWidth,
|
||||
color: activeColor.value
|
||||
},
|
||||
itemStyle: {
|
||||
color: isCountMode.value ? '#ffffff' : activeColor.value,
|
||||
borderColor: activeColor.value,
|
||||
borderWidth: isCountMode.value ? 2.5 : 0,
|
||||
borderWidth: isCountMode.value ? (props.compact ? 3 : 2.5) : 0,
|
||||
borderRadius: [4, 4, 0, 0]
|
||||
},
|
||||
areaStyle: {
|
||||
@@ -190,13 +332,7 @@ const chartOptions = computed(() => ({
|
||||
animationDurationUpdate: 1200,
|
||||
animationEasing: 'linear',
|
||||
animationEasingUpdate: 'linear',
|
||||
grid: {
|
||||
top: 12,
|
||||
right: 24,
|
||||
bottom: 22,
|
||||
left: 36,
|
||||
containLabel: true
|
||||
},
|
||||
grid: chartGrid.value,
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
confine: true,
|
||||
@@ -221,20 +357,22 @@ const chartOptions = computed(() => ({
|
||||
axisLine: { lineStyle: { color: 'rgba(148, 163, 184, 0.28)' } },
|
||||
axisLabel: {
|
||||
color: '#64748b',
|
||||
fontSize: 11,
|
||||
fontSize: compactScale.value.axisLabelSize,
|
||||
fontWeight: 700
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: Math.ceil(stackedMaxValue.value * 1.18),
|
||||
splitNumber: 5,
|
||||
max: yAxisMax.value,
|
||||
interval: props.compact ? (yAxisMax.value / 2) : undefined,
|
||||
splitNumber: props.compact ? 2 : 5,
|
||||
name: '',
|
||||
axisLabel: {
|
||||
color: '#64748b',
|
||||
fontSize: 11,
|
||||
fontSize: compactScale.value.axisLabelSize,
|
||||
fontWeight: 700,
|
||||
margin: props.compact ? 12 : 8,
|
||||
formatter: (value) => (isCountMode.value ? `${Math.round(value)}` : formatAxisCurrency(value))
|
||||
},
|
||||
splitLine: { lineStyle: { color: 'rgba(226, 232, 240, 0.75)' } }
|
||||
@@ -352,6 +490,16 @@ function formatTooltip(params) {
|
||||
if (!first) {
|
||||
return ''
|
||||
}
|
||||
if (isComparisonMode.value) {
|
||||
const index = Number(first.dataIndex || 0)
|
||||
const label = props.labels[index] || first.axisValueLabel || first.name || ''
|
||||
return [
|
||||
label,
|
||||
`${props.primaryLabel}:${formatCurrency(claimAmountSeries.value[index] || 0)}`,
|
||||
`${props.comparisonLabel}:${formatCurrency(comparisonSeries.value[index] || 0)}`
|
||||
].join('<br/>')
|
||||
}
|
||||
|
||||
if (!isCountMode.value && amountCategorySeries.value.length) {
|
||||
return formatStackedTooltip(first)
|
||||
}
|
||||
@@ -406,6 +554,11 @@ function formatAxisCurrency(value) {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.trend-chart-compact {
|
||||
height: 100%;
|
||||
min-height: 124px;
|
||||
}
|
||||
|
||||
.chart-toolbar {
|
||||
min-height: 30px;
|
||||
display: flex;
|
||||
@@ -465,4 +618,39 @@ function formatAxisCurrency(value) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.trend-chart-compact .chart-toolbar {
|
||||
min-height: 28px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.trend-chart-compact .chart-legend {
|
||||
gap: 6px 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.trend-chart-compact .legend-pill {
|
||||
max-width: 128px;
|
||||
}
|
||||
|
||||
.trend-chart-compact .chart-legend i {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
}
|
||||
|
||||
.trend-chart-compact .chart-unit {
|
||||
padding: 2px 8px;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.trend-chart-dark .chart-legend,
|
||||
.trend-chart-dark .legend-pill {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.trend-chart-dark .chart-unit {
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: #64748b;
|
||||
}
|
||||
</style>
|
||||
|
||||
321
web/src/components/layout/AiSidebarRail.vue
Normal file
321
web/src/components/layout/AiSidebarRail.vue
Normal file
@@ -0,0 +1,321 @@
|
||||
<template>
|
||||
<aside class="ai-rail" :class="{ 'rail-collapsed': collapsed }" aria-label="AI模式导航">
|
||||
<section class="ai-rail-brand" aria-label="当前产品标识">
|
||||
<span class="ai-brand-logo" aria-hidden="true">
|
||||
<img v-if="brandLogo" :src="brandLogo" alt="" />
|
||||
<svg v-else viewBox="0 0 36 36">
|
||||
<path d="M19.8 4.5c5.7 1.1 9.9 5.7 10.5 11.6-2.8-.9-5.5-.7-7.9.6-2.8 1.5-4.5 4.3-5.2 8.2-4.4-2.8-6.5-6.5-6.3-11.1.2-4.2 3.5-7.8 8.9-9.3Z" />
|
||||
<path d="M9 7.6c-3 3.5-4 7.3-2.9 11.2 1.2 4.2 4.6 7 10.1 8.5-2 1.8-4.6 2.6-7.6 2.3C5.1 26.7 3.5 23.1 3.7 19 4 14.4 5.7 10.6 9 7.6Z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="ai-brand-copy">
|
||||
<strong>{{ displayBrandName }}</strong>
|
||||
<small>AI 财务工作台</small>
|
||||
</span>
|
||||
</section>
|
||||
|
||||
<section class="ai-rail-section ai-rail-quick" aria-label="对话操作">
|
||||
<template v-for="action in quickActions" :key="action.event">
|
||||
<label
|
||||
v-if="action.event === 'search' && conversationSearchOpen"
|
||||
class="ai-conversation-search"
|
||||
>
|
||||
<i class="mdi mdi-magnify" aria-hidden="true"></i>
|
||||
<input
|
||||
ref="conversationSearchInputRef"
|
||||
v-model="conversationSearchQuery"
|
||||
type="search"
|
||||
placeholder="搜索对话标题"
|
||||
@keydown.esc.prevent="closeConversationSearch"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="conversationSearchQuery ? '清空对话搜索' : '关闭对话搜索'"
|
||||
@click="handleConversationSearchAuxAction"
|
||||
>
|
||||
<i class="mdi mdi-close" aria-hidden="true"></i>
|
||||
</button>
|
||||
</label>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="ai-quick-btn"
|
||||
:class="{ primary: action.primary }"
|
||||
@click="handleQuickAction(action.event)"
|
||||
>
|
||||
<i :class="action.icon" aria-hidden="true"></i>
|
||||
<span>{{ action.label }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<div class="ai-rail-divider"></div>
|
||||
|
||||
<nav class="ai-rail-section ai-rail-nav" aria-label="业务导航">
|
||||
<div class="ai-nav-list">
|
||||
<button
|
||||
v-for="item in businessNavItems"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="ai-nav-btn"
|
||||
:class="{ active: activeView === item.id }"
|
||||
:aria-current="activeView === item.id ? 'page' : undefined"
|
||||
@click="emit('navigate', item.id)"
|
||||
>
|
||||
<span class="ai-nav-icon" aria-hidden="true">
|
||||
<i :class="item.aiIcon"></i>
|
||||
</span>
|
||||
<span class="ai-nav-copy">
|
||||
<strong>{{ item.displayLabel }}</strong>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="ai-rail-divider"></div>
|
||||
|
||||
<section class="ai-rail-section ai-rail-recents" aria-label="最近对话">
|
||||
<h2 class="ai-section-heading">最近对话</h2>
|
||||
<div class="ai-recents-list">
|
||||
<div
|
||||
v-for="recent in filteredConversationHistory"
|
||||
:key="recent.id"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="ai-recent-item"
|
||||
:class="{ active: activeConversationId === recent.id }"
|
||||
:aria-current="activeConversationId === recent.id ? 'true' : undefined"
|
||||
@click="handleRecentClick(recent)"
|
||||
@dblclick.stop="startEditingRecentTitle(recent)"
|
||||
@keydown.enter.prevent="emit('open-recent', recent)"
|
||||
@keydown.space.prevent="emit('open-recent', recent)"
|
||||
>
|
||||
<span class="ai-recent-main">
|
||||
<input
|
||||
v-if="editingConversationId === recent.id"
|
||||
ref="editingTitleInputRef"
|
||||
v-model="editingConversationTitle"
|
||||
class="ai-recent-title-input"
|
||||
type="text"
|
||||
aria-label="编辑对话标题"
|
||||
@click.stop
|
||||
@dblclick.stop
|
||||
@keydown.enter.prevent="commitRecentTitleEdit(recent)"
|
||||
@keydown.esc.prevent="cancelRecentTitleEdit"
|
||||
@blur="commitRecentTitleEdit(recent)"
|
||||
/>
|
||||
<span v-else class="ai-recent-title">{{ recent.title }}</span>
|
||||
<span class="ai-recent-desc">{{ recent.desc }}</span>
|
||||
</span>
|
||||
<span class="ai-recent-time">{{ recent.time }}</span>
|
||||
</div>
|
||||
<p v-if="!normalizedConversationHistory.length" class="ai-recents-empty">暂无历史对话</p>
|
||||
<p v-else-if="!filteredConversationHistory.length" class="ai-recents-empty">没有匹配的对话</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="ai-rail-user" aria-label="当前用户">
|
||||
<div class="ai-user-avatar" aria-hidden="true">{{ displayUser.avatar }}</div>
|
||||
<div class="ai-user-copy">
|
||||
<strong>{{ displayUser.name }}</strong>
|
||||
<span>{{ displayUser.subtitle }}</span>
|
||||
</div>
|
||||
<div class="ai-user-actions" aria-label="用户操作">
|
||||
<button type="button" class="ai-user-action ai-user-logout" aria-label="退出系统" @click="emit('logout')">
|
||||
<i class="mdi mdi-logout-variant" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, ref } from 'vue'
|
||||
|
||||
import { resolveAiSidebarBusinessViewIds } from '../../utils/aiSidebarBusinessAccess.js'
|
||||
|
||||
const props = defineProps({
|
||||
navItems: { type: Array, required: true },
|
||||
activeView: { type: String, required: true },
|
||||
activeConversationId: { type: String, default: '' },
|
||||
brandName: { type: String, default: '' },
|
||||
brandLogo: { type: String, default: '' },
|
||||
currentUser: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
name: '系统管理员',
|
||||
role: '管理员',
|
||||
avatar: '管'
|
||||
})
|
||||
},
|
||||
collapsed: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
conversationHistory: { type: Array, default: () => [] }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['navigate', 'new-chat', 'open-recent', 'rename-conversation', 'logout'])
|
||||
const conversationSearchOpen = ref(false)
|
||||
const conversationSearchQuery = ref('')
|
||||
const conversationSearchInputRef = ref(null)
|
||||
const editingConversationId = ref('')
|
||||
const editingConversationTitle = ref('')
|
||||
const editingTitleInputRef = ref(null)
|
||||
let recentClickTimer = null
|
||||
|
||||
const quickActions = [
|
||||
{
|
||||
label: '新建对话',
|
||||
icon: 'mdi mdi-plus',
|
||||
event: 'new-chat',
|
||||
primary: true
|
||||
},
|
||||
{
|
||||
label: '查询对话',
|
||||
icon: 'mdi mdi-magnify',
|
||||
event: 'search'
|
||||
}
|
||||
]
|
||||
|
||||
const displayBrandName = computed(() => String(props.brandName || '易财费控').trim() || '易财费控')
|
||||
|
||||
const sidebarMeta = {
|
||||
overview: { label: '分析看板', icon: 'mdi mdi-chart-line-variant' },
|
||||
documents: { label: '单据中心', icon: 'mdi mdi-file-document-outline' },
|
||||
receiptFolder: { label: '票据夹', icon: 'mdi mdi-receipt-text-outline' },
|
||||
budget: { label: '预算管理', icon: 'mdi mdi-chart-donut' },
|
||||
policies: { label: '财务政策', icon: 'mdi mdi-book-open-page-variant-outline' },
|
||||
audit: { label: '规则管理', icon: 'mdi mdi-shield-check-outline' },
|
||||
digitalEmployees: { label: '数字员工', icon: 'mdi mdi-robot-outline' },
|
||||
employees: { label: '员工管理', icon: 'mdi mdi-account-group-outline' },
|
||||
settings: { label: '系统设置', icon: 'mdi mdi-tune-vertical' }
|
||||
}
|
||||
|
||||
const aiBusinessViewIds = computed(() => new Set(resolveAiSidebarBusinessViewIds(props.currentUser)))
|
||||
|
||||
const businessNavItems = computed(() =>
|
||||
props.navItems
|
||||
.filter((item) => aiBusinessViewIds.value.has(item.id))
|
||||
.map((item) => ({
|
||||
...item,
|
||||
displayLabel: sidebarMeta[item.id]?.label ?? item.label,
|
||||
aiIcon: sidebarMeta[item.id]?.icon ?? 'mdi mdi-circle-outline'
|
||||
}))
|
||||
)
|
||||
|
||||
const normalizedConversationHistory = computed(() => (
|
||||
Array.isArray(props.conversationHistory) ? props.conversationHistory : []
|
||||
))
|
||||
|
||||
const filteredConversationHistory = computed(() => {
|
||||
const query = conversationSearchQuery.value.trim().toLowerCase()
|
||||
if (!query) {
|
||||
return normalizedConversationHistory.value
|
||||
}
|
||||
|
||||
return normalizedConversationHistory.value.filter((recent) => (
|
||||
String(recent.title || '').toLowerCase().includes(query)
|
||||
))
|
||||
})
|
||||
|
||||
const displayUser = computed(() => ({
|
||||
name: props.currentUser?.name || '系统管理员',
|
||||
subtitle:
|
||||
props.currentUser?.email ||
|
||||
(props.currentUser?.username && props.currentUser?.username !== props.currentUser?.name ? props.currentUser.username : '') ||
|
||||
props.currentUser?.role ||
|
||||
'审批负责人',
|
||||
avatar: props.currentUser?.avatar || String(props.currentUser?.name || '管').trim().slice(0, 1) || '管'
|
||||
}))
|
||||
|
||||
function handleQuickAction(event) {
|
||||
if (event === 'new-chat') {
|
||||
emit('new-chat')
|
||||
return
|
||||
}
|
||||
if (event === 'search') {
|
||||
openConversationSearch()
|
||||
}
|
||||
}
|
||||
|
||||
function openConversationSearch() {
|
||||
conversationSearchOpen.value = true
|
||||
void nextTick(() => {
|
||||
resolveInputElement(conversationSearchInputRef.value)?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
function closeConversationSearch() {
|
||||
conversationSearchOpen.value = false
|
||||
conversationSearchQuery.value = ''
|
||||
}
|
||||
|
||||
function handleConversationSearchAuxAction() {
|
||||
if (conversationSearchQuery.value) {
|
||||
conversationSearchQuery.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
closeConversationSearch()
|
||||
}
|
||||
|
||||
function startEditingRecentTitle(recent = {}) {
|
||||
clearRecentClickTimer()
|
||||
editingConversationId.value = String(recent.id || '').trim()
|
||||
editingConversationTitle.value = String(recent.title || '').trim()
|
||||
void nextTick(() => {
|
||||
const input = resolveInputElement(editingTitleInputRef.value)
|
||||
input?.focus()
|
||||
input?.select()
|
||||
})
|
||||
}
|
||||
|
||||
function handleRecentClick(recent = {}) {
|
||||
clearRecentClickTimer()
|
||||
recentClickTimer = window.setTimeout(() => {
|
||||
emit('open-recent', recent)
|
||||
recentClickTimer = null
|
||||
}, 180)
|
||||
}
|
||||
|
||||
function clearRecentClickTimer() {
|
||||
if (recentClickTimer) {
|
||||
window.clearTimeout(recentClickTimer)
|
||||
recentClickTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function cancelRecentTitleEdit() {
|
||||
editingConversationId.value = ''
|
||||
editingConversationTitle.value = ''
|
||||
}
|
||||
|
||||
function commitRecentTitleEdit(recent = {}) {
|
||||
if (editingConversationId.value !== String(recent.id || '').trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
const title = editingConversationTitle.value.trim()
|
||||
cancelRecentTitleEdit()
|
||||
if (!title || title === String(recent.title || '').trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('rename-conversation', {
|
||||
id: recent.id,
|
||||
title
|
||||
})
|
||||
}
|
||||
|
||||
function resolveInputElement(value) {
|
||||
return Array.isArray(value) ? value[0] : value
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearRecentClickTimer()
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped src="../../assets/styles/components/ai-sidebar-rail.css"></style>
|
||||
@@ -56,63 +56,24 @@
|
||||
</ElTooltip>
|
||||
</nav>
|
||||
|
||||
<div
|
||||
class="rail-user"
|
||||
@mouseenter="openCollapsedUserMenu"
|
||||
@mouseleave="closeCollapsedUserMenu"
|
||||
@focusin="openCollapsedUserMenu"
|
||||
@focusout="handleUserFocusOut"
|
||||
>
|
||||
<div v-if="!collapsed" class="user-menu" role="menu" aria-label="用户菜单">
|
||||
<button class="user-menu-item" type="button" @click="emit('logout')">
|
||||
<i class="mdi mdi-logout-variant"></i>
|
||||
<span>退出系统</span>
|
||||
<section class="rail-user" aria-label="当前用户">
|
||||
<div class="user-avatar" aria-hidden="true">{{ displayUser.avatar }}</div>
|
||||
<div class="user-copy">
|
||||
<strong>{{ displayUser.name }}</strong>
|
||||
<span>{{ displayUser.subtitle }}</span>
|
||||
</div>
|
||||
<div class="user-actions" aria-label="用户操作">
|
||||
<button type="button" class="user-action user-logout" aria-label="退出系统" @click="emit('logout')">
|
||||
<i class="mdi mdi-logout-variant" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="collapsed && userMenuOpen"
|
||||
class="rail-user-menu-floating"
|
||||
:style="userMenuStyle"
|
||||
role="menu"
|
||||
aria-label="用户菜单"
|
||||
@mouseenter="clearUserMenuCloseTimer"
|
||||
@mouseleave="closeCollapsedUserMenu"
|
||||
>
|
||||
<button class="user-menu-item" type="button" @click="handleLogout">
|
||||
<i class="mdi mdi-logout-variant"></i>
|
||||
<span>退出系统</span>
|
||||
</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<ElTooltip
|
||||
:content="userTooltipContent"
|
||||
placement="top"
|
||||
effect="light"
|
||||
:disabled="!collapsed || userMenuOpen"
|
||||
:show-after="180"
|
||||
:hide-after="0"
|
||||
:offset="10"
|
||||
popper-class="rail-tooltip-popper"
|
||||
>
|
||||
<div class="user-summary" tabindex="0" :aria-label="userTooltipContent">
|
||||
<span class="user-avatar">{{ displayUser.avatar }}</span>
|
||||
<span class="user-copy">
|
||||
<strong>{{ displayUser.name }}</strong>
|
||||
<span>{{ displayUser.role }}</span>
|
||||
</span>
|
||||
<i class="mdi mdi-chevron-up"></i>
|
||||
</div>
|
||||
</ElTooltip>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ElTooltip } from 'element-plus/es/components/tooltip/index.mjs'
|
||||
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
navItems: { type: Array, required: true },
|
||||
@@ -164,99 +125,16 @@ const decoratedNavItems = computed(() =>
|
||||
|
||||
const displayUser = computed(() => ({
|
||||
name: props.currentUser?.name || '系统管理员',
|
||||
role: props.currentUser?.role || '管理员',
|
||||
avatar: props.currentUser?.avatar || '管'
|
||||
subtitle:
|
||||
props.currentUser?.email ||
|
||||
(props.currentUser?.username && props.currentUser?.username !== props.currentUser?.name ? props.currentUser.username : '') ||
|
||||
props.currentUser?.role ||
|
||||
'管理员',
|
||||
avatar: props.currentUser?.avatar || String(props.currentUser?.name || '管').trim().slice(0, 1) || '管'
|
||||
}))
|
||||
|
||||
const displayCompanyName = computed(() => props.companyName || '易财费控')
|
||||
const collapseTooltipContent = computed(() => (props.collapsed ? '展开侧边栏' : '折叠侧边栏'))
|
||||
const userTooltipContent = computed(() => [displayUser.value.name, displayUser.value.role].filter(Boolean).join(' · '))
|
||||
|
||||
const userMenuOpen = ref(false)
|
||||
let userMenuCloseTimer = null
|
||||
const userMenuPosition = reactive({
|
||||
top: 0,
|
||||
left: 0
|
||||
})
|
||||
|
||||
const userMenuStyle = computed(() => ({
|
||||
top: `${userMenuPosition.top}px`,
|
||||
left: `${userMenuPosition.left}px`
|
||||
}))
|
||||
|
||||
function resolveUserMenuAnchor(element) {
|
||||
return element?.querySelector?.('.user-summary') || element
|
||||
}
|
||||
|
||||
function clearUserMenuCloseTimer() {
|
||||
if (userMenuCloseTimer) {
|
||||
clearTimeout(userMenuCloseTimer)
|
||||
userMenuCloseTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function openCollapsedUserMenu(event) {
|
||||
if (!props.collapsed) {
|
||||
return
|
||||
}
|
||||
|
||||
clearUserMenuCloseTimer()
|
||||
|
||||
const anchor = resolveUserMenuAnchor(event?.currentTarget)
|
||||
if (!anchor?.getBoundingClientRect) {
|
||||
return
|
||||
}
|
||||
|
||||
const rect = anchor.getBoundingClientRect()
|
||||
userMenuPosition.top = rect.top + rect.height / 2
|
||||
userMenuPosition.left = rect.right + 12
|
||||
userMenuOpen.value = true
|
||||
}
|
||||
|
||||
function closeCollapsedUserMenu() {
|
||||
clearUserMenuCloseTimer()
|
||||
userMenuCloseTimer = setTimeout(() => {
|
||||
userMenuOpen.value = false
|
||||
userMenuCloseTimer = null
|
||||
}, 120)
|
||||
}
|
||||
|
||||
function closeCollapsedUserMenuNow() {
|
||||
clearUserMenuCloseTimer()
|
||||
userMenuOpen.value = false
|
||||
}
|
||||
|
||||
function handleUserFocusOut(event) {
|
||||
if (!props.collapsed) {
|
||||
return
|
||||
}
|
||||
|
||||
const container = event.currentTarget
|
||||
const nextTarget = event.relatedTarget
|
||||
if (nextTarget && container?.contains(nextTarget)) {
|
||||
return
|
||||
}
|
||||
|
||||
closeCollapsedUserMenuNow()
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
closeCollapsedUserMenuNow()
|
||||
emit('logout')
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.collapsed,
|
||||
(isCollapsed) => {
|
||||
if (!isCollapsed) {
|
||||
closeCollapsedUserMenuNow()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
closeCollapsedUserMenuNow()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<template>
|
||||
<header class="topbar" :class="{ 'chat-mode': isChat, 'detail-mode': isRequestDetail }">
|
||||
<div class="title-group">
|
||||
<div v-if="!isWorkbenchAiHome" class="title-group">
|
||||
<div class="eyebrow">{{ eyebrowLabel }}</div>
|
||||
<h1>{{ currentView.title }}</h1>
|
||||
<p>{{ currentView.desc }}</p>
|
||||
</div>
|
||||
<div v-else class="title-group" aria-hidden="true"></div>
|
||||
|
||||
<div class="top-actions">
|
||||
<template v-if="isChat">
|
||||
@@ -278,12 +279,23 @@
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<button class="company-switcher" type="button" aria-label="切换公司">
|
||||
<span>{{ displayCompanyName }}</span>
|
||||
<i class="mdi mdi-chevron-down"></i>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<button class="company-switcher" type="button" aria-label="切换公司">
|
||||
<span>{{ displayCompanyName }}</span>
|
||||
<i class="mdi mdi-chevron-down"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="topbar-ai-mode-toggle"
|
||||
:class="{ active: isTopbarAiMode }"
|
||||
:aria-pressed="isTopbarAiMode"
|
||||
:aria-label="topbarWorkbenchModeTitle"
|
||||
:title="topbarWorkbenchModeTitle"
|
||||
@click="toggleTopbarWorkbenchMode"
|
||||
>
|
||||
<span class="topbar-ai-mode-toggle__glyph">AI</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="isDocuments">
|
||||
<div class="kpi-chips">
|
||||
@@ -345,18 +357,36 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="isEmployees">
|
||||
<div class="kpi-chips">
|
||||
<div v-for="kpi in employeeKpis" :key="kpi.label" class="kpi-chip" :style="{ '--chip-color': kpi.color }">
|
||||
<span class="chip-value">{{ kpi.value }}<small>{{ kpi.unit }}</small></span>
|
||||
<span class="chip-label">{{ kpi.label }}</span>
|
||||
<span class="chip-delta" :class="kpi.trend">{{ kpi.meta }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
<template v-else-if="isEmployees">
|
||||
<div class="kpi-chips">
|
||||
<div v-for="kpi in employeeKpis" :key="kpi.label" class="kpi-chip" :style="{ '--chip-color': kpi.color }">
|
||||
<span class="chip-value">{{ kpi.value }}<small>{{ kpi.unit }}</small></span>
|
||||
<span class="chip-label">{{ kpi.label }}</span>
|
||||
<span class="chip-delta" :class="kpi.trend">{{ kpi.meta }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="showAiModeUtilityActions" class="topbar-utility-actions" aria-label="AI模式快捷操作">
|
||||
<button class="company-switcher" type="button" aria-label="切换公司">
|
||||
<span>{{ displayCompanyName }}</span>
|
||||
<i class="mdi mdi-chevron-down"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="topbar-ai-mode-toggle"
|
||||
:class="{ active: isTopbarAiMode }"
|
||||
:aria-pressed="isTopbarAiMode"
|
||||
:aria-label="topbarWorkbenchModeTitle"
|
||||
:title="topbarWorkbenchModeTitle"
|
||||
@click="toggleTopbarWorkbenchMode"
|
||||
>
|
||||
<span class="topbar-ai-mode-toggle__glyph">AI</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
@@ -394,14 +424,18 @@ const props = defineProps({
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
workbenchSummary: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
companyName: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
workbenchSummary: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
workbenchMode: {
|
||||
type: String,
|
||||
default: 'traditional'
|
||||
},
|
||||
companyName: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
detailMode: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
@@ -431,10 +465,11 @@ const emit = defineEmits([
|
||||
'update:overviewDashboard',
|
||||
'batchApprove',
|
||||
'openChat',
|
||||
'newApplication',
|
||||
'openDocument',
|
||||
'navigate'
|
||||
])
|
||||
'newApplication',
|
||||
'openDocument',
|
||||
'navigate',
|
||||
'toggleWorkbenchMode'
|
||||
])
|
||||
const isChat = computed(() => props.activeView === 'chat')
|
||||
const isOverview = computed(() => props.activeView === 'overview')
|
||||
const isWorkbench = computed(() => props.activeView === 'workbench')
|
||||
@@ -444,12 +479,16 @@ const isRequests = computed(() => props.activeView === 'requests')
|
||||
const isDigitalEmployees = computed(() => props.activeView === 'digitalEmployees')
|
||||
const isApproval = computed(() => props.activeView === 'approval')
|
||||
const isPolicies = computed(() => props.activeView === 'policies')
|
||||
const isEmployees = computed(() => props.activeView === 'employees')
|
||||
const isEmployees = computed(() => props.activeView === 'employees')
|
||||
const eyebrowLabel = computed(() => (
|
||||
String(props.currentView?.eyebrow || '').trim()
|
||||
|| (isChat.value ? 'Smart Finance Q&A' : 'Smart Expense Operations')
|
||||
))
|
||||
const displayCompanyName = computed(() => String(props.companyName || '远光软件股份有限公司').trim() || '远光软件股份有限公司')
|
||||
const isTopbarAiMode = computed(() => props.workbenchMode === 'ai')
|
||||
const topbarWorkbenchModeTitle = computed(() => (isTopbarAiMode.value ? 'AI 模式,点击切换传统模式' : '传统模式,点击切换 AI 模式'))
|
||||
const isWorkbenchAiHome = computed(() => isWorkbench.value && isTopbarAiMode.value)
|
||||
const showAiModeUtilityActions = computed(() => isTopbarAiMode.value && !isWorkbench.value)
|
||||
const MAX_NOTIFICATION_ITEMS = 30
|
||||
const {
|
||||
markDocumentInboxRowRead,
|
||||
@@ -576,12 +615,16 @@ const readNotifications = computed(() => notificationItems.value.filter((item) =
|
||||
const activeNotifications = computed(() => (
|
||||
notificationTab.value === 'unread' ? unreadNotifications.value : readNotifications.value
|
||||
))
|
||||
const topbarNotificationCount = computed(() => {
|
||||
const count = unreadNotifications.value.length
|
||||
return count > 0 ? Math.min(count, 99) : 0
|
||||
})
|
||||
|
||||
function clearDocumentInboxInitialRefreshTimer() {
|
||||
const topbarNotificationCount = computed(() => {
|
||||
const count = unreadNotifications.value.length
|
||||
return count > 0 ? Math.min(count, 99) : 0
|
||||
})
|
||||
|
||||
function toggleTopbarWorkbenchMode() {
|
||||
emit('toggleWorkbenchMode')
|
||||
}
|
||||
|
||||
function clearDocumentInboxInitialRefreshTimer() {
|
||||
if (documentInboxInitialRefreshTimer && typeof window !== 'undefined') {
|
||||
window.clearTimeout(documentInboxInitialRefreshTimer)
|
||||
documentInboxInitialRefreshTimer = null
|
||||
|
||||
@@ -18,32 +18,45 @@
|
||||
<p>{{ decisionDescription }}</p>
|
||||
</div>
|
||||
<div class="employee-risk-decision-action">
|
||||
<span>建议结论</span>
|
||||
<strong :class="decisionTone">{{ decisionAction }}</strong>
|
||||
<span>是否建议通过</span>
|
||||
<strong :class="decisionTone">{{ decisionBadgeLabel }}</strong>
|
||||
<p>{{ decisionAction }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="employee-risk-profile-section" aria-label="单据风险依据">
|
||||
<dl class="employee-risk-review-summary" aria-label="审核建议摘要">
|
||||
<div
|
||||
v-for="item in reviewSummaryItems"
|
||||
:key="item.key"
|
||||
:class="['employee-risk-review-item', item.tone]"
|
||||
>
|
||||
<dt>{{ item.label }}</dt>
|
||||
<dd>{{ item.value }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<section class="employee-risk-profile-section" aria-label="单据关键依据">
|
||||
<div class="employee-risk-section-head">
|
||||
<span>{{ stageBasisTitle }}</span>
|
||||
<small>{{ stageBasisHint }}</small>
|
||||
</div>
|
||||
<div v-if="compactEvidenceItems.length" class="employee-risk-profile-list">
|
||||
<article
|
||||
v-for="item in compactEvidenceItems"
|
||||
<details
|
||||
v-for="(item, index) in compactEvidenceItems"
|
||||
:key="item.code"
|
||||
:class="['employee-risk-evidence-row', item.tone]"
|
||||
:open="index === 0"
|
||||
>
|
||||
<div class="employee-risk-evidence-title">
|
||||
<summary class="employee-risk-evidence-title">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.status }}</strong>
|
||||
</div>
|
||||
</summary>
|
||||
<ul v-if="item.evidence.length">
|
||||
<li v-for="basis in item.evidence" :key="basis">{{ basis }}</li>
|
||||
</ul>
|
||||
</article>
|
||||
</details>
|
||||
</div>
|
||||
<p v-else class="employee-risk-muted">当前未识别到需要重点展示的单据风险依据。</p>
|
||||
<p v-else class="employee-risk-muted">当前未识别到需要重点展示的单据依据。</p>
|
||||
</section>
|
||||
</div>
|
||||
</article>
|
||||
@@ -95,12 +108,12 @@ export default {
|
||||
}
|
||||
return 'normal'
|
||||
})
|
||||
const stageTitle = computed(() => props.isApplicationDocument ? '申请审核建议' : '报销审核建议')
|
||||
const stageBasisTitle = computed(() => props.isApplicationDocument ? '申请单风险依据' : '报销单风险依据')
|
||||
const stageTitle = computed(() => props.isApplicationDocument ? '申请审核建议' : 'AI建议')
|
||||
const stageBasisTitle = computed(() => props.isApplicationDocument ? '申请单关键依据' : '报销单关键依据')
|
||||
const stageBasisHint = computed(() => (
|
||||
props.isApplicationDocument
|
||||
? '仅展示申请单本身的金额、预算触发、事由和规则命中依据。'
|
||||
: '仅展示报销单本身的票据、金额、行程和规则命中依据。'
|
||||
? '默认只展开最关键的申请依据,其他细节点开查看。'
|
||||
: '默认只展开最关键的报销依据,其他细节点开查看。'
|
||||
))
|
||||
const decisionTitle = computed(() => resolveDecision(decisionTone.value, props.isApplicationDocument).title)
|
||||
const decisionAction = computed(() => {
|
||||
@@ -111,25 +124,26 @@ export default {
|
||||
})
|
||||
const decisionBadgeLabel = computed(() => {
|
||||
if (decisionTone.value === 'high') {
|
||||
return '高风险'
|
||||
return '不通过'
|
||||
}
|
||||
if (decisionTone.value === 'medium') {
|
||||
return '需关注'
|
||||
return '待补充'
|
||||
}
|
||||
return '可审批'
|
||||
return '可通过'
|
||||
})
|
||||
const decisionDescription = computed(() => {
|
||||
const riskCount = currentRiskCards.value.length
|
||||
const subject = props.isApplicationDocument ? '申请' : '报销'
|
||||
if (riskCount) {
|
||||
if (!props.isApplicationDocument && riskExplanationItems.value.length) {
|
||||
return `当前报销已识别 ${riskCount} 个需核对风险点,用户已补充异常说明,审批人应核对说明与票据佐证是否充分。`
|
||||
return `当前${subject}识别到 ${riskCount} 个需核对风险点,已补充说明但仍建议先核对票据与行程。`
|
||||
}
|
||||
return `${props.isApplicationDocument ? '当前申请' : '当前报销'}已识别 ${riskCount} 个需核对风险点,审批人应优先查看中高风险依据。`
|
||||
return `当前${subject}识别到 ${riskCount} 个需核对风险点,请优先查看高风险依据。`
|
||||
}
|
||||
if (materialIssues.value.length || sceneIssues.value.length) {
|
||||
return `${props.isApplicationDocument ? '当前申请' : '当前报销'}存在材料或业务说明不完整,建议补齐后再继续处理。`
|
||||
return `当前${subject}存在材料或业务说明不完整,建议补齐后再处理。`
|
||||
}
|
||||
return `${props.isApplicationDocument ? '当前申请' : '当前报销'}未发现中高风险阻断项,可结合当前环节权限按流程处理。`
|
||||
return `当前${subject}未发现中高风险阻断项,可按流程继续处理。`
|
||||
})
|
||||
const stageEvidenceItems = computed(() => (
|
||||
props.isApplicationDocument ? buildApplicationEvidence() : buildReimbursementEvidence()
|
||||
@@ -139,6 +153,38 @@ export default {
|
||||
const sourceItems = abnormalItems.length ? abnormalItems : stageEvidenceItems.value
|
||||
return sourceItems.map((item) => ({ ...item }))
|
||||
})
|
||||
const stageRiskFactSummary = computed(() => buildStageRiskFactSummary({
|
||||
isApplicationDocument: props.isApplicationDocument,
|
||||
riskCount: currentRiskCards.value.length,
|
||||
highCount: highRiskCards.value.length,
|
||||
mediumCount: mediumRiskCards.value.length,
|
||||
materialIssueCount: materialIssues.value.length,
|
||||
sceneIssueCount: sceneIssues.value.length
|
||||
}))
|
||||
const stageReviewBasisSummary = computed(() => buildStageReviewBasisSummary(
|
||||
compactEvidenceItems.value,
|
||||
props.isApplicationDocument
|
||||
))
|
||||
const reviewSummaryItems = computed(() => [
|
||||
{
|
||||
key: 'fact',
|
||||
label: '风险概览',
|
||||
tone: decisionTone.value,
|
||||
value: stageRiskFactSummary.value
|
||||
},
|
||||
{
|
||||
key: 'basis',
|
||||
label: '重点依据',
|
||||
tone: decisionTone.value,
|
||||
value: stageReviewBasisSummary.value
|
||||
},
|
||||
{
|
||||
key: 'action',
|
||||
label: '审核建议',
|
||||
tone: decisionTone.value,
|
||||
value: decisionAction.value
|
||||
}
|
||||
])
|
||||
|
||||
function buildApplicationEvidence() {
|
||||
const budgetCards = currentRiskCards.value.filter((card) => /预算|余额|占用|超预算/.test(cardText(card)))
|
||||
@@ -217,28 +263,68 @@ export default {
|
||||
decisionDescription,
|
||||
decisionAction,
|
||||
decisionTitle,
|
||||
reviewSummaryItems,
|
||||
stageBasisHint,
|
||||
stageBasisTitle,
|
||||
stageEvidenceItems,
|
||||
stageReviewBasisSummary,
|
||||
stageRiskFactSummary,
|
||||
stageTitle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildStageRiskFactSummary({
|
||||
isApplicationDocument,
|
||||
riskCount = 0,
|
||||
highCount = 0,
|
||||
mediumCount = 0,
|
||||
materialIssueCount = 0,
|
||||
sceneIssueCount = 0
|
||||
} = {}) {
|
||||
const subject = isApplicationDocument ? '申请单' : '报销单'
|
||||
if (riskCount > 0) {
|
||||
return `${subject}识别 ${riskCount} 个需核对风险点,高风险 ${highCount} 个,中风险 ${mediumCount} 个。`
|
||||
}
|
||||
const issueCount = materialIssueCount + sceneIssueCount
|
||||
if (issueCount > 0) {
|
||||
return `${subject}暂无中高风险命中,但仍有 ${issueCount} 个材料或业务说明项需要补齐。`
|
||||
}
|
||||
return `${subject}未识别到中高风险阻断项。`
|
||||
}
|
||||
|
||||
function buildStageReviewBasisSummary(evidenceItems = [], isApplicationDocument = false) {
|
||||
const abnormalLabels = evidenceItems
|
||||
.filter((item) => isAbnormalEvidence(item))
|
||||
.map((item) => String(item?.label || '').trim())
|
||||
.filter(Boolean)
|
||||
if (abnormalLabels.length) {
|
||||
return `重点核对${abnormalLabels.join('、')}。`
|
||||
}
|
||||
return isApplicationDocument
|
||||
? '重点看申请金额、预算触发和事由是否一致。'
|
||||
: '重点看票据、金额、行程和附件是否一致。'
|
||||
}
|
||||
|
||||
function resolveDecision(tone, isApplicationDocument) {
|
||||
const subject = isApplicationDocument ? '申请' : '报销'
|
||||
const map = {
|
||||
normal: {
|
||||
title: `当前${subject}未发现中高风险阻断项`,
|
||||
action: `可按权限继续审批${isApplicationDocument ? ',系统会按预算结果决定是否跳过预算复核。' : ',后续进入财务或付款流程。'}`
|
||||
title: '建议通过',
|
||||
action: isApplicationDocument
|
||||
? '可按权限继续审核,系统会按预算结果决定是否进入下一步。'
|
||||
: '可按权限继续审批,后续进入财务或付款流程。'
|
||||
},
|
||||
medium: {
|
||||
title: `当前${subject}存在中风险,建议核对后处理`,
|
||||
action: isApplicationDocument ? '建议核对预算占用、申请事由和金额依据后再通过。' : '建议核对票据、金额和业务说明后再通过。'
|
||||
title: '建议补充后通过',
|
||||
action: isApplicationDocument
|
||||
? '建议补充预算占用、申请事由和金额依据后再通过。'
|
||||
: '建议补充票据、金额或业务说明后再通过。'
|
||||
},
|
||||
high: {
|
||||
title: `当前${subject}存在高风险,不建议直接通过`,
|
||||
action: isApplicationDocument ? '建议退回补充申请依据,或要求预算管理者复核。' : '建议退回补充票据、行程说明或超标原因。'
|
||||
title: '不建议通过',
|
||||
action: isApplicationDocument
|
||||
? '建议退回补充申请依据,或要求预算管理者复核。'
|
||||
: '建议退回补充票据、行程说明或超标原因。'
|
||||
}
|
||||
}
|
||||
return map[tone] || map.normal
|
||||
|
||||
@@ -278,6 +278,7 @@
|
||||
<div
|
||||
v-if="message.role === 'assistant' && !message.reviewPayload && !message.queryPayload && message.suggestedActions?.length"
|
||||
class="message-suggested-actions"
|
||||
:class="{ 'compact-guidance-actions': message.assistantVariant === 'compact_guidance' }"
|
||||
>
|
||||
<button
|
||||
v-for="action in message.suggestedActions"
|
||||
|
||||
Reference in New Issue
Block a user