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

@@ -881,6 +881,176 @@
line-height: 1.5; 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 { .company-switcher {
max-width: min(220px, 28vw); max-width: min(220px, 28vw);
height: 38px; height: 38px;

View File

@@ -936,25 +936,33 @@
.detail-expense-table .col-action { width: 9%; } .detail-expense-table .col-action { width: 9%; }
.expense-time { .expense-time {
position: relative; min-width: 0;
} }
.expense-time.has-major-risk { .expense-time-content {
padding-left: 30px; 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 { .expense-risk-indicator {
position: absolute;
left: 8px;
top: 50%;
width: 18px;
height: 18px;
padding: 0; padding: 0;
border: 0; border: 0;
background: transparent; background: transparent;
display: inline-grid; display: inline-grid;
place-items: center; place-items: center;
transform: translateY(-50%);
color: #dc2626; color: #dc2626;
font-size: 18px; font-size: 18px;
line-height: 1; line-height: 1;

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

View File

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

View File

@@ -2,9 +2,10 @@ import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useNavigation, navItems } from './useNavigation.js' import { useNavigation, navItems } from './useNavigation.js'
import { useRequests } from './useRequests.js' import { mapExpenseClaimToRequest, useRequests } from './useRequests.js'
import { useSystemState } from './useSystemState.js' import { useSystemState } from './useSystemState.js'
import { useToast } from './useToast.js' import { useToast } from './useToast.js'
import { fetchExpenseClaimDetail } from '../services/reimbursements.js'
import { fetchOntologyParse } from '../services/ontology.js' import { fetchOntologyParse } from '../services/ontology.js'
import { fetchLatestConversation } from '../services/orchestrator.js' import { fetchLatestConversation } from '../services/orchestrator.js'
import { clearAssistantSessionSnapshotForDraftClaim } from '../utils/assistantSessionSnapshot.js' import { clearAssistantSessionSnapshotForDraftClaim } from '../utils/assistantSessionSnapshot.js'
@@ -16,6 +17,7 @@ import {
resolveWorkbenchSessionTypeFromOntology resolveWorkbenchSessionTypeFromOntology
} from '../utils/workbenchAssistantIntent.js' } from '../utils/workbenchAssistantIntent.js'
import { buildWorkbenchSummary } from '../utils/workbenchSummary.js' import { buildWorkbenchSummary } from '../utils/workbenchSummary.js'
import { createCurrentYearDateRange } from '../utils/dateRangeDefaults.js'
const SESSION_TYPE_EXPENSE = 'expense' const SESSION_TYPE_EXPENSE = 'expense'
const SMART_ENTRY_SOURCE_APPLICATION = 'application' const SMART_ENTRY_SOURCE_APPLICATION = 'application'
@@ -62,36 +64,29 @@ export function useAppShell() {
const { currentUser } = useSystemState() const { currentUser } = useSystemState()
const { toast } = useToast() const { toast } = useToast()
const customRange = ref({ start: '2024-07-06', end: '2024-07-12' }) const customRange = ref(createCurrentYearDateRange())
const selectedRequest = computed(() => { const selectedRequest = computed(() => {
const requestId = String(route.params.requestId || '') const requestId = String(route.params.requestId || '')
if (!requestId) { if (!requestId) {
return null return null
} }
const rawRequest = requests.value.find( const snapshot = normalizeRequestForUi(selectedRequestSnapshot.value)
(item) => String(item.claimId || '').trim() === requestId || String(item.id || '').trim() === requestId if (isSameRequestIdentity(snapshot, requestId)) {
) return snapshot
const normalizedRequest = normalizeRequestForUi(rawRequest) }
if (normalizedRequest) {
return normalizedRequest const rawRequest = requests.value.find(
} (item) => String(item.claimId || '').trim() === requestId || String(item.id || '').trim() === requestId
)
const snapshot = normalizeRequestForUi(selectedRequestSnapshot.value) const normalizedRequest = normalizeRequestForUi(rawRequest)
if ( if (normalizedRequest) {
snapshot return normalizedRequest
&& ( }
String(snapshot.claimId || '').trim() === requestId
|| String(snapshot.id || '').trim() === requestId return null
|| String(snapshot.documentNo || '').trim() === requestId
)
) {
return snapshot
}
return null
}) })
const detailMode = computed(() => route.name === 'app-document-detail') const detailMode = computed(() => route.name === 'app-document-detail')
@@ -110,9 +105,81 @@ export function useAppShell() {
return reloadRequests() 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( watch(
() => [activeView.value, route.name], () => [activeView.value, route.name],
([view]) => { ([view]) => {
if (route.name === 'app-document-detail') {
void ensureRequestsLoaded()
void refreshSelectedRequestDetail(String(route.params.requestId || ''))
return
}
if (view === 'documents') { if (view === 'documents') {
void reloadDocumentCenterRequests() void reloadDocumentCenterRequests()
return return
@@ -425,6 +492,7 @@ export function useAppShell() {
params: { requestId: request.claimId || request.id }, params: { requestId: request.claimId || request.id },
query: buildDocumentDetailQuery(options) query: buildDocumentDetailQuery(options)
}) })
void refreshSelectedRequestDetail(request)
} }
function closeRequestDetail() { function closeRequestDetail() {
@@ -438,6 +506,7 @@ export function useAppShell() {
async function handleRequestUpdated() { async function handleRequestUpdated() {
await reloadRequests() await reloadRequests()
await refreshSelectedRequestDetail(String(route.params.requestId || ''))
} }
async function handleRequestDeleted(payload = {}) { async function handleRequestDeleted(payload = {}) {

View File

@@ -37,6 +37,7 @@ import {
const DEFAULT_OVERVIEW_RANGE = '近10日' const DEFAULT_OVERVIEW_RANGE = '近10日'
const DAY_MS = 24 * 60 * 60 * 1000 const DAY_MS = 24 * 60 * 60 * 1000
const RISK_DAILY_TREND_MAX_BUCKETS = 14
const emptyFinanceTotals = { const emptyFinanceTotals = {
reimbursementAmount: 0, reimbursementAmount: 0,
@@ -137,6 +138,64 @@ function resolveTopRangeKey(range, customRange = {}) {
return key || DEFAULT_OVERVIEW_RANGE 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 = {}) { export function useOverviewView(options = {}) {
const activeDashboardKey = computed(() => { const activeDashboardKey = computed(() => {
const dashboard = String(options.dashboard || '').trim() const dashboard = String(options.dashboard || '').trim()
@@ -830,11 +889,7 @@ export function useOverviewView(options = {}) {
}) })
const riskDailyTrendRows = computed(() => { const riskDailyTrendRows = computed(() => {
const rows = Array.isArray(riskDashboard.value.dailyTrend) ? riskDashboard.value.dailyTrend : [] const rows = Array.isArray(riskDashboard.value.dailyTrend) ? riskDashboard.value.dailyTrend : []
const normalizedRows = rows.slice(-7).map((item) => ({ const normalizedRows = aggregateRiskDailyTrendRows(rows)
date: String(item.date || '').trim() || '-',
total: Number(item.total || 0),
highOrAbove: Number(item.high_or_above ?? item.highOrAbove ?? 0)
}))
const maxValue = Math.max(...normalizedRows.map((item) => item.total), 1) const maxValue = Math.max(...normalizedRows.map((item) => item.total), 1)
return normalizedRows.map((item) => ({ return normalizedRows.map((item) => ({

View 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
}
}

View File

@@ -21,6 +21,7 @@ import { useRequests } from '../composables/useRequests.js'
import { useChat } from '../composables/useChat.js' import { useChat } from '../composables/useChat.js'
import { useToast } from '../composables/useToast.js' import { useToast } from '../composables/useToast.js'
import { documents } from '../data/requests.js' import { documents } from '../data/requests.js'
import { createCurrentYearDateRange } from '../utils/dateRangeDefaults.js'
export default { export default {
name: 'App', name: 'App',
@@ -71,7 +72,7 @@ export default {
const { toastText, toast } = useToast() const { toastText, toast } = useToast()
const docSearch = ref('') const docSearch = ref('')
const customRange = ref({ start: '2024-07-06', end: '2024-07-12' }) const customRange = ref(createCurrentYearDateRange())
const travelPrompts = ['帮我提交出差申请', '预订机票', '预订酒店', '预订火车票', '查询差旅政策'] const travelPrompts = ['帮我提交出差申请', '预订机票', '预订酒店', '预订火车票', '查询差旅政策']
const topBarView = computed(() => { const topBarView = computed(() => {

View 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)
}
}

View File

@@ -235,6 +235,13 @@ export function canViewRiskForContext(flag, options = {}) {
return context.isDirectManagerReviewer || context.canViewApprovalRiskAdvice || context.isBudgetReviewer 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) { if (context.isCurrentApplicant) {
return actionability === 'fixable_by_submitter' || visibilityScope === 'submitter' return actionability === 'fixable_by_submitter' || visibilityScope === 'submitter'
} }

View File

@@ -205,26 +205,31 @@
<span>条款填写时间</span> <span>条款填写时间</span>
</td> </td>
<td :class="['expense-time col-time', { 'has-major-risk': hasExpenseRiskIndicator(item) }]"> <td :class="['expense-time col-time', { 'has-major-risk': hasExpenseRiskIndicator(item) }]">
<button <div class="expense-time-content">
v-if="hasExpenseRiskIndicator(item)" <button
class="expense-risk-indicator" v-if="hasExpenseRiskIndicator(item)"
type="button" class="expense-risk-indicator"
:title="resolveExpenseRiskIndicatorTitle(item)" type="button"
:aria-label="resolveExpenseRiskIndicatorTitle(item)" :title="resolveExpenseRiskIndicatorTitle(item)"
@click="focusExpenseRisk(item)" :aria-label="resolveExpenseRiskIndicatorTitle(item)"
> @click="focusExpenseRisk(item)"
<i class="mdi mdi-alert"></i> >
</button> <i class="mdi mdi-alert"></i>
<template v-if="editingExpenseId === item.id"> </button>
<div class="cell-editor"> <span v-else class="expense-risk-indicator-placeholder" aria-hidden="true"></span>
<input v-model="expenseEditor.itemDate" class="editor-input" type="date" /> <div class="expense-time-value">
<span>{{ item.dayLabel }}</span> <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> </div>
</template> </div>
<template v-else>
<strong>{{ item.time }}</strong>
<span>{{ item.dayLabel }}</span>
</template>
</td> </td>
<td class="expense-type col-type"> <td class="expense-type col-type">
<template v-if="editingExpenseId === item.id"> <template v-if="editingExpenseId === item.id">

View File

@@ -1682,7 +1682,7 @@ export default {
const target = card const target = card
? document.getElementById(resolveRiskCardDomId(card)) ? document.getElementById(resolveRiskCardDomId(card))
: riskSection : riskSection
target?.scrollIntoView({ behavior: 'smooth', block: 'center' }) target?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' })
if (highlightedRiskCardTimer) { if (highlightedRiskCardTimer) {
window.clearTimeout(highlightedRiskCardTimer) window.clearTimeout(highlightedRiskCardTimer)

View File

@@ -454,10 +454,33 @@ function resolveSourceStandardAdjustment(source, id, requestModel) {
if (reimbursableAmount === null) { if (reimbursableAmount === null) {
return 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 { return {
originalAmount: parseOptionalCurrency(source?.originalItemAmount ?? source?.original_item_amount ?? source?.originalAmount ?? source?.original_amount), originalAmount,
reimbursableAmount, reimbursableAmount,
employeeAbsorbedAmount: parseOptionalCurrency(source?.employeeAbsorbedAmount ?? source?.employee_absorbed_amount) || 0, employeeAbsorbedAmount,
message: String(source?.standardAdjustmentMessage || source?.standard_adjustment_message || '').trim() message: String(source?.standardAdjustmentMessage || source?.standard_adjustment_message || '').trim()
} }
} }

View File

@@ -78,18 +78,28 @@ test('documents center reloads immediately when entered or clicked again', () =>
test('document detail navigation preserves document center list query', () => { test('document detail navigation preserves document center list query', () => {
assert.match( assert.match(
appShellComposable, 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( assert.match(
appShellComposable, 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( assert.match(
appShellComposable, 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', () => { 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, /const SMART_ENTRY_SOURCE_APPLICATION = 'application'/)
assert.match(appShellComposable, /function openExpenseApplicationCreate\(\) \{[\s\S]*openFinancialAssistantCreate\(SMART_ENTRY_SOURCE_APPLICATION\)/) assert.match(appShellComposable, /function openExpenseApplicationCreate\(\) \{[\s\S]*openFinancialAssistantCreate\(SMART_ENTRY_SOURCE_APPLICATION\)/)

View File

@@ -16,6 +16,10 @@ const dashboardComponent = readFileSync(
fileURLToPath(new URL('../src/components/dashboard/RiskObservationDashboard.vue', import.meta.url)), fileURLToPath(new URL('../src/components/dashboard/RiskObservationDashboard.vue', import.meta.url)),
'utf8' 'utf8'
) )
const riskDailyTrendChart = readFileSync(
fileURLToPath(new URL('../src/components/charts/RiskDailyTrendChart.vue', import.meta.url)),
'utf8'
)
const overviewViewModel = readFileSync( const overviewViewModel = readFileSync(
fileURLToPath(new URL('../src/composables/useOverviewView.js', import.meta.url)), fileURLToPath(new URL('../src/composables/useOverviewView.js', import.meta.url)),
'utf8' 'utf8'
@@ -24,6 +28,18 @@ const overviewTemplate = readFileSync(
fileURLToPath(new URL('../src/views/OverviewView.vue', import.meta.url)), fileURLToPath(new URL('../src/views/OverviewView.vue', import.meta.url)),
'utf8' '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( const riskLabels = readFileSync(
fileURLToPath(new URL('../src/utils/riskLabels.js', import.meta.url)), fileURLToPath(new URL('../src/utils/riskLabels.js', import.meta.url)),
'utf8' 'utf8'
@@ -118,6 +134,28 @@ test('risk dashboard follows the top overview range without card-level selectors
assert.match(dashboardComponent, /RiskDailyTrendChart/) 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', () => { test('risk dashboard shows loading overlay and realtime refresh status', () => {
assert.match(overviewTemplate, /dashboard-loading-state/) assert.match(overviewTemplate, /dashboard-loading-state/)
assert.match(overviewTemplate, /floating/) assert.match(overviewTemplate, /floating/)

View File

@@ -105,6 +105,43 @@ test('reimbursement submitter sees only fixable claim risks', () => {
assert.deepEqual(visibleCards.map((card) => card.id), ['ticket-date']) 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', () => { test('finance can see reimbursement compliance risks but not budget governance detail', () => {
const request = { const request = {
id: 'RE-202606010002', id: 'RE-202606010002',

View File

@@ -17,6 +17,7 @@ import {
import { import {
buildExpenseItemViewModel, buildExpenseItemViewModel,
buildDraftBlockingIssues, buildDraftBlockingIssues,
rebuildExpenseItems,
buildStandardAdjustmentMap, buildStandardAdjustmentMap,
isApplicationDocumentRequest isApplicationDocumentRequest
} from '../src/views/scripts/travelRequestDetailExpenseModel.js' } 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', () => { test('expense rows show a major-risk warning icon before time', () => {
assert.match(detailViewTemplate, /'has-major-risk': hasExpenseRiskIndicator\(item\)/) 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"/)
assert.match(detailViewTemplate, /class="expense-risk-indicator-placeholder"/)
assert.match(detailViewTemplate, /@click="focusExpenseRisk\(item\)"/) assert.match(detailViewTemplate, /@click="focusExpenseRisk\(item\)"/)
assert.match(detailViewStyle, /\.expense-time-content \{/)
assert.match(detailViewStyle, /\.expense-risk-indicator \{/) 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, /function hasExpenseRiskIndicator\(item\)/)
assert.match(detailViewScript, /buildItemClaimRiskState\(item, resolveClaimRiskFlags\(\)\)/) 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(detailViewTemplate, /'is-highlighted': isHighlightedRiskCard\(card\)/)
assert.match(detailViewScript, /async function focusExpenseRisk\(item\)/) assert.match(detailViewScript, /async function focusExpenseRisk\(item\)/)
assert.match(detailViewScript, /document\.getElementById\(resolveRiskCardDomId\(card\)\)/) 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, /\.validation-section--risk \.risk-advice-card\.is-highlighted/)
assert.match(detailViewStyle, /@keyframes risk-card-flash/) 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) 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', () => { test('standard adjustment resolves submitter risk prompt only after accepted while reviewer still sees notice', () => {
const originalRiskCard = { const originalRiskCard = {
id: 'risk-hotel-1', id: 'risk-hotel-1',