费用进度
-
@@ -248,7 +247,9 @@
+
+
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import PanelHead from '../shared/PanelHead.vue'
+import ExpenseStatsDetailModal from './ExpenseStatsDetailModal.vue'
import ExpenseProfileDetailModal from './ExpenseProfileDetailModal.vue'
import workbenchHeroBackground from '../../assets/personal-workbench-hero-bg-theme-base.webp'
import { useSystemState } from '../../composables/useSystemState.js'
@@ -392,6 +402,7 @@ const {
})
const latestExpenseConversation = ref(null)
const hasLocalExpenseSnapshot = ref(false)
+const expenseStatsModalOpen = ref(false)
const expenseProfileModalOpen = ref(false)
const employeeProfile = ref(null)
const employeeProfileRuns = ref([])
@@ -451,6 +462,7 @@ const expenseProfileOperations = computed(() =>
buildProfileOperationsFromAgentRuns(employeeProfileRuns.value, currentUser.value)
)
const expenseProfileEmptyReason = computed(() => String(employeeProfile.value?.empty_reason || '').trim())
+const expenseStatsDetail = computed(() => props.workbenchSummary.expenseStatsDetail || {})
const currentUserProfileKey = computed(() => {
const user = currentUser.value || {}
return [
@@ -700,6 +712,14 @@ async function loadCurrentEmployeeProfile() {
employeeProfileLoading.value = false
}
+function openExpenseStatsModal() {
+ expenseStatsModalOpen.value = true
+}
+
+function closeExpenseStatsModal() {
+ expenseStatsModalOpen.value = false
+}
+
function openExpenseProfileModal() {
expenseProfileModalOpen.value = true
if (!employeeProfile.value && !employeeProfileLoading.value) {
diff --git a/web/src/utils/workbenchSummary.js b/web/src/utils/workbenchSummary.js
index 74c3837..4777731 100644
--- a/web/src/utils/workbenchSummary.js
+++ b/web/src/utils/workbenchSummary.js
@@ -64,6 +64,61 @@ function formatCurrency(value) {
}).format(parseNumber(value))
}
+function formatPercent(value) {
+ const percent = Number(value)
+ if (!Number.isFinite(percent)) {
+ return '0%'
+ }
+
+ return `${percent.toFixed(percent >= 10 || percent === 0 ? 0 : 1)}%`
+}
+
+function padDatePart(value) {
+ return String(value).padStart(2, '0')
+}
+
+function formatDateTimeLabel(value) {
+ if (value instanceof Date) {
+ return [
+ value.getFullYear(),
+ padDatePart(value.getMonth() + 1),
+ padDatePart(value.getDate())
+ ].join('-') + ` ${padDatePart(value.getHours())}:${padDatePart(value.getMinutes())}`
+ }
+
+ const text = normalizeText(value)
+ if (!text) {
+ return '暂无时间'
+ }
+
+ const match = /^(\d{4})-(\d{2})-(\d{2})(?:[T\s](\d{2}):(\d{2}))?/.exec(text)
+ if (match) {
+ return match[4] ? `${match[1]}-${match[2]}-${match[3]} ${match[4]}:${match[5]}` : `${match[1]}-${match[2]}-${match[3]}`
+ }
+
+ return text
+}
+
+function formatDurationLabel(milliseconds) {
+ const duration = Number(milliseconds)
+ if (!Number.isFinite(duration) || duration <= 0) {
+ return '暂无耗时'
+ }
+
+ const minute = 60 * 1000
+ const hour = 60 * minute
+ const day = 24 * hour
+
+ if (duration < hour) {
+ return `${Math.max(1, Math.round(duration / minute))}分钟`
+ }
+ if (duration < day) {
+ return `${Math.max(1, Math.round(duration / hour))}小时`
+ }
+
+ return `${Math.ceil(duration / day)}天`
+}
+
function resolveRequestIdentity(request) {
return normalizeText(request?.claimNo || request?.claim_no || request?.id || request?.claimId)
}
@@ -232,6 +287,177 @@ function buildNotifications(todoItems, progressItems) {
return [...todoNotifications, ...progressNotifications]
}
+function resolveExpenseCategory(request) {
+ const explicitCategory = normalizeText(
+ request?.expenseCategory
+ || request?.expense_category
+ || request?.category
+ || request?.expenseType
+ || request?.expense_type
+ || request?.type
+ )
+ if (explicitCategory) {
+ return explicitCategory
+ }
+
+ const text = normalizeText([
+ request?.sceneLabel,
+ request?.title,
+ request?.note,
+ request?.description
+ ].filter(Boolean).join(' '))
+
+ if (/差旅|出差|交通|机票|火车|高铁|出租|网约车|住宿|酒店/i.test(text)) {
+ return '差旅交通'
+ }
+ if (/招待|客户|餐饮|宴请|接待/i.test(text)) {
+ return '业务招待'
+ }
+ if (/办公|采购|用品|设备|耗材/i.test(text)) {
+ return '办公采购'
+ }
+ if (/培训|学习|课程|会议/i.test(text)) {
+ return '培训会议'
+ }
+ if (/市场|活动|推广|宣传/i.test(text)) {
+ return '市场活动'
+ }
+
+ return '其他费用'
+}
+
+function buildExpenseDistributionRows(ownedRequests) {
+ const groups = new Map()
+
+ for (const request of ownedRequests) {
+ const category = resolveExpenseCategory(request)
+ const amount = parseNumber(request?.amount)
+ const group = groups.get(category) || {
+ key: category,
+ label: category,
+ count: 0,
+ amount: 0
+ }
+
+ group.count += 1
+ group.amount += amount
+ groups.set(category, group)
+ }
+
+ const totalAmount = Array.from(groups.values()).reduce((sum, item) => sum + item.amount, 0)
+ const rows = Array.from(groups.values())
+ .sort((left, right) => right.amount - left.amount || right.count - left.count)
+ .slice(0, 6)
+
+ return rows.map((item) => {
+ const percent = totalAmount > 0 ? (item.amount / totalAmount) * 100 : 0
+ return {
+ ...item,
+ amountLabel: formatCurrency(item.amount),
+ percent,
+ percentLabel: formatPercent(percent)
+ }
+ })
+}
+
+function collectRequestDates(request) {
+ const candidates = [
+ request?.createdAt,
+ request?.submittedAt,
+ request?.updatedAt,
+ request?.applyTime,
+ request?.occurredAt
+ ]
+
+ const steps = Array.isArray(request?.progressSteps) ? request.progressSteps : []
+ for (const step of steps) {
+ candidates.push(step?.time, step?.createdAt, step?.updatedAt, step?.timestamp)
+ }
+
+ return candidates
+ .map((item) => toDate(item))
+ .filter(Boolean)
+ .sort((left, right) => left.getTime() - right.getTime())
+}
+
+function buildExpenseProcessingRows(ownedRequests) {
+ return ownedRequests
+ .map((request) => {
+ const dates = collectRequestDates(request)
+ const requestId = resolveRequestIdentity(request)
+ const title = normalizeText(request?.title || request?.note || request?.sceneLabel) || '费用单据'
+ const startedAt = dates[0] || toDate(request?.createdAt || request?.submittedAt)
+ const latestAt = dates[dates.length - 1] || toDate(request?.updatedAt || request?.submittedAt || request?.createdAt)
+ const stepCount = Array.isArray(request?.progressSteps) ? request.progressSteps.length : 0
+
+ return {
+ id: requestId || title,
+ requestId,
+ title,
+ status: normalizeText(request?.approvalStatus || request?.status) || '处理中',
+ statusTone: resolveProgressStatusTone(normalizeText(request?.approvalKey)),
+ startedAt: startedAt ? formatDateTimeLabel(startedAt) : '暂无开始时间',
+ updatedAt: latestAt ? formatDateTimeLabel(latestAt) : '暂无更新时间',
+ durationLabel: startedAt && latestAt ? formatDurationLabel(latestAt.getTime() - startedAt.getTime()) : '暂无耗时',
+ stepCount,
+ sortTime: latestAt ? latestAt.getTime() : 0
+ }
+ })
+ .sort((left, right) => right.sortTime - left.sortTime)
+ .slice(0, 6)
+ .map(({ sortTime, ...item }) => item)
+}
+
+function buildExpenseOperationRows(todoItems, notifications, progressItems) {
+ const rows = []
+
+ for (const item of todoItems) {
+ rows.push({
+ id: `todo:${item.requestId || item.description}`,
+ action: item.status || item.title,
+ detail: item.description,
+ time: item.due,
+ tone: item.statusTone || 'info',
+ source: '待办'
+ })
+ }
+
+ for (const item of notifications) {
+ rows.push({
+ id: `notice:${item.id}`,
+ action: item.title,
+ detail: item.description,
+ time: item.time,
+ tone: item.tone || 'info',
+ source: item.unread ? '未读提醒' : '系统提醒'
+ })
+ }
+
+ for (const item of progressItems.slice(0, 6)) {
+ rows.push({
+ id: `progress:${item.requestId || item.id}`,
+ action: item.status,
+ detail: `${item.requestId || item.id || '单据'} · ${item.title}`,
+ time: item.updatedAt || '最近更新',
+ tone: item.statusTone || 'info',
+ source: '进度'
+ })
+ }
+
+ const seen = new Set()
+ return rows
+ .filter((item) => {
+ const key = [item.action, item.detail, item.time].join('|')
+ if (seen.has(key)) {
+ return false
+ }
+ seen.add(key)
+ return true
+ })
+ .sort((left, right) => normalizeText(right.time).localeCompare(normalizeText(left.time)))
+ .slice(0, 8)
+}
+
export function buildWorkbenchSummary(requests, currentUser) {
const ownedRequests = Array.isArray(requests)
? requests.filter((item) => belongsToCurrentUser(item, currentUser))
@@ -254,6 +480,11 @@ export function buildWorkbenchSummary(requests, currentUser) {
const todoItems = buildTodoItems(ownedRequests)
const progressItems = buildProgressItems(ownedRequests)
const notifications = buildNotifications(todoItems, progressItems)
+ const expenseStatsDetail = {
+ distributionRows: buildExpenseDistributionRows(ownedRequests),
+ processingRows: buildExpenseProcessingRows(ownedRequests),
+ operationRows: buildExpenseOperationRows(todoItems, notifications, progressItems)
+ }
return {
monthlyCount,
@@ -270,6 +501,7 @@ export function buildWorkbenchSummary(requests, currentUser) {
todoItems,
progressItems,
notifications,
+ expenseStatsDetail,
unreadNotificationCount: notifications.filter((item) => item.unread).length
}
}
diff --git a/web/tests/expense-stats-detail-modal.test.mjs b/web/tests/expense-stats-detail-modal.test.mjs
new file mode 100644
index 0000000..f06978e
--- /dev/null
+++ b/web/tests/expense-stats-detail-modal.test.mjs
@@ -0,0 +1,23 @@
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import test from 'node:test'
+import { fileURLToPath } from 'node:url'
+
+const modal = readFileSync(
+ fileURLToPath(new URL('../src/components/business/ExpenseStatsDetailModal.vue', import.meta.url)),
+ 'utf8'
+)
+
+test('expense stats detail modal exposes distribution, processing time and operation detail sections', () => {
+ assert.match(modal, /Expense Operation Details/)
+ assert.match(modal, /历史报销费用分布/)
+ assert.match(modal, /单据处理时间/)
+ assert.match(modal, /系统操作详情/)
+ assert.match(modal, /ElDialog/)
+ assert.match(modal, /ElTag/)
+ assert.match(modal, /distributionRows/)
+ assert.match(modal, /processingRows/)
+ assert.match(modal, /operationRows/)
+ assert.match(modal, /--expense-detail-percent/)
+ assert.match(modal, /统计口径来自当前工作台已加载的个人单据/)
+})
diff --git a/web/tests/personal-workbench-assistant.test.mjs b/web/tests/personal-workbench-assistant.test.mjs
index fb19243..6a8240c 100644
--- a/web/tests/personal-workbench-assistant.test.mjs
+++ b/web/tests/personal-workbench-assistant.test.mjs
@@ -152,8 +152,24 @@ test('workbench progress rows show update time first', () => {
assert.match(workbench, /