feat: 风险可见性控制与差旅详情页交互优化

- 新增风险可见性工具函数与风险日趋势图表组件
- 优化差旅请求详情页费用模型与视图交互
- 完善顶部导航栏样式与应用壳路由逻辑
- 补充风险可见性、风险看板与差旅详情测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-03 22:15:45 +08:00
parent 75d5c178e1
commit 87da5df91b
17 changed files with 809 additions and 168 deletions

View File

@@ -32,6 +32,12 @@ const totals = computed(() => props.rows.map((item) => Number(item.total || 0)))
const highValues = computed(() => props.rows.map((item) => Number(item.highOrAbove || 0)))
const maxValue = computed(() => Math.max(...totals.value, ...highValues.value, 1))
const axisMax = computed(() => Math.max(5, Math.ceil(maxValue.value * 1.2)))
const barWidth = computed(() => {
if (labels.value.length >= 12) return 9
if (labels.value.length >= 8) return 11
return 14
})
const bottomGridSize = computed(() => (labels.value.some((label) => String(label).includes('~')) ? 38 : 28))
const ariaLabel = computed(() =>
props.rows.map((item) => (
@@ -47,7 +53,7 @@ const chartOptions = computed(() => ({
grid: {
top: 34,
right: 16,
bottom: 24,
bottom: bottomGridSize.value,
left: 28,
containLabel: true
},
@@ -86,7 +92,10 @@ const chartOptions = computed(() => ({
axisLabel: {
color: '#64748b',
fontSize: 11,
fontWeight: 700
fontWeight: 700,
interval: 0,
lineHeight: 14,
formatter: (value) => String(value || '').replace('~', '\n~')
}
},
yAxis: {
@@ -106,7 +115,8 @@ const chartOptions = computed(() => ({
name: '风险观察',
type: 'bar',
data: totals.value,
barWidth: 14,
barWidth: barWidth.value,
barMaxWidth: 14,
itemStyle: {
color: themeColors.value.chartPrimary,
borderRadius: [4, 4, 0, 0]

View File

@@ -123,14 +123,14 @@
<template v-else-if="isWorkbench">
<div class="topbar-toolset" aria-label="工作台快捷工具">
<div class="notification-wrap" :class="{ 'is-open': notificationOpen }">
<div ref="notificationWrapRef" class="notification-wrap" :class="{ 'is-open': notificationOpen }">
<button
class="topbar-icon-btn notification-btn"
type="button"
aria-label="通知"
:aria-expanded="notificationOpen"
aria-haspopup="dialog"
@click="notificationOpen = !notificationOpen"
@click="toggleNotification"
>
<i class="mdi mdi-bell-outline"></i>
<span v-if="topbarNotificationCount" class="notification-badge">{{ topbarNotificationCount }}</span>
@@ -161,7 +161,7 @@
class="notification-close-btn"
type="button"
aria-label="关闭通知"
@click="notificationOpen = false"
@click="closeNotification"
>
<i class="mdi mdi-close"></i>
</button>
@@ -228,9 +228,55 @@
</Transition>
</div>
<button class="topbar-icon-btn" type="button" aria-label="帮助">
<i class="mdi mdi-help-circle-outline"></i>
</button>
<div ref="helpWrapRef" class="help-wrap" :class="{ 'is-open': helpOpen }">
<button
class="topbar-icon-btn help-btn"
type="button"
aria-label="帮助"
:aria-expanded="helpOpen"
aria-haspopup="dialog"
@click="toggleHelp"
>
<i class="mdi mdi-help-circle-outline"></i>
</button>
<Transition name="help-panel">
<div v-if="helpOpen" class="help-popover" role="dialog" aria-label="产品信息">
<header class="help-head">
<span class="help-head-icon" aria-hidden="true">
<i class="mdi mdi-information-outline"></i>
</span>
<span class="help-head-copy">
<strong>{{ topbarHelpDemo.productName }}</strong>
<small>产品版本与版权信息</small>
</span>
<button
class="help-close-btn"
type="button"
aria-label="关闭帮助"
@click="closeHelp"
>
<i class="mdi mdi-close"></i>
</button>
</header>
<dl class="help-meta">
<div class="help-meta-row">
<dt>版本号</dt>
<dd>{{ topbarHelpDemo.version }}</dd>
</div>
<div class="help-meta-row">
<dt>更新时间</dt>
<dd>{{ topbarHelpDemo.updatedAt }}</dd>
</div>
<div class="help-meta-row help-meta-row--block">
<dt>Copyright</dt>
<dd>{{ topbarHelpDemo.copyright }}</dd>
</div>
</dl>
</div>
</Transition>
</div>
<button class="company-switcher" type="button" aria-label="切换公司">
<span>{{ displayCompanyName }}</span>
@@ -315,8 +361,10 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useDocumentCenterInbox } from '../../composables/useDocumentCenterInbox.js'
import { useTopBarNotificationStates } from '../../composables/useTopBarNotificationStates.js'
import { useDocumentCenterInbox } from '../../composables/useDocumentCenterInbox.js'
import { useTopBarNotificationStates } from '../../composables/useTopBarNotificationStates.js'
import { useTopBarWorkbenchPopovers } from '../../composables/useTopBarWorkbenchPopovers.js'
import { createCurrentYearDateRange, formatDateValue } from '../../utils/dateRangeDefaults.js'
import EnterpriseSelect from '../shared/EnterpriseSelect.vue'
const props = defineProps({
@@ -367,7 +415,7 @@ const props = defineProps({
},
customRange: {
type: Object,
default: () => ({ start: '2024-07-06', end: '2024-07-12' })
default: createCurrentYearDateRange
},
overviewDashboard: {
type: String,
@@ -396,12 +444,12 @@ 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 eyebrowLabel = computed(() => (
String(props.currentView?.eyebrow || '').trim()
|| (isChat.value ? 'Smart Finance Q&A' : 'Smart Expense Operations')
))
const displayCompanyName = computed(() => String(props.companyName || '远光软件股份有限公司').trim() || '远光软件股份有限公司')
const MAX_NOTIFICATION_ITEMS = 30
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 MAX_NOTIFICATION_ITEMS = 30
const {
markDocumentInboxRowRead,
markDocumentInboxRowsRead,
@@ -409,22 +457,32 @@ const {
refreshDocumentInbox,
startDocumentInboxPolling,
stopDocumentInboxPolling
} = useDocumentCenterInbox()
let documentInboxInitialRefreshTimer = null
const notificationOpen = ref(false)
const {
readNotificationIds,
hideNotificationStates,
isNotificationHidden,
isNotificationRead,
loadNotificationStates,
markNotificationStateRead
} = useTopBarNotificationStates()
const notificationTab = ref('unread')
function normalizeNotificationId(value) {
return String(value || '').trim()
}
} = useDocumentCenterInbox()
let documentInboxInitialRefreshTimer = null
const {
notificationOpen,
helpOpen,
notificationWrapRef,
helpWrapRef,
topbarHelpDemo,
toggleNotification,
toggleHelp,
closeNotification,
closeHelp
} = useTopBarWorkbenchPopovers()
const {
readNotificationIds,
hideNotificationStates,
isNotificationHidden,
isNotificationRead,
loadNotificationStates,
markNotificationStateRead
} = useTopBarNotificationStates()
const notificationTab = ref('unread')
function normalizeNotificationId(value) {
return String(value || '').trim()
}
function formatNotificationTime(value) {
const date = new Date(value)
@@ -459,28 +517,28 @@ function resolveWorkbenchNotificationId(item, index) {
return normalizeNotificationId(`workbench:${item?.id || [item?.title, item?.description, item?.time, index].join('|')}`)
}
const documentNotificationItems = computed(() =>
documentInboxNotificationRows.value
.map((row) => {
const id = normalizeNotificationId(`document:${row.documentKey || row.claimId || row.documentNo}`)
if (!id || isNotificationHidden(id)) {
return null
}
const unread = Boolean(row.isUnread) && !isNotificationRead(id)
return {
id,
const documentNotificationItems = computed(() =>
documentInboxNotificationRows.value
.map((row) => {
const id = normalizeNotificationId(`document:${row.documentKey || row.claimId || row.documentNo}`)
if (!id || isNotificationHidden(id)) {
return null
}
const unread = Boolean(row.isUnread) && !isNotificationRead(id)
return {
id,
kind: 'document',
title: `${row.documentTypeLabel || '单据'} ${row.documentNo || row.claimId || '待生成'}`,
description: resolveDocumentNotificationDescription(row),
time: formatNotificationTime(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',
badge: unread ? '新' : '',
target: {
type: 'document',
time: formatNotificationTime(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',
badge: unread ? '新' : '',
target: {
type: 'document',
id: row.claimId,
claimNo: row.documentNo
},
@@ -561,17 +619,17 @@ function resolveNotificationIcon(item) {
return 'mdi mdi-bell-outline'
}
function markNotificationRead(item) {
if (!item?.id || !item.unread) {
return
}
if (item.kind === 'document' && item.documentRow) {
markDocumentInboxRowRead(item.documentRow)
}
void markNotificationStateRead(item)
}
function markNotificationRead(item) {
if (!item?.id || !item.unread) {
return
}
if (item.kind === 'document' && item.documentRow) {
markDocumentInboxRowRead(item.documentRow)
}
void markNotificationStateRead(item)
}
function clearAllNotifications() {
const currentItems = notificationItems.value
@@ -587,13 +645,13 @@ function clearAllNotifications() {
markDocumentInboxRowsRead(documentRows)
}
void hideNotificationStates(currentItems)
notificationTab.value = 'unread'
}
void hideNotificationStates(currentItems)
notificationTab.value = 'unread'
}
function openNotification(item) {
markNotificationRead(item)
notificationOpen.value = false
closeNotification()
const target = item?.target || {}
if (target.type === 'document' && (target.id || target.claimNo)) {
emit('openDocument', {
@@ -793,25 +851,29 @@ watch(
watch(
() => props.activeView,
(activeView, previousView) => {
if (activeView === 'workbench' && previousView !== 'workbench') {
clearDocumentInboxInitialRefreshTimer()
void loadNotificationStates()
void refreshDocumentInbox({ force: true })
if (activeView === 'workbench' && previousView !== 'workbench') {
clearDocumentInboxInitialRefreshTimer()
void loadNotificationStates()
void refreshDocumentInbox({ force: true })
}
if (activeView !== 'workbench') {
closeNotification()
closeHelp()
}
}
)
watch(notificationOpen, (open) => {
if (open) {
void loadNotificationStates()
}
})
onMounted(() => {
void loadNotificationStates()
scheduleDocumentInboxInitialRefresh()
startDocumentInboxPolling()
})
}
)
watch(notificationOpen, (open) => {
if (open) {
void loadNotificationStates()
}
})
onMounted(() => {
void loadNotificationStates()
scheduleDocumentInboxInitialRefresh()
startDocumentInboxPolling()
})
onBeforeUnmount(() => {
clearDocumentInboxInitialRefreshTimer()
@@ -836,16 +898,9 @@ function formatRangeLabel(start, end) {
return `${start} ~ ${end}`
}
function toDateLabel(date) {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
function buildPresetRangeLabel(label) {
const now = new Date()
const today = toDateLabel(now)
const today = formatDateValue(now)
if (label === '今日') {
return today
@@ -855,7 +910,7 @@ function buildPresetRangeLabel(label) {
const start = new Date(now)
start.setHours(0, 0, 0, 0)
start.setDate(start.getDate() - 9)
return `${toDateLabel(start)} ~ ${today}`
return `${formatDateValue(start)} ~ ${today}`
}
if (label === '本周') {
@@ -863,7 +918,7 @@ function buildPresetRangeLabel(label) {
const day = start.getDay() || 7
start.setHours(0, 0, 0, 0)
start.setDate(start.getDate() - day + 1)
return `${toDateLabel(start)} ~ ${today}`
return `${formatDateValue(start)} ~ ${today}`
}
if (label === '本月') {