refactor: consolidate finance workflow modules
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}))
|
||||
)
|
||||
|
||||
|
||||
@@ -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: '分析看板' },
|
||||
|
||||
@@ -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) {
|
||||
|
||||
74
web/src/components/shared/AppModalLoadingState.vue
Normal file
74
web/src/components/shared/AppModalLoadingState.vue
Normal 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>
|
||||
104
web/src/components/shared/AppViewLoadingState.vue
Normal file
104
web/src/components/shared/AppViewLoadingState.vue
Normal 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>
|
||||
Reference in New Issue
Block a user