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

@@ -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 = {}) {

View File

@@ -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) => ({

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