feat: 风险可见性控制与差旅详情页交互优化
- 新增风险可见性工具函数与风险日趋势图表组件 - 优化差旅请求详情页费用模型与视图交互 - 完善顶部导航栏样式与应用壳路由逻辑 - 补充风险可见性、风险看板与差旅详情测试覆盖
This commit is contained in:
@@ -881,6 +881,176 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.help-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.help-wrap.is-open .help-btn {
|
||||
border: 1px solid var(--theme-primary-light-5);
|
||||
background: var(--theme-primary-light-9);
|
||||
color: var(--theme-primary-active);
|
||||
}
|
||||
|
||||
.help-btn {
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
transition:
|
||||
border-color 180ms var(--ease),
|
||||
background 180ms var(--ease),
|
||||
color 180ms var(--ease);
|
||||
}
|
||||
|
||||
.help-panel-enter-active,
|
||||
.help-panel-leave-active {
|
||||
transition:
|
||||
opacity 220ms var(--ease),
|
||||
transform 220ms var(--ease);
|
||||
}
|
||||
|
||||
.help-panel-enter-from,
|
||||
.help-panel-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.help-panel-enter-active,
|
||||
.help-panel-leave-active {
|
||||
transition-duration: 1ms;
|
||||
}
|
||||
|
||||
.help-panel-enter-from,
|
||||
.help-panel-leave-to {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.help-popover {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
z-index: 60;
|
||||
width: min(300px, calc(100vw - 32px));
|
||||
overflow: hidden;
|
||||
border: 1px solid #d7e0ea;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.help-popover::before {
|
||||
content: "";
|
||||
display: block;
|
||||
height: 3px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--theme-primary-active) 0%,
|
||||
var(--theme-primary-light-3, #7eb3d4) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.help-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid #edf2f7;
|
||||
background: #fafbfd;
|
||||
}
|
||||
|
||||
.help-head-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid var(--theme-primary-light-6);
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: var(--theme-primary-active);
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.help-head-copy {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.help-head-copy strong {
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.help-head-copy small {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.help-close-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex: 0 0 auto;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
font-size: 18px;
|
||||
transition:
|
||||
background 160ms var(--ease),
|
||||
color 160ms var(--ease);
|
||||
}
|
||||
|
||||
.help-close-btn:hover {
|
||||
background: #eef2f7;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.help-meta {
|
||||
margin: 0;
|
||||
padding: 10px 14px 12px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.help-meta-row {
|
||||
display: grid;
|
||||
grid-template-columns: 72px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.help-meta-row--block {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.help-meta-row dt {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.help-meta-row dd {
|
||||
margin: 0;
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.help-meta-row--block dd {
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.company-switcher {
|
||||
max-width: min(220px, 28vw);
|
||||
height: 38px;
|
||||
|
||||
@@ -936,25 +936,33 @@
|
||||
.detail-expense-table .col-action { width: 9%; }
|
||||
|
||||
.expense-time {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.expense-time.has-major-risk {
|
||||
padding-left: 30px;
|
||||
.expense-time-content {
|
||||
display: grid;
|
||||
grid-template-columns: 18px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.expense-time-value {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.expense-risk-indicator,
|
||||
.expense-risk-indicator-placeholder {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.expense-risk-indicator {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
top: 50%;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
transform: translateY(-50%);
|
||||
color: #dc2626;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 === '本月') {
|
||||
|
||||
@@ -2,9 +2,10 @@ import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { useNavigation, navItems } from './useNavigation.js'
|
||||
import { useRequests } from './useRequests.js'
|
||||
import { useSystemState } from './useSystemState.js'
|
||||
import { mapExpenseClaimToRequest, useRequests } from './useRequests.js'
|
||||
import { useSystemState } from './useSystemState.js'
|
||||
import { useToast } from './useToast.js'
|
||||
import { fetchExpenseClaimDetail } from '../services/reimbursements.js'
|
||||
import { fetchOntologyParse } from '../services/ontology.js'
|
||||
import { fetchLatestConversation } from '../services/orchestrator.js'
|
||||
import { clearAssistantSessionSnapshotForDraftClaim } from '../utils/assistantSessionSnapshot.js'
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
resolveWorkbenchSessionTypeFromOntology
|
||||
} from '../utils/workbenchAssistantIntent.js'
|
||||
import { buildWorkbenchSummary } from '../utils/workbenchSummary.js'
|
||||
import { createCurrentYearDateRange } from '../utils/dateRangeDefaults.js'
|
||||
|
||||
const SESSION_TYPE_EXPENSE = 'expense'
|
||||
const SMART_ENTRY_SOURCE_APPLICATION = 'application'
|
||||
@@ -62,36 +64,29 @@ export function useAppShell() {
|
||||
const { currentUser } = useSystemState()
|
||||
const { toast } = useToast()
|
||||
|
||||
const customRange = ref({ start: '2024-07-06', end: '2024-07-12' })
|
||||
const customRange = ref(createCurrentYearDateRange())
|
||||
|
||||
const selectedRequest = computed(() => {
|
||||
const requestId = String(route.params.requestId || '')
|
||||
|
||||
if (!requestId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const rawRequest = requests.value.find(
|
||||
(item) => String(item.claimId || '').trim() === requestId || String(item.id || '').trim() === requestId
|
||||
)
|
||||
const normalizedRequest = normalizeRequestForUi(rawRequest)
|
||||
if (normalizedRequest) {
|
||||
return normalizedRequest
|
||||
}
|
||||
|
||||
const snapshot = normalizeRequestForUi(selectedRequestSnapshot.value)
|
||||
if (
|
||||
snapshot
|
||||
&& (
|
||||
String(snapshot.claimId || '').trim() === requestId
|
||||
|| String(snapshot.id || '').trim() === requestId
|
||||
|| String(snapshot.documentNo || '').trim() === requestId
|
||||
)
|
||||
) {
|
||||
return snapshot
|
||||
}
|
||||
|
||||
return null
|
||||
const selectedRequest = computed(() => {
|
||||
const requestId = String(route.params.requestId || '')
|
||||
|
||||
if (!requestId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const snapshot = normalizeRequestForUi(selectedRequestSnapshot.value)
|
||||
if (isSameRequestIdentity(snapshot, requestId)) {
|
||||
return snapshot
|
||||
}
|
||||
|
||||
const rawRequest = requests.value.find(
|
||||
(item) => String(item.claimId || '').trim() === requestId || String(item.id || '').trim() === requestId
|
||||
)
|
||||
const normalizedRequest = normalizeRequestForUi(rawRequest)
|
||||
if (normalizedRequest) {
|
||||
return normalizedRequest
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
const detailMode = computed(() => route.name === 'app-document-detail')
|
||||
@@ -110,9 +105,81 @@ export function useAppShell() {
|
||||
return reloadRequests()
|
||||
}
|
||||
|
||||
function isSameRequestIdentity(request, requestId) {
|
||||
const normalizedId = String(requestId || '').trim()
|
||||
if (!request || !normalizedId) {
|
||||
return false
|
||||
}
|
||||
return [
|
||||
request.claimId,
|
||||
request.id,
|
||||
request.claimNo,
|
||||
request.documentNo
|
||||
].some((value) => String(value || '').trim() === normalizedId)
|
||||
}
|
||||
|
||||
function resolveRequestDetailLookupId(requestOrId = selectedRequestSnapshot.value) {
|
||||
if (typeof requestOrId === 'string') {
|
||||
return requestOrId.trim()
|
||||
}
|
||||
return String(
|
||||
requestOrId?.claimId
|
||||
|| requestOrId?.claim_id
|
||||
|| requestOrId?.id
|
||||
|| requestOrId?.claimNo
|
||||
|| requestOrId?.claim_no
|
||||
|| ''
|
||||
).trim()
|
||||
}
|
||||
|
||||
function upsertRequestSnapshot(nextRequest) {
|
||||
if (!nextRequest) {
|
||||
return
|
||||
}
|
||||
selectedRequestSnapshot.value = nextRequest
|
||||
const nextIdValues = [
|
||||
nextRequest.claimId,
|
||||
nextRequest.id,
|
||||
nextRequest.claimNo,
|
||||
nextRequest.documentNo
|
||||
].map((item) => String(item || '').trim()).filter(Boolean)
|
||||
const nextIdSet = new Set(nextIdValues)
|
||||
const index = requests.value.findIndex((item) => [
|
||||
item.claimId,
|
||||
item.id,
|
||||
item.claimNo,
|
||||
item.documentNo
|
||||
].some((value) => nextIdSet.has(String(value || '').trim())))
|
||||
if (index >= 0) {
|
||||
requests.value.splice(index, 1, nextRequest)
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshSelectedRequestDetail(requestOrId = selectedRequestSnapshot.value) {
|
||||
const lookupId = resolveRequestDetailLookupId(requestOrId)
|
||||
if (!lookupId) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const payload = await fetchExpenseClaimDetail(lookupId)
|
||||
const mappedRequest = mapExpenseClaimToRequest(payload)
|
||||
const routeRequestId = String(route.params.requestId || '').trim()
|
||||
if (!routeRequestId || isSameRequestIdentity(mappedRequest, routeRequestId) || routeRequestId === lookupId) {
|
||||
upsertRequestSnapshot(mappedRequest)
|
||||
}
|
||||
} catch {
|
||||
// 保留当前快照,避免详情刷新失败时把页面置空。
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [activeView.value, route.name],
|
||||
([view]) => {
|
||||
if (route.name === 'app-document-detail') {
|
||||
void ensureRequestsLoaded()
|
||||
void refreshSelectedRequestDetail(String(route.params.requestId || ''))
|
||||
return
|
||||
}
|
||||
if (view === 'documents') {
|
||||
void reloadDocumentCenterRequests()
|
||||
return
|
||||
@@ -425,6 +492,7 @@ export function useAppShell() {
|
||||
params: { requestId: request.claimId || request.id },
|
||||
query: buildDocumentDetailQuery(options)
|
||||
})
|
||||
void refreshSelectedRequestDetail(request)
|
||||
}
|
||||
|
||||
function closeRequestDetail() {
|
||||
@@ -438,6 +506,7 @@ export function useAppShell() {
|
||||
|
||||
async function handleRequestUpdated() {
|
||||
await reloadRequests()
|
||||
await refreshSelectedRequestDetail(String(route.params.requestId || ''))
|
||||
}
|
||||
|
||||
async function handleRequestDeleted(payload = {}) {
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
|
||||
const DEFAULT_OVERVIEW_RANGE = '近10日'
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
const RISK_DAILY_TREND_MAX_BUCKETS = 14
|
||||
|
||||
const emptyFinanceTotals = {
|
||||
reimbursementAmount: 0,
|
||||
@@ -137,6 +138,64 @@ function resolveTopRangeKey(range, customRange = {}) {
|
||||
return key || DEFAULT_OVERVIEW_RANGE
|
||||
}
|
||||
|
||||
function formatRiskTrendDateLabel(value) {
|
||||
const date = parseLocalDate(value)
|
||||
if (!date) {
|
||||
return String(value || '-').trim() || '-'
|
||||
}
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${month}-${day}`
|
||||
}
|
||||
|
||||
function buildRiskTrendBucketLabel(first, last) {
|
||||
const start = String(first?.date || '').trim()
|
||||
const end = String(last?.date || '').trim()
|
||||
if (!start || start === end) {
|
||||
return formatRiskTrendDateLabel(start)
|
||||
}
|
||||
return `${formatRiskTrendDateLabel(start)}~${formatRiskTrendDateLabel(end)}`
|
||||
}
|
||||
|
||||
function normalizeRiskTrendRow(item) {
|
||||
return {
|
||||
date: String(item.date || '').trim() || '-',
|
||||
total: Number(item.total || 0),
|
||||
highOrAbove: Number(item.high_or_above ?? item.highOrAbove ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
function aggregateRiskDailyTrendRows(rows, maxBuckets = RISK_DAILY_TREND_MAX_BUCKETS) {
|
||||
const normalizedRows = rows
|
||||
.map(normalizeRiskTrendRow)
|
||||
.filter((item) => item.date !== '-' || item.total > 0 || item.highOrAbove > 0)
|
||||
|
||||
if (normalizedRows.length <= maxBuckets) {
|
||||
return normalizedRows.map((item) => ({
|
||||
...item,
|
||||
date: formatRiskTrendDateLabel(item.date),
|
||||
sourceStartDate: item.date,
|
||||
sourceEndDate: item.date
|
||||
}))
|
||||
}
|
||||
|
||||
const bucketSize = Math.ceil(normalizedRows.length / maxBuckets)
|
||||
const buckets = []
|
||||
for (let index = 0; index < normalizedRows.length; index += bucketSize) {
|
||||
const bucketRows = normalizedRows.slice(index, index + bucketSize)
|
||||
const first = bucketRows[0]
|
||||
const last = bucketRows[bucketRows.length - 1]
|
||||
buckets.push({
|
||||
date: buildRiskTrendBucketLabel(first, last),
|
||||
sourceStartDate: first?.date || '',
|
||||
sourceEndDate: last?.date || '',
|
||||
total: bucketRows.reduce((sum, item) => sum + item.total, 0),
|
||||
highOrAbove: bucketRows.reduce((sum, item) => sum + item.highOrAbove, 0)
|
||||
})
|
||||
}
|
||||
return buckets
|
||||
}
|
||||
|
||||
export function useOverviewView(options = {}) {
|
||||
const activeDashboardKey = computed(() => {
|
||||
const dashboard = String(options.dashboard || '').trim()
|
||||
@@ -830,11 +889,7 @@ export function useOverviewView(options = {}) {
|
||||
})
|
||||
const riskDailyTrendRows = computed(() => {
|
||||
const rows = Array.isArray(riskDashboard.value.dailyTrend) ? riskDashboard.value.dailyTrend : []
|
||||
const normalizedRows = rows.slice(-7).map((item) => ({
|
||||
date: String(item.date || '').trim() || '-',
|
||||
total: Number(item.total || 0),
|
||||
highOrAbove: Number(item.high_or_above ?? item.highOrAbove ?? 0)
|
||||
}))
|
||||
const normalizedRows = aggregateRiskDailyTrendRows(rows)
|
||||
const maxValue = Math.max(...normalizedRows.map((item) => item.total), 1)
|
||||
|
||||
return normalizedRows.map((item) => ({
|
||||
|
||||
90
web/src/composables/useTopBarWorkbenchPopovers.js
Normal file
90
web/src/composables/useTopBarWorkbenchPopovers.js
Normal file
@@ -0,0 +1,90 @@
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
/** 工作台 TopBar 帮助气泡静态演示数据 */
|
||||
export const TOPBAR_HELP_DEMO = {
|
||||
productName: 'Smart Expense Operations',
|
||||
version: 'v0.1.0',
|
||||
copyright: 'Copyright © 2024-2026 X-Financial. All Rights Reserved.',
|
||||
updatedAt: '2026-06-03'
|
||||
}
|
||||
|
||||
export function useTopBarWorkbenchPopovers() {
|
||||
const notificationOpen = ref(false)
|
||||
const helpOpen = ref(false)
|
||||
const notificationWrapRef = ref(null)
|
||||
const helpWrapRef = ref(null)
|
||||
|
||||
function toggleNotification() {
|
||||
const next = !notificationOpen.value
|
||||
notificationOpen.value = next
|
||||
if (next) {
|
||||
helpOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleHelp() {
|
||||
const next = !helpOpen.value
|
||||
helpOpen.value = next
|
||||
if (next) {
|
||||
notificationOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function closeNotification() {
|
||||
notificationOpen.value = false
|
||||
}
|
||||
|
||||
function closeHelp() {
|
||||
helpOpen.value = false
|
||||
}
|
||||
|
||||
function isInsideWrap(wrapRef, target) {
|
||||
const root = wrapRef.value
|
||||
return Boolean(root && target instanceof Node && root.contains(target))
|
||||
}
|
||||
|
||||
function handleTopBarPopoverOutside(event) {
|
||||
if (!notificationOpen.value && !helpOpen.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const target = event.target
|
||||
if (!(target instanceof Node)) {
|
||||
notificationOpen.value = false
|
||||
helpOpen.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (notificationOpen.value && !isInsideWrap(notificationWrapRef, target)) {
|
||||
notificationOpen.value = false
|
||||
}
|
||||
|
||||
if (helpOpen.value && !isInsideWrap(helpWrapRef, target)) {
|
||||
helpOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('pointerdown', handleTopBarPopoverOutside, true)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.removeEventListener('pointerdown', handleTopBarPopoverOutside, true)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
notificationOpen,
|
||||
helpOpen,
|
||||
notificationWrapRef,
|
||||
helpWrapRef,
|
||||
topbarHelpDemo: TOPBAR_HELP_DEMO,
|
||||
toggleNotification,
|
||||
toggleHelp,
|
||||
closeNotification,
|
||||
closeHelp
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { useRequests } from '../composables/useRequests.js'
|
||||
import { useChat } from '../composables/useChat.js'
|
||||
import { useToast } from '../composables/useToast.js'
|
||||
import { documents } from '../data/requests.js'
|
||||
import { createCurrentYearDateRange } from '../utils/dateRangeDefaults.js'
|
||||
|
||||
export default {
|
||||
name: 'App',
|
||||
@@ -71,7 +72,7 @@ export default {
|
||||
const { toastText, toast } = useToast()
|
||||
|
||||
const docSearch = ref('')
|
||||
const customRange = ref({ start: '2024-07-06', end: '2024-07-12' })
|
||||
const customRange = ref(createCurrentYearDateRange())
|
||||
const travelPrompts = ['帮我提交出差申请', '预订机票', '预订酒店', '预订火车票', '查询差旅政策']
|
||||
|
||||
const topBarView = computed(() => {
|
||||
|
||||
14
web/src/utils/dateRangeDefaults.js
Normal file
14
web/src/utils/dateRangeDefaults.js
Normal file
@@ -0,0 +1,14 @@
|
||||
export function formatDateValue(date = new 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}`
|
||||
}
|
||||
|
||||
export function createCurrentYearDateRange(now = new Date()) {
|
||||
const year = now.getFullYear()
|
||||
return {
|
||||
start: `${year}-01-01`,
|
||||
end: formatDateValue(now)
|
||||
}
|
||||
}
|
||||
@@ -235,6 +235,13 @@ export function canViewRiskForContext(flag, options = {}) {
|
||||
return context.isDirectManagerReviewer || context.canViewApprovalRiskAdvice || context.isBudgetReviewer
|
||||
}
|
||||
|
||||
const isReimbursementSubmitterFixableRisk = (
|
||||
(actionability === 'fixable_by_submitter' || visibilityScope === 'submitter') &&
|
||||
!['budget', 'profile'].includes(riskDomain)
|
||||
)
|
||||
if (isReimbursementSubmitterFixableRisk) {
|
||||
return true
|
||||
}
|
||||
if (context.isCurrentApplicant) {
|
||||
return actionability === 'fixable_by_submitter' || visibilityScope === 'submitter'
|
||||
}
|
||||
|
||||
@@ -205,26 +205,31 @@
|
||||
<span>条款填写时间</span>
|
||||
</td>
|
||||
<td :class="['expense-time col-time', { 'has-major-risk': hasExpenseRiskIndicator(item) }]">
|
||||
<button
|
||||
v-if="hasExpenseRiskIndicator(item)"
|
||||
class="expense-risk-indicator"
|
||||
type="button"
|
||||
:title="resolveExpenseRiskIndicatorTitle(item)"
|
||||
:aria-label="resolveExpenseRiskIndicatorTitle(item)"
|
||||
@click="focusExpenseRisk(item)"
|
||||
>
|
||||
<i class="mdi mdi-alert"></i>
|
||||
</button>
|
||||
<template v-if="editingExpenseId === item.id">
|
||||
<div class="cell-editor">
|
||||
<input v-model="expenseEditor.itemDate" class="editor-input" type="date" />
|
||||
<span>{{ item.dayLabel }}</span>
|
||||
<div class="expense-time-content">
|
||||
<button
|
||||
v-if="hasExpenseRiskIndicator(item)"
|
||||
class="expense-risk-indicator"
|
||||
type="button"
|
||||
:title="resolveExpenseRiskIndicatorTitle(item)"
|
||||
:aria-label="resolveExpenseRiskIndicatorTitle(item)"
|
||||
@click="focusExpenseRisk(item)"
|
||||
>
|
||||
<i class="mdi mdi-alert"></i>
|
||||
</button>
|
||||
<span v-else class="expense-risk-indicator-placeholder" aria-hidden="true"></span>
|
||||
<div class="expense-time-value">
|
||||
<template v-if="editingExpenseId === item.id">
|
||||
<div class="cell-editor">
|
||||
<input v-model="expenseEditor.itemDate" class="editor-input" type="date" />
|
||||
<span>{{ item.dayLabel }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<strong>{{ item.time }}</strong>
|
||||
<span>{{ item.dayLabel }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<strong>{{ item.time }}</strong>
|
||||
<span>{{ item.dayLabel }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</td>
|
||||
<td class="expense-type col-type">
|
||||
<template v-if="editingExpenseId === item.id">
|
||||
|
||||
@@ -1682,7 +1682,7 @@ export default {
|
||||
const target = card
|
||||
? document.getElementById(resolveRiskCardDomId(card))
|
||||
: riskSection
|
||||
target?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
target?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' })
|
||||
|
||||
if (highlightedRiskCardTimer) {
|
||||
window.clearTimeout(highlightedRiskCardTimer)
|
||||
|
||||
@@ -454,10 +454,33 @@ function resolveSourceStandardAdjustment(source, id, requestModel) {
|
||||
if (reimbursableAmount === null) {
|
||||
return null
|
||||
}
|
||||
const originalAmount = parseOptionalCurrency(
|
||||
source?.originalItemAmount
|
||||
?? source?.original_item_amount
|
||||
?? source?.originalAmount
|
||||
?? source?.original_amount
|
||||
)
|
||||
const employeeAbsorbedAmount = parseOptionalCurrency(
|
||||
source?.employeeAbsorbedAmount
|
||||
?? source?.employee_absorbed_amount
|
||||
) || 0
|
||||
const hasExplicitAdjustmentMarker = Boolean(
|
||||
source?.standardAdjustmentAccepted
|
||||
|| source?.standard_adjustment_accepted
|
||||
|| source?.hasStandardAdjustment
|
||||
|| source?.has_standard_adjustment
|
||||
|| String(source?.standardAdjustmentMessage || source?.standard_adjustment_message || '').trim()
|
||||
|| employeeAbsorbedAmount > 0
|
||||
|| (originalAmount !== null && reimbursableAmount < originalAmount)
|
||||
)
|
||||
if (!hasExplicitAdjustmentMarker) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
originalAmount: parseOptionalCurrency(source?.originalItemAmount ?? source?.original_item_amount ?? source?.originalAmount ?? source?.original_amount),
|
||||
originalAmount,
|
||||
reimbursableAmount,
|
||||
employeeAbsorbedAmount: parseOptionalCurrency(source?.employeeAbsorbedAmount ?? source?.employee_absorbed_amount) || 0,
|
||||
employeeAbsorbedAmount,
|
||||
message: String(source?.standardAdjustmentMessage || source?.standard_adjustment_message || '').trim()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,18 +78,28 @@ test('documents center reloads immediately when entered or clicked again', () =>
|
||||
test('document detail navigation preserves document center list query', () => {
|
||||
assert.match(
|
||||
appShellComposable,
|
||||
/function openRequestDetail\(request\) \{[\s\S]*name: 'app-document-detail'[\s\S]*params: \{ requestId: request\.claimId \|\| request\.id \},[\s\S]*query: \{ \.\.\.route\.query \}/
|
||||
/function openRequestDetail\(request, options = \{\}\) \{[\s\S]*name: 'app-document-detail'[\s\S]*params: \{ requestId: request\.claimId \|\| request\.id \},[\s\S]*query: buildDocumentDetailQuery\(options\)/
|
||||
)
|
||||
assert.match(
|
||||
appShellComposable,
|
||||
/function closeRequestDetail\(\) \{[\s\S]*router\.push\(\{ name: 'app-documents', query: \{ \.\.\.route\.query \} \}\)/
|
||||
/function closeRequestDetail\(\) \{[\s\S]*router\.push\(\{ name: 'app-documents', query: buildDocumentReturnQuery\(\) \}\)/
|
||||
)
|
||||
assert.match(
|
||||
appShellComposable,
|
||||
/async function handleRequestDeleted\(payload = \{\}\) \{[\s\S]*router\.push\(\{ name: 'app-documents', query: \{ \.\.\.route\.query \} \}\)/
|
||||
/async function handleRequestDeleted\(payload = \{\}\) \{[\s\S]*router\.push\(\{ name: 'app-documents', query: buildDocumentReturnQuery\(\) \}\)/
|
||||
)
|
||||
})
|
||||
|
||||
test('document detail refreshes claim detail instead of relying on stale list cache', () => {
|
||||
assert.match(appShellComposable, /import \{ fetchExpenseClaimDetail \} from '\.\.\/services\/reimbursements\.js'/)
|
||||
assert.match(appShellComposable, /import \{ mapExpenseClaimToRequest, useRequests \} from '\.\/useRequests\.js'/)
|
||||
assert.match(appShellComposable, /const snapshot = normalizeRequestForUi\(selectedRequestSnapshot\.value\)[\s\S]*if \(isSameRequestIdentity\(snapshot, requestId\)\) \{[\s\S]*return snapshot/)
|
||||
assert.match(appShellComposable, /async function refreshSelectedRequestDetail\(requestOrId = selectedRequestSnapshot\.value\) \{[\s\S]*fetchExpenseClaimDetail\(lookupId\)[\s\S]*mapExpenseClaimToRequest\(payload\)[\s\S]*upsertRequestSnapshot\(mappedRequest\)/)
|
||||
assert.match(appShellComposable, /function openRequestDetail\(request, options = \{\}\) \{[\s\S]*void refreshSelectedRequestDetail\(request\)/)
|
||||
assert.match(appShellComposable, /async function handleRequestUpdated\(\) \{[\s\S]*await reloadRequests\(\)[\s\S]*await refreshSelectedRequestDetail\(String\(route\.params\.requestId \|\| ''\)\)/)
|
||||
assert.match(appShellComposable, /route\.name === 'app-document-detail'[\s\S]*void refreshSelectedRequestDetail\(String\(route\.params\.requestId \|\| ''\)\)/)
|
||||
})
|
||||
|
||||
test('application entry keeps its own assistant source without creating a separate dialog', () => {
|
||||
assert.match(appShellComposable, /const SMART_ENTRY_SOURCE_APPLICATION = 'application'/)
|
||||
assert.match(appShellComposable, /function openExpenseApplicationCreate\(\) \{[\s\S]*openFinancialAssistantCreate\(SMART_ENTRY_SOURCE_APPLICATION\)/)
|
||||
|
||||
@@ -16,6 +16,10 @@ const dashboardComponent = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/dashboard/RiskObservationDashboard.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const riskDailyTrendChart = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/charts/RiskDailyTrendChart.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const overviewViewModel = readFileSync(
|
||||
fileURLToPath(new URL('../src/composables/useOverviewView.js', import.meta.url)),
|
||||
'utf8'
|
||||
@@ -24,6 +28,18 @@ const overviewTemplate = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/OverviewView.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const topBarComponent = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/layout/TopBar.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const appShellComposable = readFileSync(
|
||||
fileURLToPath(new URL('../src/composables/useAppShell.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const legacyAppScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/scripts/App.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const riskLabels = readFileSync(
|
||||
fileURLToPath(new URL('../src/utils/riskLabels.js', import.meta.url)),
|
||||
'utf8'
|
||||
@@ -118,6 +134,28 @@ test('risk dashboard follows the top overview range without card-level selectors
|
||||
assert.match(dashboardComponent, /RiskDailyTrendChart/)
|
||||
})
|
||||
|
||||
test('overview custom date defaults use current year instead of hard-coded legacy dates', () => {
|
||||
assert.match(topBarComponent, /createCurrentYearDateRange/)
|
||||
assert.match(topBarComponent, /formatDateValue/)
|
||||
assert.match(appShellComposable, /createCurrentYearDateRange\(\)/)
|
||||
assert.match(legacyAppScript, /createCurrentYearDateRange\(\)/)
|
||||
assert.doesNotMatch(topBarComponent, /2024-07-06|2024-07-12/)
|
||||
assert.doesNotMatch(appShellComposable, /2024-07-06|2024-07-12/)
|
||||
assert.doesNotMatch(legacyAppScript, /2024-07-06|2024-07-12/)
|
||||
})
|
||||
|
||||
test('risk daily trend is bucketed for long ranges and keeps chart labels readable', () => {
|
||||
assert.match(overviewViewModel, /RISK_DAILY_TREND_MAX_BUCKETS = 14/)
|
||||
assert.match(overviewViewModel, /aggregateRiskDailyTrendRows/)
|
||||
assert.match(overviewViewModel, /Math\.ceil\(normalizedRows\.length \/ maxBuckets\)/)
|
||||
assert.match(overviewViewModel, /buildRiskTrendBucketLabel/)
|
||||
assert.doesNotMatch(overviewViewModel, /rows\.slice\(-7\)/)
|
||||
assert.match(riskDailyTrendChart, /const barWidth = computed/)
|
||||
assert.match(riskDailyTrendChart, /barMaxWidth: 14/)
|
||||
assert.match(riskDailyTrendChart, /bottomGridSize/)
|
||||
assert.match(riskDailyTrendChart, /replace\('~', '\\n~'\)/)
|
||||
})
|
||||
|
||||
test('risk dashboard shows loading overlay and realtime refresh status', () => {
|
||||
assert.match(overviewTemplate, /dashboard-loading-state/)
|
||||
assert.match(overviewTemplate, /floating/)
|
||||
|
||||
@@ -105,6 +105,43 @@ test('reimbursement submitter sees only fixable claim risks', () => {
|
||||
assert.deepEqual(visibleCards.map((card) => card.id), ['ticket-date'])
|
||||
})
|
||||
|
||||
test('reimbursement detail still shows submitter-fixable attachment risks when viewer identity is incomplete', () => {
|
||||
const request = {
|
||||
id: 'RE-20260603083825-876B85XW',
|
||||
claimId: '2ad80b25-b153-407e-91be-ed2651045fb1',
|
||||
documentTypeCode: 'claim',
|
||||
approvalKey: 'draft',
|
||||
node: 'pending-submit',
|
||||
employeeId: 'EMP-CLAIM-OWNER',
|
||||
typeCode: 'travel',
|
||||
expenseItems: []
|
||||
}
|
||||
const currentUserWithoutEmployeeMatch = {
|
||||
id: 'FRONTEND-AUTH-SNAPSHOT',
|
||||
employeeId: '',
|
||||
name: '',
|
||||
roleCodes: []
|
||||
}
|
||||
const cards = [
|
||||
{
|
||||
id: 'hotel-limit-risk',
|
||||
source: 'attachment_analysis',
|
||||
businessStage: 'reimbursement',
|
||||
tone: 'high',
|
||||
risk: 'hotel limit exceeded',
|
||||
risk_domain: 'invoice',
|
||||
visibility_scope: 'submitter',
|
||||
actionability: 'fixable_by_submitter'
|
||||
}
|
||||
]
|
||||
|
||||
const visibleCards = filterRiskCardsForVisibility(cards, {
|
||||
request,
|
||||
currentUser: currentUserWithoutEmployeeMatch
|
||||
})
|
||||
assert.deepEqual(visibleCards.map((card) => card.id), ['hotel-limit-risk'])
|
||||
})
|
||||
|
||||
test('finance can see reimbursement compliance risks but not budget governance detail', () => {
|
||||
const request = {
|
||||
id: 'RE-202606010002',
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import {
|
||||
buildExpenseItemViewModel,
|
||||
buildDraftBlockingIssues,
|
||||
rebuildExpenseItems,
|
||||
buildStandardAdjustmentMap,
|
||||
isApplicationDocumentRequest
|
||||
} from '../src/views/scripts/travelRequestDetailExpenseModel.js'
|
||||
@@ -543,9 +544,13 @@ test('AI advice risk section uses compact card styling hooks', () => {
|
||||
|
||||
test('expense rows show a major-risk warning icon before time', () => {
|
||||
assert.match(detailViewTemplate, /'has-major-risk': hasExpenseRiskIndicator\(item\)/)
|
||||
assert.match(detailViewTemplate, /class="expense-time-content"/)
|
||||
assert.match(detailViewTemplate, /class="expense-risk-indicator"/)
|
||||
assert.match(detailViewTemplate, /class="expense-risk-indicator-placeholder"/)
|
||||
assert.match(detailViewTemplate, /@click="focusExpenseRisk\(item\)"/)
|
||||
assert.match(detailViewStyle, /\.expense-time-content \{/)
|
||||
assert.match(detailViewStyle, /\.expense-risk-indicator \{/)
|
||||
assert.match(detailViewStyle, /\.expense-risk-indicator,\s*\.expense-risk-indicator-placeholder \{/)
|
||||
assert.match(detailViewScript, /function hasExpenseRiskIndicator\(item\)/)
|
||||
assert.match(detailViewScript, /buildItemClaimRiskState\(item, resolveClaimRiskFlags\(\)\)/)
|
||||
})
|
||||
@@ -556,7 +561,7 @@ test('expense risk indicator can focus and flash related risk card', () => {
|
||||
assert.match(detailViewTemplate, /'is-highlighted': isHighlightedRiskCard\(card\)/)
|
||||
assert.match(detailViewScript, /async function focusExpenseRisk\(item\)/)
|
||||
assert.match(detailViewScript, /document\.getElementById\(resolveRiskCardDomId\(card\)\)/)
|
||||
assert.match(detailViewScript, /scrollIntoView\(\{ behavior: 'smooth', block: 'center' \}\)/)
|
||||
assert.match(detailViewScript, /scrollIntoView\(\{ behavior: 'smooth', block: 'nearest', inline: 'nearest' \}\)/)
|
||||
assert.match(detailViewStyle, /\.validation-section--risk \.risk-advice-card\.is-highlighted/)
|
||||
assert.match(detailViewStyle, /@keyframes risk-card-flash/)
|
||||
})
|
||||
@@ -748,6 +753,50 @@ test('expense detail shows standard-adjusted reimbursable amount separately from
|
||||
assert.equal(item.hasStandardAdjustment, true)
|
||||
})
|
||||
|
||||
test('plain reimbursable amount does not mark an item as standard-adjusted during detail rebuild', () => {
|
||||
const item = buildExpenseItemViewModel(
|
||||
{
|
||||
id: 'hotel-risk-item',
|
||||
itemType: 'hotel_ticket',
|
||||
itemReason: '上海住宿',
|
||||
itemAmount: 1086,
|
||||
reimbursableAmount: 1086,
|
||||
originalItemAmount: 1086,
|
||||
invoiceId: 'hotel-risk.jpg',
|
||||
standardAdjustmentAccepted: false,
|
||||
hasStandardAdjustment: false
|
||||
},
|
||||
0,
|
||||
{ riskFlags: [] }
|
||||
)
|
||||
|
||||
assert.equal(item.standardAdjustmentAccepted, false)
|
||||
assert.equal(item.hasStandardAdjustment, false)
|
||||
assert.equal(item.reimbursableAmount, 1086)
|
||||
|
||||
const rebuiltItems = rebuildExpenseItems([item], { riskFlags: [] })
|
||||
assert.equal(rebuiltItems[0].standardAdjustmentAccepted, false)
|
||||
assert.equal(rebuiltItems[0].hasStandardAdjustment, false)
|
||||
|
||||
const riskCards = [
|
||||
{
|
||||
id: 'hotel-risk',
|
||||
source: 'attachment_analysis',
|
||||
itemId: 'hotel-risk-item',
|
||||
tone: 'high',
|
||||
risk: '住宿标准超标。'
|
||||
}
|
||||
]
|
||||
const visibleCards = filterSubmitterResolvedRiskCards({
|
||||
cards: riskCards,
|
||||
businessStage: 'reimbursement',
|
||||
isCurrentApplicant: true,
|
||||
expenseItems: rebuiltItems,
|
||||
standardAdjustmentMap: new Map()
|
||||
})
|
||||
assert.deepEqual(visibleCards.map((card) => card.id), ['hotel-risk'])
|
||||
})
|
||||
|
||||
test('standard adjustment resolves submitter risk prompt only after accepted while reviewer still sees notice', () => {
|
||||
const originalRiskCard = {
|
||||
id: 'risk-hotel-1',
|
||||
|
||||
Reference in New Issue
Block a user