feat: 新增风险图谱算法与系统仪表盘及操作反馈体系

后端新增风险图谱算法模块、风险观察与反馈服务、规则 DSL
校验器和可解释性引擎,完善系统仪表盘和财务仪表盘统计,
优化 agent 运行和编排执行链路,清理旧开发文档,前端新增
系统趋势、负载热力图等多种仪表盘图表组件,完善操作反馈
对话框和工作台日期选择器,优化报销创建和审批详情交互,
补充单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-30 15:46:51 +08:00
parent 4c59941ec6
commit 7989f3a159
314 changed files with 30073 additions and 20626 deletions

View File

@@ -1,15 +1,21 @@
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useNavigation, navItems } from './useNavigation.js'
import { useRequests } from './useRequests.js'
import { useRequests } from './useRequests.js'
import { useSystemState } from './useSystemState.js'
import { useToast } from './useToast.js'
import { fetchLatestConversation } from '../services/orchestrator.js'
import { clearAssistantSessionSnapshotForDraftClaim } from '../utils/assistantSessionSnapshot.js'
import { buildDetailAlerts } from '../utils/detailAlerts.js'
import { normalizeRequestForUi } from '../utils/requestViewModel.js'
import { buildWorkbenchSummary } from '../utils/workbenchSummary.js'
import { useToast } from './useToast.js'
import { fetchOntologyParse } from '../services/ontology.js'
import { fetchLatestConversation } from '../services/orchestrator.js'
import { clearAssistantSessionSnapshotForDraftClaim } from '../utils/assistantSessionSnapshot.js'
import { buildDetailAlerts } from '../utils/detailAlerts.js'
import { normalizeRequestForUi } from '../utils/requestViewModel.js'
import {
buildWorkbenchIntentOntologyContext,
resolveWorkbenchSessionTypeFallback,
resolveWorkbenchSessionTypeFromOntology
} from '../utils/workbenchAssistantIntent.js'
import { buildWorkbenchSummary } from '../utils/workbenchSummary.js'
const SESSION_TYPE_EXPENSE = 'expense'
const SMART_ENTRY_SOURCE_APPLICATION = 'application'
@@ -24,10 +30,11 @@ export function useAppShell() {
prompt: '',
source: 'documents',
request: null,
files: [],
conversation: null,
scope: null
})
files: [],
conversation: null,
scope: null,
sessionType: ''
})
const smartEntrySessionId = ref(0)
const smartEntryRevealToken = ref(0)
const smartEntryInvalidatedDraftClaimId = ref('')
@@ -48,10 +55,10 @@ export function useAppShell() {
ensureLoaded: ensureRequestsLoaded,
reload: reloadRequests
} = useRequests()
const { currentUser } = useSystemState()
const { toast } = useToast()
const customRange = ref({ start: '2024-07-06', end: '2024-07-12' })
const { currentUser } = useSystemState()
const { toast } = useToast()
const customRange = ref({ start: '2024-07-06', end: '2024-07-12' })
const selectedRequest = computed(() => {
const requestId = String(route.params.requestId || '')
@@ -171,7 +178,8 @@ export function useAppShell() {
request: null,
files: [],
conversation: null,
scope: null
scope: null,
sessionType: ''
}
smartEntrySessionId.value += 1
}
@@ -209,6 +217,52 @@ export function useAppShell() {
return String(payload.source || '').trim() === 'detail' && Boolean(resolveSmartEntryClaimScope(payload))
}
async function resolveSmartEntrySessionType(payload = {}) {
const explicitSessionType = String(payload.sessionType || '').trim()
if (explicitSessionType) {
return explicitSessionType
}
const source = String(payload.source || 'workbench').trim()
if (source !== 'workbench') {
return ''
}
const prompt = String(payload.prompt || '').trim()
const files = Array.isArray(payload.files) ? payload.files : []
const fallbackSessionType = resolveWorkbenchSessionTypeFallback(prompt, {
attachmentCount: files.length
})
if (!prompt) {
return fallbackSessionType
}
try {
const ontology = await fetchOntologyParse(
{
query: prompt,
user_id: resolveCurrentUserId(),
context_json: {
...buildWorkbenchIntentOntologyContext({
currentUser: currentUser.value,
files
}),
user_input_text: prompt,
fallback_session_type: fallbackSessionType
}
},
{
timeoutMs: 12000,
timeoutMessage: '意图识别超时,已使用本地规则进入助手。'
}
)
return resolveWorkbenchSessionTypeFromOntology(ontology, prompt, fallbackSessionType)
} catch (error) {
console.warn('Workbench model intent routing failed, fallback to local routing:', error)
return fallbackSessionType
}
}
function isApplicationDocumentPayload(payload = {}, claimNo = '') {
const documentType = String(
payload.documentType
@@ -227,32 +281,32 @@ export function useAppShell() {
|| normalizedClaimNo.startsWith('APP-')
)
}
async function resolveSmartEntryConversation(payload = {}) {
if (payload.conversation) {
return payload.conversation
}
if (isDetailClaimScopedPayload(payload)) {
return null
}
if (!payload.restoreLatestConversation) {
return null
}
try {
const latestPayload = await fetchLatestConversation(resolveCurrentUserId(), SESSION_TYPE_EXPENSE, {
preferRecoverable: true
})
return latestPayload?.found ? latestPayload.conversation || null : null
} catch (error) {
console.warn('Failed to restore latest expense conversation for smart entry:', error)
toast(error?.message || '恢复最近报销会话失败,请稍后重试。')
return null
}
}
async function resolveSmartEntryConversation(payload = {}) {
if (payload.conversation) {
return payload.conversation
}
if (isDetailClaimScopedPayload(payload)) {
return null
}
if (!payload.restoreLatestConversation) {
return null
}
try {
const latestPayload = await fetchLatestConversation(resolveCurrentUserId(), SESSION_TYPE_EXPENSE, {
preferRecoverable: true
})
return latestPayload?.found ? latestPayload.conversation || null : null
} catch (error) {
console.warn('Failed to restore latest expense conversation for smart entry:', error)
toast(error?.message || '恢复最近报销会话失败,请稍后重试。')
return null
}
}
async function openSmartEntry(payload = {}) {
const shouldReplaceOpenEntry = Boolean(
payload?.source === 'budget'
@@ -265,38 +319,42 @@ export function useAppShell() {
smartEntryRevealToken.value += 1
return
}
const conversation = await resolveSmartEntryConversation(payload)
const scope = resolveSmartEntryClaimScope(payload)
smartEntryOpen.value = true
smartEntryContext.value = {
prompt: payload.prompt ?? '',
source: payload.source ?? 'workbench',
request: payload.request ?? selectedRequest.value,
files: Array.isArray(payload.files) ? payload.files : [],
conversation,
scope
}
smartEntrySessionId.value += 1
}
function closeSmartEntry() {
smartEntryOpen.value = false
}
async function handleDraftSaved(payload = {}) {
const [conversation, sessionType] = await Promise.all([
resolveSmartEntryConversation(payload),
resolveSmartEntrySessionType(payload)
])
const scope = resolveSmartEntryClaimScope(payload)
smartEntryOpen.value = true
smartEntryContext.value = {
prompt: payload.prompt ?? '',
source: payload.source ?? 'workbench',
request: payload.request ?? selectedRequest.value,
files: Array.isArray(payload.files) ? payload.files : [],
conversation,
scope,
sessionType
}
smartEntrySessionId.value += 1
}
function closeSmartEntry() {
smartEntryOpen.value = false
}
async function handleDraftSaved(payload = {}) {
const claimNo = String(payload.claimNo || payload.claim_no || '').trim()
const status = String(payload.status || payload.claimStatus || '').trim()
const approvalStage = String(payload.approvalStage || payload.approval_stage || '').trim()
const isApplicationDocument = isApplicationDocumentPayload(payload, claimNo)
await reloadRequests()
if (status === 'submitted') {
if (isApplicationDocument) {
toast(`${claimNo || '该'}申请单已提交${approvalStage ? `,当前节点:${approvalStage}` : ',等待直属领导审批'}`)
return
}
smartEntryOpen.value = false
toast(
isApplicationDocument
? `${claimNo || '该'}申请单已提交${approvalStage ? `,当前节点:${approvalStage}` : ',等待直属领导审批'}`
: `${claimNo || '该'}单据已完成 AI预审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}`
)
toast(`${claimNo || '该'}单据已完成 AI预审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}`)
router.push({ name: 'app-documents' })
return
}
@@ -306,7 +364,7 @@ export function useAppShell() {
: `${claimNo || '该'}单据已保存为草稿,可继续上传票据或补充信息。`
)
}
function openRequestDetail(request) {
selectedRequestSnapshot.value = request || null
router.push({
@@ -322,57 +380,57 @@ export function useAppShell() {
async function handleRequestUpdated() {
await reloadRequests()
}
async function handleRequestDeleted(payload = {}) {
const deletedClaimId = String(payload.claimId || payload.claim_id || '').trim()
if (deletedClaimId) {
clearAssistantSessionSnapshotForDraftClaim(resolveCurrentUserId(), deletedClaimId, SESSION_TYPE_EXPENSE)
smartEntryInvalidatedDraftClaimId.value = deletedClaimId
}
async function handleRequestDeleted(payload = {}) {
const deletedClaimId = String(payload.claimId || payload.claim_id || '').trim()
if (deletedClaimId) {
clearAssistantSessionSnapshotForDraftClaim(resolveCurrentUserId(), deletedClaimId, SESSION_TYPE_EXPENSE)
smartEntryInvalidatedDraftClaimId.value = deletedClaimId
}
await reloadRequests()
selectedRequestSnapshot.value = null
router.push({ name: 'app-documents' })
}
return {
activeRange,
activeView,
closeRequestDetail,
closeSmartEntry,
currentView,
return {
activeRange,
activeView,
closeRequestDetail,
closeSmartEntry,
currentView,
customRange,
detailMode,
filteredRequests,
filters,
handleApprove,
handleDraftSaved,
handleNavigate,
handleReject,
handleRequestDeleted,
filters,
handleApprove,
handleDraftSaved,
handleNavigate,
handleReject,
handleRequestDeleted,
handleRequestUpdated,
navItems,
openExpenseApplicationCreate,
openRequestDetail,
openSmartEntry,
openTravelCreate,
ranges,
requestSummary,
workbenchSummary,
requestsError,
requestsLoading,
reloadRequests,
requests,
search,
selectedRequest,
setView,
smartEntryContext,
openTravelCreate,
ranges,
requestSummary,
workbenchSummary,
requestsError,
requestsLoading,
reloadRequests,
requests,
search,
selectedRequest,
setView,
smartEntryContext,
smartEntryInvalidatedDraftClaimId,
smartEntryOpen,
smartEntryRevealToken,
smartEntrySessionId,
detailAlerts,
toast,
topBarView
}
}
detailAlerts,
toast,
topBarView
}
}

View File

@@ -2,6 +2,7 @@ import { nextTick, ref } from 'vue'
import { useSystemState } from './useSystemState.js'
import { runOrchestrator } from '../services/orchestrator.js'
import { filterVisibleMessageMeta } from '../utils/assistantMessageMeta.js'
const initialMessages = [
{
@@ -73,18 +74,6 @@ export function useChat(activeView) {
function buildOrchestratorMeta(payload) {
const items = []
if (payload?.selected_agent) {
items.push(`Agent: ${payload.selected_agent}`)
}
if (payload?.permission_level) {
items.push(`权限: ${payload.permission_level}`)
}
if (payload?.trace_summary?.tool_count) {
items.push(`工具: ${payload.trace_summary.tool_count}`)
}
if (payload?.trace_summary?.degraded) {
items.push('已降级')
}
@@ -93,11 +82,7 @@ export function useChat(activeView) {
items.push('待确认')
}
if (payload?.run_id) {
items.push(`Run: ${payload.run_id}`)
}
return items
return filterVisibleMessageMeta(items)
}
function buildAssistantMessage(payload, fallbackText) {

View File

@@ -0,0 +1,161 @@
import { ref } from 'vue'
import { createOperationFeedback } from '../services/operationFeedback.js'
const LOW_RATING_MAX = 3
function pickText(...values) {
for (const value of values) {
const normalized = String(value || '').trim()
if (normalized) {
return normalized
}
}
return ''
}
function resolveUserId(currentUser = {}) {
return pickText(
currentUser.username,
currentUser.email,
currentUser.name,
'anonymous'
)
}
export function normalizeOperationFeedbackContext(context = {}, currentUser = {}) {
const traceSummary = context.trace_summary || context.traceSummary || null
const result = context.result && typeof context.result === 'object' ? context.result : {}
const resultSummary = pickText(
context.result_summary,
context.resultSummary,
result.answer,
result.message
)
return {
runId: pickText(context.run_id, context.runId),
conversationId: pickText(context.conversation_id, context.conversationId),
userId: pickText(context.user_id, context.userId, resolveUserId(currentUser)),
agent: pickText(context.selected_agent, context.selectedAgent, context.agent),
source: pickText(context.source, 'user_message'),
sessionType: pickText(context.session_type, context.sessionType),
operationType: pickText(context.operation_type, context.operationType, 'assistant_round'),
operationStatus: pickText(context.operation_status, context.operationStatus, context.status),
routeReason: pickText(context.route_reason, context.routeReason),
entrySource: pickText(context.entry_source, context.entrySource),
traceSummary,
resultSummary: resultSummary.slice(0, 500)
}
}
export function buildOperationFeedbackPayload(context = {}, feedback = {}, currentUser = {}) {
const normalizedContext = normalizeOperationFeedbackContext(context, currentUser)
const rating = Number(feedback.rating || 0)
const reason = String(feedback.reason || '').trim()
return {
run_id: normalizedContext.runId || null,
conversation_id: normalizedContext.conversationId || null,
user_id: normalizedContext.userId || null,
agent: normalizedContext.agent || null,
source: normalizedContext.source || null,
session_type: normalizedContext.sessionType || null,
operation_type: normalizedContext.operationType || 'assistant_round',
operation_status: normalizedContext.operationStatus || null,
rating,
reason: reason || null,
context_json: {
entry_source: normalizedContext.entrySource,
route_reason: normalizedContext.routeReason,
trace_summary: normalizedContext.traceSummary,
result_summary: normalizedContext.resultSummary,
low_rating: rating > 0 && rating <= LOW_RATING_MAX
}
}
}
export function useOperationFeedback({ currentUser, toast } = {}) {
const operationFeedbackDialog = ref({
open: false,
submitting: false,
error: '',
context: null
})
const promptedRunIds = new Set()
function openOperationFeedback(context = {}) {
const normalized = normalizeOperationFeedbackContext(context, currentUser?.value || {})
if (!normalized.runId || promptedRunIds.has(normalized.runId)) {
return
}
if (normalized.operationStatus && normalized.operationStatus !== 'succeeded') {
return
}
promptedRunIds.add(normalized.runId)
globalThis.setTimeout(() => {
operationFeedbackDialog.value = {
open: true,
submitting: false,
error: '',
context: normalized
}
}, 320)
}
function closeOperationFeedback() {
if (operationFeedbackDialog.value.submitting) {
return
}
operationFeedbackDialog.value = {
...operationFeedbackDialog.value,
open: false,
error: ''
}
}
async function submitOperationFeedbackRating(feedback = {}) {
const rating = Number(feedback.rating || 0)
if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
operationFeedbackDialog.value = {
...operationFeedbackDialog.value,
error: '请选择 1 到 5 星评分。'
}
return
}
const context = operationFeedbackDialog.value.context || {}
operationFeedbackDialog.value = {
...operationFeedbackDialog.value,
submitting: true,
error: ''
}
try {
await createOperationFeedback(
buildOperationFeedbackPayload(context, feedback, currentUser?.value || {})
)
operationFeedbackDialog.value = {
open: false,
submitting: false,
error: '',
context: null
}
toast?.('评价已记录,后续会纳入智能体质量统计。')
} catch (error) {
operationFeedbackDialog.value = {
...operationFeedbackDialog.value,
submitting: false,
error: error?.message || '评价提交失败,请稍后重试。'
}
}
}
return {
operationFeedbackDialog,
openOperationFeedback,
closeOperationFeedback,
submitOperationFeedbackRating
}
}

View File

@@ -1,19 +1,53 @@
import { computed, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { fetchFinanceDashboard, fetchSystemDashboard } from '../services/analytics.js'
import { fetchRiskObservationDashboard } from '../services/riskObservations.js'
import {
metricBlueprints,
systemMetricBlueprints,
trendRanges,
trendSeries,
spendByCategory,
exceptionMix,
spendByCategory as fallbackSpendByCategory,
exceptionMix as fallbackExceptionMix,
departmentRangeOptions,
bottlenecks,
budgetSummary
bottlenecks as fallbackBottlenecks,
budgetSummary as fallbackBudgetSummary,
systemDashboardTotals as fallbackSystemDashboardTotals,
systemAgentDailyRatio as fallbackSystemAgentDailyRatio,
systemLoginWave as fallbackSystemLoginWave,
systemTokenDailyWave as fallbackSystemTokenDailyWave,
systemUsageDurationSummary as fallbackSystemUsageDurationSummary,
systemUserTokenUsage as fallbackSystemUserTokenUsage,
systemAccuracyComparison as fallbackSystemAccuracyComparison,
systemTrendSeries,
systemToolCallMix,
systemExecutionMix,
systemToolRankings,
systemModelUsage,
systemFeedbackSummary as fallbackSystemFeedbackSummary,
systemLoadHeatmap,
systemToolDetailRows as fallbackSystemToolDetailRows
} from '../data/metrics.js'
export function useOverviewView() {
export function useOverviewView(options = {}) {
const activeTrendRange = ref(trendRanges[0])
const activeDepartmentRange = ref(departmentRangeOptions[0])
const riskWindowOptions = [
{ label: '近 7 天', value: 7 },
{ label: '近 30 天', value: 30 },
{ label: '近 90 天', value: 90 }
]
const activeRiskWindowDays = ref(30)
const financeDashboardPayload = ref(null)
const financeDashboardLoading = ref(false)
const financeDashboardError = ref(null)
const systemDashboardPayload = ref(null)
const systemDashboardLoading = ref(false)
const systemDashboardError = ref(null)
const riskDashboardPayload = ref(null)
const riskDashboardLoading = ref(false)
const riskDashboardError = ref(null)
const demoTotals = {
pendingCount: 128,
@@ -40,6 +74,15 @@ export function useOverviewView() {
const formatCurrency = (value) => formatCompact(value)
const formatNumberCompact = (value) => {
const number = Number(value || 0)
if (number >= 1_000_000) return `${(number / 1_000_000).toFixed(1)}M`
if (number >= 1_000) return `${(number / 1_000).toFixed(1)}K`
return `${Math.round(number)}`
}
const formatPercent = (value) => `${Math.round(Number(value || 0) * 100)}%`
const formatMetricValue = (metric, value) => {
if (metric.key === 'pendingAmount') return formatCurrency(Math.round(value))
if (metric.key === 'avgSla') return `${value.toFixed(1)} ${metric.unit}`
@@ -48,34 +91,382 @@ export function useOverviewView() {
return `${Math.round(value)}`
}
const formatSystemMetricValue = (metric, value) => {
const numericValue = Number(value || 0)
if (metric.key === 'modelTokens') return formatNumberCompact(numericValue)
if (metric.key === 'avgOnlineMinutes') return `${numericValue.toFixed(1)} ${metric.unit}`
if (metric.key === 'executionSuccessRate') return `${numericValue.toFixed(1)}${metric.unit}`
if (metric.key === 'positiveFeedback') {
const negativeFeedback = Math.round(Number(systemDashboardTotals.value.negativeFeedback || 0))
return `${Math.round(numericValue)} / ${negativeFeedback}`
}
if (metric.unit) return `${formatNumberCompact(numericValue)} ${metric.unit}`
return formatNumberCompact(numericValue)
}
const getFinanceRangeParams = () => {
const activeRange = String(options.activeRange || '近10日')
const customRange = options.customRange || {}
const isCustomRange = activeRange === 'custom'
return {
rangeKey: activeRange,
startDate: isCustomRange ? customRange.start : '',
endDate: isCustomRange ? customRange.end : '',
trendRange: activeTrendRange.value,
departmentRange: activeDepartmentRange.value
}
}
const loadFinanceDashboard = async () => {
financeDashboardLoading.value = true
financeDashboardError.value = null
try {
financeDashboardPayload.value = await fetchFinanceDashboard(getFinanceRangeParams())
} catch (error) {
financeDashboardPayload.value = null
financeDashboardError.value = error
} finally {
financeDashboardLoading.value = false
}
}
const loadSystemDashboard = async () => {
systemDashboardLoading.value = true
systemDashboardError.value = null
try {
systemDashboardPayload.value = await fetchSystemDashboard({ days: 7 })
} catch (error) {
systemDashboardPayload.value = null
systemDashboardError.value = error
} finally {
systemDashboardLoading.value = false
}
}
const loadRiskDashboard = async () => {
riskDashboardLoading.value = true
riskDashboardError.value = null
try {
riskDashboardPayload.value = await fetchRiskObservationDashboard({
windowDays: activeRiskWindowDays.value,
limit: 500
})
} catch (error) {
riskDashboardPayload.value = null
riskDashboardError.value = error
} finally {
riskDashboardLoading.value = false
}
}
const setRiskWindowDays = (value) => {
const days = Number(value || 30)
const matched = riskWindowOptions.some((item) => Number(item.value) === days)
activeRiskWindowDays.value = matched ? days : 30
}
onMounted(() => {
void loadFinanceDashboard()
void loadSystemDashboard()
void loadRiskDashboard()
})
watch(
() => [
options.activeRange,
options.customRange?.start,
options.customRange?.end,
activeTrendRange.value,
activeDepartmentRange.value
],
() => {
void loadFinanceDashboard()
}
)
watch(activeRiskWindowDays, () => {
void loadRiskDashboard()
})
const systemDashboardTotals = computed(() => (
systemDashboardPayload.value?.totals || fallbackSystemDashboardTotals
))
const systemAgentDailyRatio = computed(() => (
systemDashboardPayload.value?.agentDailyRatio || fallbackSystemAgentDailyRatio
))
const systemLoginWave = computed(() => (
systemDashboardPayload.value?.loginWave || fallbackSystemLoginWave
))
const systemTokenDailyWave = computed(() => (
systemDashboardPayload.value?.tokenDailyWave || fallbackSystemTokenDailyWave
))
const systemUsageDurationSummary = computed(() => (
systemDashboardPayload.value?.usageDurationSummary || fallbackSystemUsageDurationSummary
))
const systemUserTokenUsage = computed(() => (
systemDashboardPayload.value?.userTokenUsage || fallbackSystemUserTokenUsage
))
const systemAccuracyComparison = computed(() => (
systemDashboardPayload.value?.accuracyComparison || fallbackSystemAccuracyComparison
))
const systemFeedbackSummary = computed(() => (
systemDashboardPayload.value?.feedbackSummary || fallbackSystemFeedbackSummary
))
const systemToolDetailRows = computed(() => (
systemDashboardPayload.value?.toolDetailRows || fallbackSystemToolDetailRows
))
const riskDashboard = computed(() => (
riskDashboardPayload.value || {
windowDays: activeRiskWindowDays.value,
totalObservations: 0,
pendingCount: 0,
highOrAboveCount: 0,
confirmedCount: 0,
falsePositiveCount: 0,
totalAmount: 0,
averageScore: 0,
confirmationRate: 0,
falsePositiveRate: 0,
candidateRuleCount: 0,
levelDistribution: {},
statusDistribution: {},
signalDistribution: {},
sourceDistribution: {},
automationDistribution: {},
departmentDistribution: {},
expenseTypeDistribution: {},
riskTypeDistribution: {},
supplierDistribution: {},
employeeGradeDistribution: {},
dailyTrend: [],
topRiskSignals: [],
topDepartments: [],
topEmployees: [],
topSuppliers: [],
topExpenseTypes: [],
topRules: [],
recentHighObservations: []
}
))
const financeDashboardTotals = computed(() => (
financeDashboardPayload.value?.totals || demoTotals
))
const financeMetricMeta = computed(() => (
financeDashboardPayload.value?.metricMeta || {}
))
const financeTrend = computed(() => (
financeDashboardPayload.value?.trend || trendSeries[activeTrendRange.value]
))
const financeSpendByCategory = computed(() => (
financeDashboardPayload.value?.spendByCategory || fallbackSpendByCategory
))
const financeExceptionMix = computed(() => (
financeDashboardPayload.value?.exceptionMix || fallbackExceptionMix
))
const financeDepartmentRanking = computed(() => (
financeDashboardPayload.value?.departmentRanking || demoDepartments
))
const financeBottlenecks = computed(() => (
financeDashboardPayload.value?.bottlenecks || fallbackBottlenecks
))
const financeBudgetSummary = computed(() => (
financeDashboardPayload.value?.budgetSummary || fallbackBudgetSummary
))
const resolveSystemMetricMeta = (metric) => {
const totals = systemDashboardTotals.value
const realDashboardLoaded = Boolean(systemDashboardPayload.value)
if (!realDashboardLoaded) {
return {
changeText: metric.change,
delta: metric.delta,
trend: metric.trend
}
}
if (metric.key === 'toolCalls' || metric.key === 'modelTokens') {
const changeValue = Number(totals[`${metric.key}Change`] || 0)
return {
changeText: `${changeValue >= 0 ? '+' : ''}${changeValue.toFixed(1)}%`,
delta: '较上一周期',
trend: changeValue < 0 ? 'down' : 'up'
}
}
if (metric.key === 'executionSuccessRate') {
const errorRate = Math.max(0, 100 - Number(totals.executionSuccessRate || 0))
return {
changeText: '实时',
delta: `错误率 ${errorRate.toFixed(1)}%`,
trend: 'up'
}
}
if (metric.key === 'positiveFeedback') {
return {
changeText: '实时',
delta: `差评 ${Math.round(Number(totals.negativeFeedback || 0))}`,
trend: 'up'
}
}
return {
changeText: '实时',
delta: metric.key === 'onlineUsers' ? '活跃会话' : '按最近会话统计',
trend: metric.trend
}
}
const resolveFinanceMetricMeta = (metric) => {
const meta = financeMetricMeta.value[metric.key]
if (!financeDashboardPayload.value || !meta) {
return {
changeText: metric.change,
delta: metric.delta,
trend: metric.trend
}
}
return {
changeText: meta.changeText || metric.change,
delta: meta.delta || metric.delta,
trend: meta.trend || metric.trend
}
}
const kpiMetrics = computed(() => metricBlueprints.map((metric, index) => {
const rawValue = demoTotals[metric.key]
const rawValue = Number(financeDashboardTotals.value[metric.key] || 0)
const displayValue = formatMetricValue(metric, rawValue)
const metricMeta = resolveFinanceMetricMeta(metric)
return {
...metric,
...metricMeta,
displayValue,
changeText: metric.change,
delay: index * 55
}
}))
const activeTrend = computed(() => trendSeries[activeTrendRange.value])
const spendTotal = computed(() => spendByCategory.reduce((sum, item) => sum + item.value, 0))
const riskTotal = computed(() => exceptionMix.reduce((sum, item) => sum + item.value, 0))
const systemKpiMetrics = computed(() => systemMetricBlueprints.map((metric, index) => {
const rawValue = systemDashboardTotals.value[metric.key]
const displayValue = formatSystemMetricValue(metric, rawValue)
const metricMeta = resolveSystemMetricMeta(metric)
const spendLegend = computed(() => spendByCategory.map((item) => ({
return {
...metric,
...metricMeta,
displayValue,
delay: index * 55
}
}))
const riskKpiMetrics = computed(() => {
const data = riskDashboard.value
const rows = [
{
label: '新增风险数',
value: formatNumberCompact(data.totalObservations),
changeText: `${data.windowDays}`,
delta: '统一观察池',
trend: 'up',
icon: 'mdi mdi-shield-search',
accent: 'var(--theme-primary)'
},
{
label: '高风险待处置',
value: formatNumberCompact(data.highOrAboveCount),
changeText: data.highOrAboveCount > 0 ? '需关注' : '稳定',
delta: '高/重大风险',
trend: data.highOrAboveCount > 0 ? 'down' : 'up',
icon: 'mdi mdi-alert-octagon-outline',
accent: '#ef4444'
},
{
label: '涉及金额',
value: formatCurrency(Number(data.totalAmount || 0)),
changeText: '归集',
delta: '关联单据金额',
trend: 'up',
icon: 'mdi mdi-cash-multiple',
accent: '#0f766e'
},
{
label: '已确认风险',
value: formatNumberCompact(data.confirmedCount),
changeText: formatPercent(data.confirmationRate),
delta: '人工确认',
trend: 'up',
icon: 'mdi mdi-check-decagram-outline',
accent: 'var(--success)'
},
{
label: '误报数量',
value: formatNumberCompact(data.falsePositiveCount),
changeText: formatPercent(data.falsePositiveRate),
delta: '反馈校准',
trend: data.falsePositiveRate > 0.2 ? 'down' : 'up',
icon: 'mdi mdi-tune-variant',
accent: '#8b5cf6'
},
{
label: '待复核',
value: formatNumberCompact(data.pendingCount),
changeText: '待处理',
delta: '人工闭环',
trend: data.pendingCount > 0 ? 'down' : 'up',
icon: 'mdi mdi-account-clock-outline',
accent: '#f59e0b'
}
]
return rows.map((item, index) => ({
...item,
displayValue: item.value,
delay: index * 55
}))
})
const activeTrend = computed(() => financeTrend.value)
const spendTotal = computed(() => financeSpendByCategory.value.reduce((sum, item) => sum + Number(item.value || 0), 0))
const riskTotal = computed(() => financeExceptionMix.value.reduce((sum, item) => sum + Number(item.value || 0), 0))
const spendCenterValue = computed(() => formatCurrency(Math.round(spendTotal.value)))
const spendLegend = computed(() => financeSpendByCategory.value.map((item) => ({
...item,
display: `${Math.round((item.value / spendTotal.value) * 100)}%`
display: spendTotal.value ? `${Math.round((Number(item.value || 0) / spendTotal.value) * 100)}%` : '0%'
})))
const riskLegend = computed(() => exceptionMix.map((item) => ({
const riskLegend = computed(() => financeExceptionMix.value.map((item) => ({
...item,
display: `${item.value}`
})))
const systemToolTotal = computed(() =>
systemToolCallMix.reduce((sum, item) => sum + item.value, 0)
)
const systemExecutionTotal = computed(() =>
systemExecutionMix.reduce((sum, item) => sum + item.value, 0)
)
const systemToolCallLegend = computed(() => systemToolCallMix.map((item) => ({
...item,
display: `${Math.round((item.value / systemToolTotal.value) * 100)}%`
})))
const systemExecutionLegend = computed(() => systemExecutionMix.map((item) => ({
...item,
display: `${Math.round((item.value / systemExecutionTotal.value) * 1000) / 10}%`
})))
const rankedDepartments = computed(() => {
const rows = demoDepartments
const rows = financeDepartmentRanking.value.map((item) => ({
...item,
amount: Number(item.amount || item.value || 0)
}))
const max = Math.max(...rows.map((item) => item.amount), 1)
return rows.slice(0, 5).map((item, index) => ({
@@ -88,25 +479,215 @@ export function useOverviewView() {
}))
})
const systemToolRankingItems = computed(() => systemToolRankings.map((item, index) => ({
...item,
rank: index + 1,
shortName: item.name,
amount: item.value
})))
const systemModelUsageRows = computed(() => systemModelUsage.map((item) => ({
...item,
tokenLabel: `${formatNumberCompact(item.tokens)} tokens`,
width: `${Math.max(Math.min(item.share, 100), 8)}%`
})))
const systemExecutionRows = computed(() => systemExecutionMix.map((item) => ({
...item,
display: `${Math.round((item.value / systemExecutionTotal.value) * 1000) / 10}%`,
width: `${Math.max((item.value / systemExecutionTotal.value) * 100, 1)}%`
})))
const systemToolDetailItems = computed(() => {
const maxCalls = Math.max(...systemToolDetailRows.value.map((item) => item.calls), 1)
return systemToolDetailRows.value.map((item) => ({
...item,
callLabel: `${formatNumberCompact(item.calls)}`,
tokenLabel: `${formatNumberCompact(item.tokens)} tokens`,
width: `${Math.max((item.calls / maxCalls) * 100, 10)}%`
}))
})
const systemUsageDurationRows = computed(() => {
const rows = systemUsageDurationSummary.value.rows || []
const maxValue = Math.max(...rows.map((item) => item.value), 1)
return rows.map((item) => ({
...item,
width: `${Math.max((item.value / maxValue) * 100, 12)}%`
}))
})
const riskLevelLegend = computed(() => buildRiskDistributionLegend(
riskDashboard.value.levelDistribution,
{
critical: '重大风险',
high: '高风险',
medium: '中风险',
low: '低风险'
},
{
critical: '#b91c1c',
high: '#ef4444',
medium: '#f59e0b',
low: '#3b82f6'
}
))
const riskSourceLegend = computed(() => buildRiskDistributionLegend(
riskDashboard.value.sourceDistribution,
{
financial_risk_graph: '风险图谱',
rule_center: '规则中心',
unknown: '未知来源'
},
{
financial_risk_graph: 'var(--theme-primary)',
rule_center: '#0f766e',
unknown: '#94a3b8'
}
))
const riskSignalRanking = computed(() => {
const rows = Array.isArray(riskDashboard.value.topRiskSignals)
? riskDashboard.value.topRiskSignals
: []
const fallbackRows = Object.entries(riskDashboard.value.signalDistribution || {})
.map(([name, count]) => ({ name, count }))
return (rows.length ? rows : fallbackRows)
.slice(0, 6)
.map((item, index) => ({
name: formatRiskSignalName(item.name),
shortName: formatRiskSignalName(item.name),
value: Number(item.count || 0),
color: [
'#ef4444',
'#f59e0b',
'var(--theme-primary)',
'#3b82f6',
'#8b5cf6',
'#0f766e'
][index] || '#64748b'
}))
})
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 maxValue = Math.max(...normalizedRows.map((item) => item.total), 1)
return normalizedRows.map((item) => ({
...item,
width: `${Math.max((item.total / maxValue) * 100, 4)}%`,
highWidth: `${Math.max((item.highOrAbove / maxValue) * 100, item.highOrAbove ? 4 : 0)}%`
}))
})
function buildRiskDistributionLegend(distribution, labels, colors) {
const entries = Object.entries(distribution || {})
.filter(([, value]) => Number(value || 0) > 0)
if (!entries.length) {
return [
{
name: '暂无数据',
value: 1,
display: '0项',
color: '#cbd5e1'
}
]
}
return entries.map(([key, value]) => ({
name: labels[key] || formatRiskSignalName(key),
value: Number(value || 0),
display: `${Number(value || 0)}`,
color: colors[key] || 'var(--theme-primary)'
}))
}
function formatRiskSignalName(value) {
const text = String(value || '').trim()
const labels = {
duplicate_invoice: '重复发票',
split_billing: '拆分报销',
frequent_small_claims: '高频小额',
location_mismatch: '地点不一致',
amount_outlier: '金额异常',
preapproval_absent: '缺少事前申请'
}
return labels[text] || text.replace(/_/g, ' ') || '未知风险'
}
const bottlenecks = financeBottlenecks
const budgetSummary = financeBudgetSummary
const spendByCategory = financeSpendByCategory
const exceptionMix = financeExceptionMix
return {
activeDepartmentRange,
activeRiskWindowDays,
activeTrend,
activeTrendRange,
bottlenecks,
budgetSummary,
departmentRangeOptions,
exceptionMix,
financeDashboardError,
financeDashboardLoading,
formatCompact,
formatCurrency,
formatMetricValue,
formatNumberCompact,
formatSystemMetricValue,
kpiMetrics,
metricBlueprints,
rankedDepartments,
riskDashboard,
riskDashboardError,
riskDashboardLoading,
riskDailyTrendRows,
riskLegend,
riskKpiMetrics,
riskLevelLegend,
riskSignalRanking,
riskSourceLegend,
riskTotal,
riskWindowOptions,
setRiskWindowDays,
spendByCategory,
spendCenterValue,
spendLegend,
spendTotal,
systemDashboardTotals,
systemDashboardError,
systemDashboardLoading,
systemAgentDailyRatio,
systemLoginWave,
systemTokenDailyWave,
systemUsageDurationRows,
systemUsageDurationSummary,
systemUserTokenUsage,
systemAccuracyComparison,
systemExecutionLegend,
systemExecutionMix,
systemExecutionTotal,
systemFeedbackSummary,
systemKpiMetrics,
systemLoadHeatmap,
systemExecutionRows,
systemMetricBlueprints,
systemModelUsageRows,
systemToolDetailItems,
systemToolCallLegend,
systemToolCallMix,
systemToolRankingItems,
systemToolRankings,
systemToolTotal,
systemTrendSeries,
trendRanges,
trendSeries
}

View File

@@ -44,15 +44,18 @@ const ROUTE_DESCRIPTION_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket'
const HOTEL_DESCRIPTION_EXPENSE_TYPES = new Set(['hotel_ticket'])
const DOCUMENT_TYPE_APPLICATION = 'application'
const DOCUMENT_TYPE_REIMBURSEMENT = 'reimbursement'
const RELATED_APPLICATION_STEP_LABEL = '关联单据'
const ARCHIVED_STEP_LABEL = '已归档'
const REIMBURSEMENT_PROGRESS_LABELS = [
'创建单据',
RELATED_APPLICATION_STEP_LABEL,
'待提交',
'AI预审',
'直属领导审批',
'财务审批',
'待付款',
'已付款'
'已付款',
ARCHIVED_STEP_LABEL
]
const APPLICATION_PROGRESS_LABELS = [
@@ -366,7 +369,7 @@ function resolveProgressCurrentIndex(approvalMeta, workflowNode) {
const normalizedNode = String(workflowNode || '').trim()
if (approvalMeta.key === 'completed') {
return 6
return 7
}
if (approvalMeta.key === 'pending_payment') {
@@ -380,7 +383,7 @@ function resolveProgressCurrentIndex(approvalMeta, workflowNode) {
return 5
}
if (normalizedNode.includes('归档') || normalizedNode.includes('入账')) {
return 6
return 7
}
if (normalizedNode.includes('财务')) {
return 4
@@ -589,6 +592,116 @@ function findLatestPaymentEvent(claim) {
)
}
function findApplicationHandoffEvent(claim) {
const handoffEvents = getRiskFlags(claim).filter((flag) => (
flag
&& typeof flag === 'object'
&& normalizeText(flag.source) === 'application_handoff'
&& normalizeText(flag.application_claim_no || flag.applicationClaimNo)
))
return getLatestEvent(handoffEvents) || handoffEvents[handoffEvents.length - 1] || null
}
function normalizeApplicationHandoffDetail(flag = {}) {
const detail = flag?.application_detail || flag?.applicationDetail || {}
return detail && typeof detail === 'object' ? detail : {}
}
function resolveRelatedApplicationAmountLabel(flag, detail, claim) {
const explicitLabel = normalizeText(
flag?.application_amount_label
|| flag?.applicationAmountLabel
|| detail?.application_amount_label
|| detail?.applicationAmountLabel
)
if (explicitLabel) return explicitLabel
const rawAmount = normalizeText(
flag?.application_amount
|| flag?.applicationAmount
|| flag?.application_budget_amount
|| flag?.applicationBudgetAmount
|| detail?.application_amount
|| detail?.applicationAmount
|| detail?.amount
|| claim?.amount
)
const amountValue = parseNumber(rawAmount)
return amountValue > 0 ? formatAmount(amountValue) : rawAmount
}
function resolveRelatedApplicationInfo(claim, typeLabel = '') {
const handoff = findApplicationHandoffEvent(claim)
if (!handoff) {
return null
}
const detail = normalizeApplicationHandoffDetail(handoff)
const claimNo = normalizeText(handoff.application_claim_no || handoff.applicationClaimNo)
const applicationType = normalizeText(
detail.application_type
|| detail.applicationType
|| handoff.application_type
|| handoff.applicationType
|| typeLabel
)
const location = normalizeText(
detail.application_location
|| detail.applicationLocation
|| detail.location
|| handoff.application_location
|| handoff.applicationLocation
|| claim?.location
)
const reason = normalizeText(
detail.application_reason
|| detail.applicationReason
|| detail.reason
|| handoff.application_reason
|| handoff.applicationReason
|| claim?.reason
)
const content = normalizeText(
detail.application_content
|| detail.applicationContent
|| handoff.application_content
|| handoff.applicationContent
) || [applicationType, location].filter(Boolean).join(' / ')
const rawTime = normalizeText(
detail.application_time
|| detail.applicationTime
|| detail.time
|| handoff.application_time
|| handoff.applicationTime
|| claim?.occurred_at
)
return {
id: normalizeText(handoff.application_claim_id || handoff.applicationClaimId),
claimNo,
content,
reason,
days: normalizeText(
detail.application_days
|| detail.applicationDays
|| detail.days
|| handoff.application_days
|| handoff.applicationDays
),
location,
time: formatDate(rawTime) || rawTime,
amountLabel: resolveRelatedApplicationAmountLabel(handoff, detail, claim),
statusLabel: normalizeText(handoff.application_status_label || handoff.applicationStatusLabel),
transportMode: normalizeText(
detail.application_transport_mode
|| detail.applicationTransportMode
|| detail.transport_mode
|| handoff.application_transport_mode
|| handoff.applicationTransportMode
)
}
}
function findLatestApplicationReturnEvent(claim) {
return getLatestEvent(
getRiskFlags(claim).filter((flag) => {
@@ -608,6 +721,28 @@ function findLatestApplicationReturnEvent(claim) {
)
}
function findMergedApplicationBudgetApprovalEvent(claim) {
return getLatestEvent(
getRiskFlags(claim).filter((flag) => {
if (!flag || typeof flag !== 'object') {
return false
}
const source = normalizeText(flag.source)
const eventType = normalizeText(flag.event_type || flag.eventType)
const previousStage = normalizeText(flag.previous_approval_stage || flag.previousApprovalStage)
const nextStage = normalizeText(flag.next_approval_stage || flag.nextApprovalStage)
const mergedFlag = Boolean(flag.budget_approval_merged || flag.budgetApprovalMerged)
return (
source === 'manual_approval'
&& eventType === 'expense_application_approval'
&& previousStage.includes('直属领导')
&& nextStage.includes('审批完成')
&& mergedFlag
)
})
)
}
function buildProgressStepMeta(time, detail = '', title = '') {
return {
time,
@@ -620,6 +755,15 @@ function buildCompletedStepMeta(claim, label) {
const stepLabel = normalizeText(label)
const employeeName = normalizeText(claim?.employee_name) || '申请人'
if (stepLabel === RELATED_APPLICATION_STEP_LABEL) {
const relatedApplication = resolveRelatedApplicationInfo(claim)
const createdAt = formatDateTime(claim?.created_at)
if (relatedApplication?.claimNo) {
return buildProgressStepMeta(`已关联 ${relatedApplication.claimNo}`, createdAt)
}
return buildProgressStepMeta('待核对关联单据', createdAt)
}
if (stepLabel === '创建单据' || stepLabel === '创建申请') {
const createdAt = formatDateTime(claim?.created_at)
return buildProgressStepMeta(stepLabel === '创建申请' ? `${employeeName}发起申请` : `${employeeName}创建`, createdAt)
@@ -694,6 +838,11 @@ function buildCompletedStepMeta(claim, label) {
return buildProgressStepMeta('归档入账', archivedAt)
}
if (stepLabel === ARCHIVED_STEP_LABEL) {
const archivedAt = formatDateTime(claim?.updated_at)
return buildProgressStepMeta(ARCHIVED_STEP_LABEL, archivedAt)
}
if (stepLabel === '审批完成') {
const completedAt = formatDateTime(claim?.updated_at)
return buildProgressStepMeta('审批完成', completedAt)
@@ -704,7 +853,7 @@ function buildCompletedStepMeta(claim, label) {
function resolveCurrentStepStartedAt(claim, label) {
const stepLabel = normalizeText(label)
if (stepLabel === '创建单据' || stepLabel === '创建申请') {
if (stepLabel === RELATED_APPLICATION_STEP_LABEL || stepLabel === '创建单据' || stepLabel === '创建申请') {
return claim?.created_at
}
if (stepLabel === '待提交') {
@@ -733,7 +882,7 @@ function resolveCurrentStepStartedAt(claim, label) {
const paymentEvent = findLatestPaymentEvent(claim)
return paymentEvent?.created_at || paymentEvent?.createdAt || claim?.updated_at || claim?.submitted_at
}
if (stepLabel === '归档入账' || stepLabel === '审批完成') {
if (stepLabel === '归档入账' || stepLabel === ARCHIVED_STEP_LABEL || stepLabel === '审批完成') {
return claim?.updated_at || claim?.submitted_at
}
return ''
@@ -746,17 +895,26 @@ function buildProgressSteps(approvalMeta, workflowNode, claim = {}, options = {}
&& Boolean(findLatestApplicationReturnEvent(claim))
&& approvalMeta.key === 'supplement'
)
const hasMergedApplicationBudgetApproval = (
documentTypeCode === DOCUMENT_TYPE_APPLICATION
&& Boolean(findMergedApplicationBudgetApprovalEvent(claim))
)
const progressLabels =
documentTypeCode === DOCUMENT_TYPE_APPLICATION
? hasApplicationReturnStep
? ['创建申请', '直属领导审批', '退回', '待提交']
: APPLICATION_PROGRESS_LABELS
: hasMergedApplicationBudgetApproval
? ['创建申请', '直属领导审批', '审批完成']
: APPLICATION_PROGRESS_LABELS
: REIMBURSEMENT_PROGRESS_LABELS
const currentIndex =
documentTypeCode === DOCUMENT_TYPE_APPLICATION
? hasApplicationReturnStep
? 3
: resolveApplicationProgressCurrentIndex(approvalMeta, workflowNode)
: Math.min(
resolveApplicationProgressCurrentIndex(approvalMeta, workflowNode),
Math.max(0, progressLabels.length - 1)
)
: resolveProgressCurrentIndex(approvalMeta, workflowNode)
const currentTime =
approvalMeta.key === 'completed'
@@ -902,6 +1060,7 @@ export function mapExpenseClaimToRequest(claim) {
const riskSummary = buildRiskSummary(claim?.risk_flags_json)
const expenseItems = buildExpenseItems(claim, riskSummary)
const applyDateTime = claim?.submitted_at || claim?.created_at
const relatedApplication = isApplicationDocument ? null : resolveRelatedApplicationInfo(claim, typeLabel)
return {
id: String(claim?.claim_no || claim?.id || '').trim(),
@@ -958,6 +1117,7 @@ export function mapExpenseClaimToRequest(claim) {
: `${expenseItems.length} 条费用明细,待补充票据`)
: '暂无费用明细',
note: String(claim?.reason || '').trim(),
relatedApplication,
progressSteps: buildProgressSteps(approvalMeta, workflowNode, claim, {
documentTypeCode: documentTypeMeta.documentTypeCode
}),

View File

@@ -8,18 +8,24 @@ import {
testBootstrapDatabase,
testBootstrapRuntime
} from '../services/bootstrap.js'
import { login as loginByAccount } from '../services/auth.js'
import { login as loginByAccount } from '../services/auth.js'
import { setRuntimeApiBaseUrl } from '../services/api.js'
import { checkBackendHealth } from './useBackendHealth.js'
import { resolveDefaultAuthorizedRoute } from '../utils/accessControl.js'
import { useToast } from './useToast.js'
import { fetchSettings } from '../services/settings.js'
import { setThemeSkin } from './useThemeSkin.js'
import { fetchSettings } from '../services/settings.js'
import { setThemeSkin } from './useThemeSkin.js'
import {
clearAuthSessionMetrics,
finalizeAuthSession,
incrementAuthActivityCount,
persistAuthSessionMetrics
} from '../utils/authSessionMetrics.js'
const AUTH_STORAGE_KEY = 'x-financial-authenticated'
const AUTH_USERNAME_KEY = 'x-financial-auth-username'
const AUTH_USER_KEY = 'x-financial-auth-user'
const AUTH_LAST_ACTIVITY_KEY = 'x-financial-auth-last-activity'
const AUTH_USERNAME_KEY = 'x-financial-auth-username'
const AUTH_USER_KEY = 'x-financial-auth-user'
const AUTH_LAST_ACTIVITY_KEY = 'x-financial-auth-last-activity'
const DEFAULT_USER_NAME = '系统管理员'
const DEFAULT_USER_ROLE = '管理员'
const SESSION_ACTIVITY_EVENTS = ['pointerdown', 'keydown', 'scroll', 'touchstart', 'visibilitychange']
@@ -193,13 +199,13 @@ function readStoredUser() {
return legacyUsername ? buildLegacyAdminUser(legacyUsername) : buildAnonymousUser()
}
function readLastActivityAt() {
if (typeof window === 'undefined') {
return 0
}
return Number(window.sessionStorage.getItem(AUTH_LAST_ACTIVITY_KEY) || 0)
}
function readLastActivityAt() {
if (typeof window === 'undefined') {
return 0
}
return Number(window.sessionStorage.getItem(AUTH_LAST_ACTIVITY_KEY) || 0)
}
function isSessionExpired(now = Date.now()) {
if (!readAuthState()) {
@@ -215,24 +221,26 @@ function isSessionExpired(now = Date.now()) {
return now - lastActivityAt > authIdleTimeoutMs
}
function persistAuthState(value, user = null) {
if (typeof window === 'undefined') {
return
}
function persistAuthState(value, user = null, sessionId = '') {
if (typeof window === 'undefined') {
return
}
if (value) {
window.sessionStorage.setItem(AUTH_STORAGE_KEY, 'true')
const normalizedUser = user || buildAnonymousUser()
window.sessionStorage.setItem(AUTH_USERNAME_KEY, String(normalizedUser.username || '').trim())
window.sessionStorage.setItem(AUTH_USER_KEY, JSON.stringify(normalizedUser))
return
}
const normalizedUser = user || buildAnonymousUser()
window.sessionStorage.setItem(AUTH_USERNAME_KEY, String(normalizedUser.username || '').trim())
window.sessionStorage.setItem(AUTH_USER_KEY, JSON.stringify(normalizedUser))
persistAuthSessionMetrics(sessionId)
return
}
window.sessionStorage.removeItem(AUTH_STORAGE_KEY)
window.sessionStorage.removeItem(AUTH_USERNAME_KEY)
window.sessionStorage.removeItem(AUTH_USER_KEY)
window.sessionStorage.removeItem(AUTH_LAST_ACTIVITY_KEY)
}
window.sessionStorage.removeItem(AUTH_USERNAME_KEY)
window.sessionStorage.removeItem(AUTH_USER_KEY)
window.sessionStorage.removeItem(AUTH_LAST_ACTIVITY_KEY)
clearAuthSessionMetrics()
}
function clearSessionTimeout() {
if (typeof window === 'undefined' || !sessionTimeoutHandle) {
@@ -294,19 +302,27 @@ function touchAuthActivity(force = false) {
scheduleSessionTimeout()
return
}
window.sessionStorage.setItem(AUTH_LAST_ACTIVITY_KEY, String(now))
incrementAuthActivityCount()
lastActivityWriteAt = now
scheduleSessionTimeout()
}
window.sessionStorage.setItem(AUTH_LAST_ACTIVITY_KEY, String(now))
lastActivityWriteAt = now
scheduleSessionTimeout()
}
function handleSessionActivity(event) {
if (typeof document !== 'undefined' && event?.type === 'visibilitychange' && document.visibilityState !== 'visible') {
return
}
touchAuthActivity()
}
function handleSessionActivity(event) {
if (typeof document !== 'undefined' && event?.type === 'visibilitychange' && document.visibilityState !== 'visible') {
return
}
touchAuthActivity()
}
function handleSessionUnload(event) {
if (event?.type === 'pagehide' && event.persisted) {
return
}
finalizeAuthSession('pagehide', { unload: true })
}
function installSessionMonitoring() {
if (sessionMonitoringInstalled || typeof window === 'undefined') {
@@ -314,10 +330,12 @@ function installSessionMonitoring() {
}
sessionMonitoringInstalled = true
SESSION_ACTIVITY_EVENTS.forEach((eventName) => {
window.addEventListener(eventName, handleSessionActivity, { passive: true })
})
}
SESSION_ACTIVITY_EVENTS.forEach((eventName) => {
window.addEventListener(eventName, handleSessionActivity, { passive: true })
})
window.addEventListener('pagehide', handleSessionUnload, { passive: true })
window.addEventListener('beforeunload', handleSessionUnload, { passive: true })
}
function syncAuthSession(options = {}) {
const shouldNotify = Boolean(options.notify)
@@ -641,11 +659,11 @@ async function handleLogin(credentials) {
...responseUser,
roleCodes: responseRoleCodes,
isAdmin: resolvePlatformAdminFlag(responseUser, responseRoleCodes)
}
loggedIn.value = true
persistAuthState(true, user)
currentUser.value = user
touchAuthActivity(true)
}
loggedIn.value = true
persistAuthState(true, user, response?.sessionId || '')
currentUser.value = user
touchAuthActivity(true)
return true
} catch (error) {
logout('invalid', { redirect: false })
@@ -658,12 +676,13 @@ async function handleLogin(credentials) {
}
function logout(reason = 'manual', options = {}) {
const notify = options.notify ?? reason === 'timeout'
const redirect = options.redirect ?? reason !== 'invalid'
loggedIn.value = false
persistAuthState(false)
currentUser.value = buildAnonymousUser()
const notify = options.notify ?? reason === 'timeout'
const redirect = options.redirect ?? reason !== 'invalid'
finalizeAuthSession(reason)
loggedIn.value = false
persistAuthState(false)
currentUser.value = buildAnonymousUser()
clearSessionTimeout()
if (notify) {

View File

@@ -0,0 +1,144 @@
import { computed, ref } from 'vue'
import {
buildWorkbenchDateLabel,
canApplyWorkbenchDateSelection,
getTodayDateValue,
mergeWorkbenchDateLabelIntoDraft,
stripWorkbenchDateLabelFromDraft
} from '../utils/workbenchComposerDate.js'
export function useWorkbenchComposerDate({ draft, focusInput } = {}) {
const workbenchDatePickerOpen = ref(false)
const workbenchDateMode = ref('single')
const workbenchSingleDate = ref(getTodayDateValue())
const workbenchRangeStartDate = ref(getTodayDateValue())
const workbenchRangeEndDate = ref(getTodayDateValue())
const workbenchDateTagLabel = ref('')
const workbenchCanApplyDateSelection = computed(() =>
canApplyWorkbenchDateSelection({
mode: workbenchDateMode.value,
singleDate: workbenchSingleDate.value,
rangeStartDate: workbenchRangeStartDate.value,
rangeEndDate: workbenchRangeEndDate.value
})
)
function clearWorkbenchDateSelection() {
const today = getTodayDateValue()
workbenchDatePickerOpen.value = false
workbenchDateMode.value = 'single'
workbenchSingleDate.value = today
workbenchRangeStartDate.value = today
workbenchRangeEndDate.value = today
workbenchDateTagLabel.value = ''
}
function ensureWorkbenchDateDefaults() {
const today = getTodayDateValue()
workbenchSingleDate.value ||= today
workbenchRangeStartDate.value ||= workbenchSingleDate.value || today
workbenchRangeEndDate.value ||= workbenchRangeStartDate.value || today
}
function toggleWorkbenchDatePicker() {
if (workbenchDatePickerOpen.value) {
workbenchDatePickerOpen.value = false
return
}
ensureWorkbenchDateDefaults()
workbenchDatePickerOpen.value = true
}
function closeWorkbenchDatePicker() {
workbenchDatePickerOpen.value = false
}
function setWorkbenchDateMode(mode) {
const today = getTodayDateValue()
const nextMode = mode === 'range' ? 'range' : 'single'
if (nextMode === 'range') {
const baseDate = workbenchSingleDate.value || today
workbenchRangeStartDate.value ||= baseDate
workbenchRangeEndDate.value ||= workbenchRangeStartDate.value
} else {
workbenchSingleDate.value ||= workbenchRangeStartDate.value || today
}
workbenchDateMode.value = nextMode
}
function handleWorkbenchDatePickerOutside(event) {
if (!workbenchDatePickerOpen.value) {
return
}
if (event.target instanceof Element && event.target.closest('.workbench-date-anchor')) {
return
}
workbenchDatePickerOpen.value = false
}
function applyWorkbenchDateSelection() {
const label = buildWorkbenchDateLabel({
mode: workbenchDateMode.value,
singleDate: workbenchSingleDate.value,
rangeStartDate: workbenchRangeStartDate.value,
rangeEndDate: workbenchRangeEndDate.value
})
if (!label) {
return
}
workbenchDateTagLabel.value = label
draft.value = stripWorkbenchDateLabelFromDraft(draft.value)
workbenchDatePickerOpen.value = false
focusInput?.()
}
function handleWorkbenchDateInputChange(part = 'single') {
if (workbenchDateMode.value !== 'range' || part === 'single') {
applyWorkbenchDateSelection()
return
}
if (part === 'range-start') {
if (!workbenchRangeEndDate.value || workbenchRangeEndDate.value < workbenchRangeStartDate.value) {
workbenchRangeEndDate.value = workbenchRangeStartDate.value
}
return
}
applyWorkbenchDateSelection()
}
function removeWorkbenchDateTag() {
workbenchDateTagLabel.value = ''
focusInput?.()
}
function buildWorkbenchPromptText(rawText = draft.value) {
return mergeWorkbenchDateLabelIntoDraft(rawText, workbenchDateTagLabel.value)
}
return {
workbenchDatePickerOpen,
workbenchDateMode,
workbenchSingleDate,
workbenchRangeStartDate,
workbenchRangeEndDate,
workbenchDateTagLabel,
workbenchCanApplyDateSelection,
clearWorkbenchDateSelection,
toggleWorkbenchDatePicker,
closeWorkbenchDatePicker,
setWorkbenchDateMode,
handleWorkbenchDatePickerOutside,
applyWorkbenchDateSelection,
handleWorkbenchDateInputChange,
removeWorkbenchDateTag,
buildWorkbenchPromptText
}
}