refactor: consolidate finance workflow modules

This commit is contained in:
caoxiaozhu
2026-06-23 11:21:18 +08:00
parent 1f40ce3df3
commit 73966b3a7b
52 changed files with 3468 additions and 2865 deletions

View File

@@ -28,183 +28,11 @@
<p>您可以直接向我提问,或选择下方推荐主题,快速完成费用相关事务</p>
</div>
<form class="workbench-ai-composer" @submit.prevent="submitAiModePrompt">
<div class="workbench-ai-composer-field">
<div v-if="workbenchDateTagLabel" class="workbench-ai-date-chip">
<i class="mdi mdi-calendar-check-outline"></i>
<span>{{ workbenchDateTagLabel }}</span>
<button type="button" aria-label="移除日期" :disabled="isAiModeInputLocked" @click="removeWorkbenchDateTag">
<i class="mdi mdi-close"></i>
</button>
</div>
<textarea
ref="assistantInputRef"
v-model="assistantDraft"
maxlength="1000"
rows="3"
:placeholder="isAiModeInputLocked ? '费用测算中,请稍等...' : '今天我能帮您做点什么?'"
:disabled="isAiModeInputLocked"
@keydown.enter.exact.prevent="submitAiModePrompt"
></textarea>
</div>
<div class="workbench-ai-composer-toolbar">
<div class="workbench-ai-tool-buttons">
<div class="workbench-date-anchor workbench-ai-date-anchor">
<button
type="button"
class="workbench-ai-icon-btn"
:class="{ active: workbenchDatePickerOpen || workbenchDateTagLabel }"
title="选择日期"
aria-label="选择日期"
:aria-expanded="workbenchDatePickerOpen"
:disabled="isAiModeInputLocked"
@click.stop="toggleWorkbenchDatePicker"
>
<i class="mdi mdi-calendar-range"></i>
</button>
<div
v-if="workbenchDatePickerOpen"
class="workbench-ai-date-popover"
role="dialog"
aria-label="选择业务日期"
@click.stop
>
<div class="workbench-ai-date-tabs" role="tablist" aria-label="日期模式">
<button
type="button"
:class="{ active: workbenchDateMode === 'single' }"
:disabled="isAiModeInputLocked"
@click="setWorkbenchDateMode('single')"
>
单日
</button>
<button
type="button"
:class="{ active: workbenchDateMode === 'range' }"
:disabled="isAiModeInputLocked"
@click="setWorkbenchDateMode('range')"
>
范围
</button>
</div>
<label v-if="workbenchDateMode === 'single'" class="workbench-ai-date-field">
<span>业务日期</span>
<input
v-model="workbenchSingleDate"
type="date"
:disabled="isAiModeInputLocked"
@change="handleWorkbenchDateInputChange('single')"
/>
</label>
<div v-else class="workbench-ai-date-range">
<label class="workbench-ai-date-field">
<span>开始日期</span>
<input
v-model="workbenchRangeStartDate"
type="date"
:disabled="isAiModeInputLocked"
@change="handleWorkbenchDateInputChange('range-start')"
/>
</label>
<label class="workbench-ai-date-field">
<span>结束日期</span>
<input
v-model="workbenchRangeEndDate"
type="date"
:disabled="isAiModeInputLocked"
@change="handleWorkbenchDateInputChange('range-end')"
/>
</label>
</div>
<div class="workbench-ai-date-actions">
<button type="button" class="ghost" :disabled="isAiModeInputLocked" @click="clearWorkbenchDateSelection">清除</button>
<button
type="button"
class="primary"
:disabled="!workbenchCanApplyDateSelection || isAiModeInputLocked"
@click="applyWorkbenchDateSelection"
>
完成
</button>
</div>
</div>
</div>
<button
type="button"
class="workbench-ai-icon-btn"
title="上传附件"
aria-label="上传附件"
:disabled="isAiModeInputLocked"
@click="triggerAiModeFileUpload"
>
<i class="mdi mdi-paperclip"></i>
</button>
<button
type="button"
class="workbench-ai-icon-btn"
title="语音输入"
aria-label="语音输入"
:disabled="isAiModeInputLocked"
@click="handleVoiceInput"
>
<i class="mdi mdi-microphone-outline"></i>
</button>
</div>
<div class="workbench-ai-composer-right">
<div class="workbench-ai-model-selector" :title="modelSelectorTitle">
<span>{{ displayModelName }}</span>
<i class="mdi mdi-chevron-down"></i>
</div>
<button
type="submit"
class="workbench-ai-send-btn"
:disabled="!canSubmitAiModePrompt || sending || isAiModeInputLocked"
aria-label="发送给小财管家"
>
<i class="mdi mdi-arrow-up"></i>
</button>
</div>
</div>
</form>
<div v-if="selectedFileCards.length" class="workbench-ai-file-strip" aria-label="已选择附件">
<article v-for="file in selectedFileCards" :key="file.key" class="workbench-ai-file-card">
<span class="workbench-ai-file-card__icon" :class="`type-${file.tone}`" aria-hidden="true">
<i :class="file.icon"></i>
</span>
<span class="workbench-ai-file-card__body">
<strong :title="file.name">{{ file.name }}</strong>
<small>{{ file.typeLabel }}</small>
<small
v-if="file.ocrState?.label"
class="workbench-ai-file-card__ocr"
:class="`is-${file.ocrState.status || 'idle'}`"
:title="file.ocrState.title || file.ocrState.label"
>
<i v-if="file.ocrState.status === 'recognizing'" class="mdi mdi-loading"></i>
<i v-else-if="file.ocrState.status === 'recognized'" class="mdi mdi-text-recognition"></i>
<i v-else-if="file.ocrState.status === 'failed'" class="mdi mdi-alert-circle-outline"></i>
<span>{{ file.ocrState.label }}</span>
</small>
</span>
<button
type="button"
class="workbench-ai-file-card__remove"
:disabled="isAiModeInputLocked"
:aria-label="`移除附件 ${file.name}`"
@click="removeAiModeFile(file.key)"
>
<i class="mdi mdi-close"></i>
</button>
</article>
</div>
<WorkbenchAiComposer
:runtime="workbenchAiRuntime"
placeholder="今天我能帮您做点什么?"
/>
<WorkbenchAiFileStrip :runtime="workbenchAiRuntime" />
<div class="workbench-ai-quick-start-section">
<h3 class="workbench-ai-quick-start-title">快速开始</h3>
@@ -565,183 +393,12 @@
</div>
<div class="workbench-ai-conversation-bottom">
<div v-if="selectedFileCards.length" class="workbench-ai-file-strip inline" aria-label="已选择附件">
<article v-for="file in selectedFileCards" :key="file.key" class="workbench-ai-file-card">
<span class="workbench-ai-file-card__icon" :class="`type-${file.tone}`" aria-hidden="true">
<i :class="file.icon"></i>
</span>
<span class="workbench-ai-file-card__body">
<strong :title="file.name">{{ file.name }}</strong>
<small>{{ file.typeLabel }}</small>
<small
v-if="file.ocrState?.label"
class="workbench-ai-file-card__ocr"
:class="`is-${file.ocrState.status || 'idle'}`"
:title="file.ocrState.title || file.ocrState.label"
>
<i v-if="file.ocrState.status === 'recognizing'" class="mdi mdi-loading"></i>
<i v-else-if="file.ocrState.status === 'recognized'" class="mdi mdi-text-recognition"></i>
<i v-else-if="file.ocrState.status === 'failed'" class="mdi mdi-alert-circle-outline"></i>
<span>{{ file.ocrState.label }}</span>
</small>
</span>
<button
type="button"
class="workbench-ai-file-card__remove"
:disabled="isAiModeInputLocked"
:aria-label="`移除附件 ${file.name}`"
@click="removeAiModeFile(file.key)"
>
<i class="mdi mdi-close"></i>
</button>
</article>
</div>
<form class="workbench-ai-composer workbench-ai-composer--inline" @submit.prevent="submitAiModePrompt">
<div class="workbench-ai-composer-field">
<div v-if="workbenchDateTagLabel" class="workbench-ai-date-chip">
<i class="mdi mdi-calendar-check-outline"></i>
<span>{{ workbenchDateTagLabel }}</span>
<button type="button" aria-label="移除日期" :disabled="isAiModeInputLocked" @click="removeWorkbenchDateTag">
<i class="mdi mdi-close"></i>
</button>
</div>
<textarea
ref="assistantInputRef"
v-model="assistantDraft"
maxlength="1000"
rows="3"
:placeholder="isAiModeInputLocked ? '费用测算中,请稍等...' : '继续和小财管家对话...'"
:disabled="isAiModeInputLocked"
@keydown.enter.exact.prevent="submitAiModePrompt"
></textarea>
</div>
<div class="workbench-ai-composer-toolbar">
<div class="workbench-ai-tool-buttons">
<div class="workbench-date-anchor workbench-ai-date-anchor">
<button
type="button"
class="workbench-ai-icon-btn"
:class="{ active: workbenchDatePickerOpen || workbenchDateTagLabel }"
title="选择日期"
aria-label="选择日期"
:aria-expanded="workbenchDatePickerOpen"
:disabled="isAiModeInputLocked"
@click.stop="toggleWorkbenchDatePicker"
>
<i class="mdi mdi-calendar-range"></i>
</button>
<div
v-if="workbenchDatePickerOpen"
class="workbench-ai-date-popover"
role="dialog"
aria-label="选择业务日期"
@click.stop
>
<div class="workbench-ai-date-tabs" role="tablist" aria-label="日期模式">
<button
type="button"
:class="{ active: workbenchDateMode === 'single' }"
:disabled="isAiModeInputLocked"
@click="setWorkbenchDateMode('single')"
>
单日
</button>
<button
type="button"
:class="{ active: workbenchDateMode === 'range' }"
:disabled="isAiModeInputLocked"
@click="setWorkbenchDateMode('range')"
>
范围
</button>
</div>
<label v-if="workbenchDateMode === 'single'" class="workbench-ai-date-field">
<span>业务日期</span>
<input
v-model="workbenchSingleDate"
type="date"
:disabled="isAiModeInputLocked"
@change="handleWorkbenchDateInputChange('single')"
/>
</label>
<div v-else class="workbench-ai-date-range">
<label class="workbench-ai-date-field">
<span>开始日期</span>
<input
v-model="workbenchRangeStartDate"
type="date"
:disabled="isAiModeInputLocked"
@change="handleWorkbenchDateInputChange('range-start')"
/>
</label>
<label class="workbench-ai-date-field">
<span>结束日期</span>
<input
v-model="workbenchRangeEndDate"
type="date"
:disabled="isAiModeInputLocked"
@change="handleWorkbenchDateInputChange('range-end')"
/>
</label>
</div>
<div class="workbench-ai-date-actions">
<button type="button" class="ghost" :disabled="isAiModeInputLocked" @click="clearWorkbenchDateSelection">清除</button>
<button
type="button"
class="primary"
:disabled="!workbenchCanApplyDateSelection || isAiModeInputLocked"
@click="applyWorkbenchDateSelection"
>
完成
</button>
</div>
</div>
</div>
<button
type="button"
class="workbench-ai-icon-btn"
title="上传附件"
aria-label="上传附件"
:disabled="isAiModeInputLocked"
@click="triggerAiModeFileUpload"
>
<i class="mdi mdi-paperclip"></i>
</button>
<button
type="button"
class="workbench-ai-icon-btn"
title="语音输入"
aria-label="语音输入"
:disabled="isAiModeInputLocked"
@click="handleVoiceInput"
>
<i class="mdi mdi-microphone-outline"></i>
</button>
</div>
<div class="workbench-ai-composer-right">
<div class="workbench-ai-model-selector" :title="modelSelectorTitle">
<span>{{ displayModelName }}</span>
<i class="mdi mdi-chevron-down"></i>
</div>
<button
type="submit"
class="workbench-ai-send-btn"
:disabled="!canSubmitAiModePrompt || sending || isAiModeInputLocked"
aria-label="发送给小财管家"
>
<i class="mdi mdi-arrow-up"></i>
</button>
</div>
</div>
</form>
<WorkbenchAiFileStrip inline :runtime="workbenchAiRuntime" />
<WorkbenchAiComposer
inline
:runtime="workbenchAiRuntime"
placeholder="继续和小财管家对话..."
/>
<p class="workbench-ai-disclaimer">小财管家可能会出错,重要费用事项请核对单据、制度与审批结果。</p>
</div>

View File

@@ -1,7 +1,10 @@
<template src="./PersonalWorkbenchAiMode.template.html"></template>
<script setup>
import { proxyRefs } from 'vue'
import orbIcon from '../../assets/workbench-ai-mode-orb-icon.gif'
import WorkbenchAiComposer from './workbench-ai/WorkbenchAiComposer.vue'
import WorkbenchAiFileStrip from './workbench-ai/WorkbenchAiFileStrip.vue'
import { usePersonalWorkbenchAiMode } from '../../composables/workbenchAiMode/usePersonalWorkbenchAiMode.js'
const props = defineProps({
@@ -9,9 +12,12 @@ const props = defineProps({
})
const emit = defineEmits(['conversation-change', 'conversation-history-change', 'open-document', 'request-updated'])
const aiModeRuntime = usePersonalWorkbenchAiMode(props, emit)
const workbenchAiRuntime = proxyRefs(aiModeRuntime)
const {
activeConversationTitle, aiModeActionItems, applicationPreviewEditor, applicationSubmitConfirmOpen, assistantDraft, assistantInputRef, canShowInlineSuggestedActions, canSubmitAiModePrompt, cancelDeleteConversation, cancelInlineApplicationSubmitConfirm, clearWorkbenchDateSelection, commitInlineApplicationPreviewEditor, confirmDeleteConversation, confirmInlineApplicationSubmit, conversationMessages, conversationScrollRef, conversationStarted, deleteDialogOpen, displayModelName, displayUserName, fileInputRef, handleAiAnswerMarkdownClick, handleAiModeFilesChange, handleInlineApplicationPreviewEditorKeydown, handleInlineConversationScroll, handleInlineSuggestedAction, handleVoiceInput, hasInlineAttachmentOcrDetails, hasInlineThinking, isAiModeInputLocked, isApplicationPreviewEditing, isApplicationPreviewEstimatePending, isInlineAttachmentOcrExpanded, isInlineSuggestedActionDisabled, isInlineThinkingExpanded, markInlineMessageFeedback, modelSelectorTitle, openApplicationPreviewEditor, quoteInlineMessage, regenerateLastReply, removeAiModeFile, removeWorkbenchDateTag, renderInlineConversationHtml, requestDeleteCurrentConversation, resolveApplicationPreviewEditorOptions, resolveInlineApplicationPreviewEditorControl, resolveInlineApplicationPreviewEditorDateMax, resolveInlineApplicationPreviewEditorDateMin, resolveInlineApplicationPreviewMissingFields, resolveInlineApplicationPreviewRows, resolveInlineAttachmentOcrDocuments, resolveInlineAttachmentOcrFileCount, resolveInlineThinkingEvents, runAiModeAction, scrollInlineConversationToTop, selectedFileCards, sending, setWorkbenchDateMode, submitAiModePrompt, toggleInlineAttachmentOcrDetails, toggleInlineThinking, toggleWorkbenchDatePicker, triggerAiModeFileUpload, workbenchCanApplyDateSelection, workbenchDateMode, workbenchDatePickerOpen, workbenchDateTagLabel, workbenchRangeEndDate, workbenchRangeStartDate, workbenchSingleDate, applyWorkbenchDateSelection, buildInlineApplicationPreviewFooterText, copyInlineMessage, formatMessageTime, handleWorkbenchDateInputChange
} = usePersonalWorkbenchAiMode(props, emit)
} = aiModeRuntime
</script>
<style scoped src="../../assets/styles/components/personal-workbench-ai-mode.css"></style>

View File

@@ -12,6 +12,17 @@
<span class="workbench-ai-file-card__body">
<strong :title="file.name">{{ file.name }}</strong>
<small>{{ file.typeLabel }}</small>
<small
v-if="file.ocrState?.label"
class="workbench-ai-file-card__ocr"
:class="`is-${file.ocrState.status || 'idle'}`"
:title="file.ocrState.title || file.ocrState.label"
>
<i v-if="file.ocrState.status === 'recognizing'" class="mdi mdi-loading"></i>
<i v-else-if="file.ocrState.status === 'recognized'" class="mdi mdi-text-recognition"></i>
<i v-else-if="file.ocrState.status === 'failed'" class="mdi mdi-alert-circle-outline"></i>
<span>{{ file.ocrState.label }}</span>
</small>
</span>
<button
type="button"

View File

@@ -43,7 +43,23 @@
:class="{ primary: action.primary }"
@click="handleQuickAction(action.event)"
>
<i :class="action.icon" aria-hidden="true"></i>
<span class="ai-quick-icon" aria-hidden="true">
<svg
class="ai-sidebar-tabler-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
v-for="(path, pathIndex) in action.iconPaths"
:key="`${action.event}-${pathIndex}`"
:d="path"
/>
</svg>
</span>
<span>{{ action.label }}</span>
</button>
</template>
@@ -60,10 +76,26 @@
class="ai-nav-btn"
:class="{ active: activeView === item.id }"
:aria-current="activeView === item.id ? 'page' : undefined"
@mouseenter="emit('prefetch-view', item.id)"
@focus="emit('prefetch-view', item.id)"
@click="emit('navigate', item.id)"
>
<span class="ai-nav-icon" aria-hidden="true">
<i :class="item.aiIcon"></i>
<svg
class="ai-sidebar-tabler-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
v-for="(path, pathIndex) in item.aiIconPaths"
:key="`${item.id}-${pathIndex}`"
:d="path"
/>
</svg>
</span>
<span class="ai-nav-copy">
<strong>{{ item.displayLabel }}</strong>
@@ -155,7 +187,7 @@ const props = defineProps({
conversationHistory: { type: Array, default: () => [] }
})
const emit = defineEmits(['navigate', 'new-chat', 'open-recent', 'rename-conversation', 'logout'])
const emit = defineEmits(['navigate', 'new-chat', 'open-recent', 'rename-conversation', 'logout', 'prefetch-view'])
const conversationSearchOpen = ref(false)
const conversationSearchQuery = ref('')
const conversationSearchInputRef = ref(null)
@@ -164,16 +196,78 @@ const editingConversationTitle = ref('')
const editingTitleInputRef = ref(null)
let recentClickTimer = null
const tablerIconPaths = {
plus: [
'M12 5l0 14',
'M5 12l14 0'
],
search: [
'M3 10a7 7 0 1 0 14 0a7 7 0 1 0 -14 0',
'M21 21l-6 -6'
],
fileText: [
'M14 3v4a1 1 0 0 0 1 1h4',
'M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2',
'M9 9l1 0',
'M9 13l6 0',
'M9 17l6 0'
],
folder: [
'M5 4h4l3 3h7a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2'
],
book2: [
'M19 4v16h-12a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h12',
'M19 16h-12a2 2 0 0 0 -2 2',
'M9 8h6'
],
chartLine: [
'M4 19l16 0',
'M4 15l4 -6l4 2l4 -5l4 4'
],
chartDonut: [
'M10 3.2a9 9 0 1 0 10.8 10.8a1 1 0 0 0 -1 -1h-6.8a2 2 0 0 1 -2 -2v-6.8a1 1 0 0 0 -1 -1',
'M15 3.5a9 9 0 0 1 5.5 5.5h-4.5a1 1 0 0 1 -1 -1v-4.5'
],
shieldCheck: [
'M11.46 20.846a12 12 0 0 1 -7.46 -10.846v-4l8 -3l8 3v4c0 1.122 -.154 2.203 -.441 3.226',
'M15 19l2 2l4 -4'
],
robot: [
'M7 7h10a2 2 0 0 1 2 2v7a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-7a2 2 0 0 1 2 -2',
'M9 11l.01 0',
'M15 11l.01 0',
'M9 15h6',
'M12 7v-4'
],
users: [
'M9 7m-4 0a4 4 0 1 0 8 0a4 4 0 1 0 -8 0',
'M3 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2',
'M16 3.13a4 4 0 0 1 0 7.75',
'M21 21v-2a4 4 0 0 0 -3 -3.85'
],
sliders: [
'M4 6h16',
'M4 12h10',
'M4 18h14',
'M8 6v.01',
'M14 12v.01',
'M18 18v.01'
],
circle: [
'M12 12m-8 0a8 8 0 1 0 16 0a8 8 0 1 0 -16 0'
]
}
const quickActions = [
{
label: '新建对话',
icon: 'mdi mdi-plus',
iconPaths: tablerIconPaths.plus,
event: 'new-chat',
primary: true
},
{
label: '查询对话',
icon: 'mdi mdi-magnify',
iconPaths: tablerIconPaths.search,
event: 'search'
}
]
@@ -181,15 +275,15 @@ const quickActions = [
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' }
overview: { label: '分析看板', iconPaths: tablerIconPaths.chartLine },
documents: { label: '单据中心', iconPaths: tablerIconPaths.fileText },
receiptFolder: { label: '票据夹', iconPaths: tablerIconPaths.folder },
budget: { label: '预算管理', iconPaths: tablerIconPaths.chartDonut },
policies: { label: '财务政策', iconPaths: tablerIconPaths.book2 },
audit: { label: '规则管理', iconPaths: tablerIconPaths.shieldCheck },
digitalEmployees: { label: '数字员工', iconPaths: tablerIconPaths.robot },
employees: { label: '员工管理', iconPaths: tablerIconPaths.users },
settings: { label: '系统设置', iconPaths: tablerIconPaths.sliders }
}
const aiBusinessViewIds = computed(() => new Set(resolveAiSidebarBusinessViewIds(props.currentUser)))
@@ -200,7 +294,7 @@ const businessNavItems = computed(() =>
.map((item) => ({
...item,
displayLabel: sidebarMeta[item.id]?.label ?? item.label,
aiIcon: sidebarMeta[item.id]?.icon ?? 'mdi mdi-circle-outline'
aiIconPaths: sidebarMeta[item.id]?.iconPaths ?? tablerIconPaths.circle
}))
)

View File

@@ -47,6 +47,8 @@
class="nav-btn"
:class="{ active: activeView === item.id }"
type="button"
@mouseenter="emit('prefetch-view', item.id)"
@focus="emit('prefetch-view', item.id)"
@click="emit('navigate', item.id)"
>
<span class="nav-icon" v-html="item.icon"></span>
@@ -100,7 +102,7 @@ const props = defineProps({
}
})
const emit = defineEmits(['navigate', 'openChat', 'logout', 'toggle-collapse'])
const emit = defineEmits(['navigate', 'openChat', 'logout', 'toggle-collapse', 'prefetch-view'])
const sidebarMeta = {
overview: { label: '分析看板' },

View File

@@ -153,10 +153,10 @@
<button
class="notification-clear-btn"
type="button"
:disabled="notificationItems.length === 0"
@click="clearAllNotifications"
:disabled="notificationBulkActionDisabled"
@click="handleNotificationBulkAction"
>
清空通知
{{ notificationBulkActionLabel }}
</button>
<button
class="notification-close-btn"
@@ -201,24 +201,16 @@
:class="{ unread: item.unread }"
@click="openNotification(item)"
>
<span class="notification-type-icon" :class="item.tone">
<i :class="resolveNotificationIcon(item)"></i>
<span class="notification-avatar" :class="item.tone" aria-hidden="true">
<span class="notification-avatar-label">{{ item.avatarLabel }}</span>
<span v-if="item.badge" class="notification-avatar-badge">{{ item.badge }}</span>
</span>
<span class="notification-row-main">
<span class="notification-row-head">
<span class="notification-title-line">
<strong class="notification-row-title">{{ item.title }}</strong>
<b v-if="item.badge">{{ item.badge }}</b>
</span>
<span class="notification-row-action" aria-hidden="true">
<i class="mdi mdi-chevron-right"></i>
</span>
</span>
<small class="notification-context">{{ item.description }}</small>
<span class="notification-row-foot">
<span class="notification-category-pill">{{ item.category || '系统通知' }}</span>
<span class="notification-row-content">
<span class="notification-row-top">
<strong class="notification-row-title">{{ item.title }}</strong>
<time class="notification-time">{{ item.timeLabel || item.time }}</time>
</span>
<small class="notification-preview">{{ item.description }}</small>
</span>
</button>
</div>
@@ -512,7 +504,8 @@ const {
isNotificationHidden,
isNotificationRead,
loadNotificationStates,
markNotificationStateRead
markNotificationStateRead,
markNotificationStatesRead
} = useTopBarNotificationStates()
const notificationTab = ref('unread')
@@ -565,6 +558,36 @@ function resolveDocumentNotificationDescription(row) {
].filter(Boolean).join(' · ') || '单据中心有新的单据状态'
}
function resolveNotificationAvatarLabel(item) {
const raw = String(
item?.avatarLabel
|| item?.initiatorName
|| item?.applicantName
|| item?.employeeName
|| item?.category
|| item?.title
|| '通'
).trim()
if (!raw) {
return '通'
}
return raw.replace(/\s+/g, '').slice(0, 1).toUpperCase()
}
function resolveDocumentNotificationAvatarLabel(row) {
return resolveNotificationAvatarLabel({
avatarLabel:
row?.initiatorName
|| row?.applicantName
|| row?.employeeName
|| row?.sourceLabel
|| row?.documentTypeLabel
|| row?.title
})
}
function resolveWorkbenchNotificationId(item, index) {
return normalizeNotificationId(`workbench:${item?.id || [item?.title, item?.description, item?.time, index].join('|')}`)
}
@@ -580,15 +603,15 @@ const documentNotificationItems = computed(() =>
return {
id,
kind: 'document',
title: `${row.documentTypeLabel || '单据'} ${row.documentNo || row.claimId || '待生成'}`,
description: resolveDocumentNotificationDescription(row),
time: row.updatedAt || row.createdAt,
timeLabel: formatNotificationTimeLabel(row.updatedAt || row.createdAt),
category: row.sourceLabel || '单据中心',
tone: resolveDocumentNotificationTone({ ...row, isUnread: unread }),
unread,
icon: row.source === 'approval' ? 'mdi mdi-clipboard-text-clock-outline' : 'mdi mdi-file-document-outline',
kind: 'document',
title: `${row.documentTypeLabel || '单据'} ${row.documentNo || row.claimId || '待生成'}`,
description: resolveDocumentNotificationDescription(row),
avatarLabel: resolveDocumentNotificationAvatarLabel(row),
time: row.updatedAt || row.createdAt,
timeLabel: formatNotificationTimeLabel(row.updatedAt || row.createdAt),
category: row.sourceLabel || '单据中心',
tone: resolveDocumentNotificationTone({ ...row, isUnread: unread }),
unread,
badge: unread ? '新' : '',
target: {
type: 'document',
@@ -615,10 +638,10 @@ const workbenchNotificationItems = computed(() => (
id,
kind: 'workbench',
category: item.category || '个人工作台',
avatarLabel: resolveNotificationAvatarLabel(item),
time: notificationTime,
timeLabel: formatNotificationTimeLabel(item.time || item.updatedAt || item.due),
unread: Boolean(item.unread) && !readNotificationIds.value.has(id),
icon: item.icon || resolveNotificationIcon(item)
unread: Boolean(item.unread) && !readNotificationIds.value.has(id)
}
}).filter(Boolean)
: []
@@ -631,6 +654,14 @@ const readNotifications = computed(() => notificationItems.value.filter((item) =
const activeNotifications = computed(() => (
notificationTab.value === 'unread' ? unreadNotifications.value : readNotifications.value
))
const notificationBulkActionLabel = computed(() => (
notificationTab.value === 'unread' ? '全部已读' : '删除已读'
))
const notificationBulkActionDisabled = computed(() => (
notificationTab.value === 'unread'
? unreadNotifications.value.length === 0
: readNotifications.value.length === 0
))
const topbarNotificationCount = computed(() => {
const count = unreadNotifications.value.length
return count > 0 ? Math.min(count, 99) : 0
@@ -659,26 +690,6 @@ function scheduleDocumentInboxInitialRefresh() {
}, props.activeView === 'workbench' ? 1200 : 6000)
}
function resolveNotificationIcon(item) {
if (item?.icon) {
return item.icon
}
if (item?.tone === 'danger') {
return 'mdi mdi-alert-circle-outline'
}
if (item?.tone === 'warning') {
return 'mdi mdi-alert-outline'
}
if (item?.tone === 'success') {
return 'mdi mdi-check-circle-outline'
}
return 'mdi mdi-bell-outline'
}
function markNotificationRead(item) {
if (!item?.id || !item.unread) {
return
@@ -691,8 +702,8 @@ function markNotificationRead(item) {
void markNotificationStateRead(item)
}
function clearAllNotifications() {
const currentItems = notificationItems.value
function markUnreadNotificationsRead() {
const currentItems = unreadNotifications.value
if (!currentItems.length) {
return
}
@@ -705,8 +716,29 @@ function clearAllNotifications() {
markDocumentInboxRowsRead(documentRows)
}
void markNotificationStatesRead(currentItems)
}
function deleteReadNotifications() {
const currentItems = readNotifications.value
if (!currentItems.length) {
return
}
void hideNotificationStates(currentItems)
notificationTab.value = 'unread'
}
function handleNotificationBulkAction() {
if (notificationBulkActionDisabled.value) {
return
}
if (notificationTab.value === 'unread') {
markUnreadNotificationsRead()
return
}
deleteReadNotifications()
}
function openNotification(item) {

View File

@@ -0,0 +1,74 @@
<template>
<Teleport to="body">
<div class="app-modal-loading-backdrop" role="status" aria-live="polite">
<section class="app-modal-loading-panel" aria-label="正在打开智能工作台">
<span class="app-modal-loading-spinner" aria-hidden="true"></span>
<div>
<strong>正在打开智能工作台</strong>
<p>基础页面已就绪正在载入助手模块</p>
</div>
</section>
</div>
</Teleport>
</template>
<style scoped>
.app-modal-loading-backdrop {
position: fixed;
inset: 0;
z-index: 3000;
display: grid;
place-items: center;
padding: 24px;
background: rgba(15, 23, 42, 0.36);
backdrop-filter: blur(6px);
}
.app-modal-loading-panel {
display: flex;
align-items: center;
gap: 14px;
width: min(420px, 100%);
padding: 20px 22px;
border: 1px solid rgba(255, 255, 255, 0.68);
border-radius: 8px;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.18);
color: #111827;
}
.app-modal-loading-panel strong {
display: block;
font-size: 15px;
line-height: 1.35;
}
.app-modal-loading-panel p {
margin: 4px 0 0;
color: #64748b;
font-size: 13px;
line-height: 1.45;
}
.app-modal-loading-spinner {
width: 24px;
height: 24px;
flex: 0 0 auto;
border: 2px solid rgba(59, 130, 246, 0.18);
border-top-color: #2563eb;
border-radius: 999px;
animation: app-modal-loading-spin 0.8s linear infinite;
}
@keyframes app-modal-loading-spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.app-modal-loading-spinner {
animation: none;
}
}
</style>

View File

@@ -0,0 +1,104 @@
<template>
<section class="app-view-loading-state" aria-live="polite">
<div class="app-view-loading-copy">
<span class="app-view-loading-spinner" aria-hidden="true"></span>
<div>
<strong>正在加载页面内容</strong>
<p>页面框架已就绪正在载入当前模块的数据和控件</p>
</div>
</div>
<div class="app-view-loading-skeleton" aria-hidden="true">
<span></span>
<span></span>
<span></span>
</div>
</section>
</template>
<style scoped>
.app-view-loading-state {
display: grid;
gap: 16px;
width: min(760px, 100%);
margin: 24px auto;
padding: 24px;
border: 1px solid rgba(148, 163, 184, 0.26);
border-radius: 8px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 14px 36px rgba(15, 23, 42, 0.06);
}
.app-view-loading-copy {
display: flex;
align-items: center;
gap: 12px;
color: #1f2937;
}
.app-view-loading-copy strong {
display: block;
font-size: 15px;
line-height: 1.35;
}
.app-view-loading-copy p {
margin: 4px 0 0;
color: #64748b;
font-size: 13px;
line-height: 1.45;
}
.app-view-loading-spinner {
width: 22px;
height: 22px;
flex: 0 0 auto;
border: 2px solid rgba(59, 130, 246, 0.18);
border-top-color: #2563eb;
border-radius: 999px;
animation: app-view-loading-spin 0.8s linear infinite;
}
.app-view-loading-skeleton {
display: grid;
gap: 10px;
}
.app-view-loading-skeleton span {
display: block;
height: 12px;
border-radius: 999px;
background: linear-gradient(90deg, #e5e7eb 0%, #f8fafc 48%, #e5e7eb 100%);
background-size: 200% 100%;
animation: app-view-loading-shimmer 1.4s ease-in-out infinite;
}
.app-view-loading-skeleton span:nth-child(2) {
width: 86%;
}
.app-view-loading-skeleton span:nth-child(3) {
width: 68%;
}
@keyframes app-view-loading-spin {
to {
transform: rotate(360deg);
}
}
@keyframes app-view-loading-shimmer {
0% {
background-position: 100% 0;
}
100% {
background-position: -100% 0;
}
}
@media (prefers-reduced-motion: reduce) {
.app-view-loading-spinner,
.app-view-loading-skeleton span {
animation: none;
}
}
</style>