fix: 校准首页统计与压缩页面布局

This commit is contained in:
WuYongJie 2026-07-17 10:39:26 +08:00
parent 149d4510df
commit a65de1d52f
12 changed files with 867 additions and 423 deletions

View File

@ -0,0 +1,177 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import prisma from '../../utils/prisma'
import { getDashboardStats, getValueStatistics } from '../statisticsController'
test('首页统计应排除已报废库存、完整覆盖在册状态并使用上海自然日', async () => {
const originalAssetTypeFindMany = prisma.assetType.findMany
const originalEquipmentGroupBy = prisma.equipment.groupBy
const originalEquipmentFindMany = prisma.equipment.findMany
const originalEquipmentAggregate = prisma.equipment.aggregate
const originalEquipmentCount = prisma.equipment.count
const originalBorrowRecordFindMany = prisma.borrowRecord.findMany
const borrowRanges: any[] = []
;(prisma.assetType.findMany as any) = async () => [{ name: '内存条' }]
;(prisma.equipment.groupBy as any) = async (args: any) => {
if (args.by.length === 1 && args.by[0] === 'status') {
return [
{ status: '可用', _sum: { total_quantity: 1122, remaining_quantity: 1122, total_value: 424805, borrowed_value: 0, available_value: 424805 } },
{ status: '已借出', _sum: { total_quantity: 4507, remaining_quantity: 0, total_value: 2848258.44, borrowed_value: 2848258.44, available_value: 0 } },
{ status: '待报废', _sum: { total_quantity: 1, remaining_quantity: 1, total_value: 0, borrowed_value: 0, available_value: 0 } },
{ status: '已报废', _sum: { total_quantity: 127, remaining_quantity: 127, total_value: 83257, borrowed_value: 0, available_value: 0 } }
]
}
if (args.by.includes('status')) {
return [
{ equipment_type: '显示器', status: '可用', _sum: { total_quantity: 10, remaining_quantity: 10 } },
{ equipment_type: '显示器', status: '待报废', _sum: { total_quantity: 1, remaining_quantity: 1 } }
]
}
return [{ equipment_type: '显示器', _sum: { remaining_quantity: 8 } }]
}
;(prisma.equipment.findMany as any) = async () => [
{
status: '可用',
total_value: 424805,
borrowed_value: 0,
available_value: 424805,
total_value_with_children: 0
},
{
status: '已借出',
total_value: 2848998.44,
borrowed_value: 2848998.44,
available_value: 0,
total_value_with_children: 0
}
]
;(prisma.equipment.aggregate as any) = async () => ({
_count: { id: 2057 },
_sum: { total_quantity: 2057 }
})
;(prisma.equipment.count as any) = async () => 3
;(prisma.borrowRecord.findMany as any) = async (args: any) => {
borrowRanges.push(args.where.borrow_time || args.where.return_time)
return args.where.borrow_time
? [{ children_snapshot: '[{"unit_price":20}]', equipment: { unit_price: 100 } }]
: []
}
const state = { status: 200, body: null as any }
const response = {
status(code: number) {
state.status = code
return this
},
json(body: any) {
state.body = body
return this
}
}
try {
await getDashboardStats({} as any, response as any)
assert.equal(state.status, 200)
assert.equal(state.body.data.total_equipment, 5630)
assert.equal(state.body.data.root_asset_records, 2057)
assert.equal(state.body.data.accessory_equipment, 3573)
assert.equal(state.body.data.available_equipment, 1122)
assert.equal(state.body.data.borrowed_equipment, 4507)
assert.equal(state.body.data.pending_disposal_equipment, 1)
assert.equal(state.body.data.disposed_equipment, 127)
assert.equal(state.body.data.borrowed_without_active_record, 3)
assert.equal(state.body.data.today_borrow, 1)
assert.equal(state.body.data.today_return, 0)
assert.equal(state.body.data.today_borrow_value, 120)
assert.equal(state.body.data.business_timezone, 'Asia/Shanghai')
assert.equal(state.body.data.inventory_scope, 'current_excluding_disposed')
assert.equal(state.body.data.total_value, 3273063.44)
assert.equal(state.body.data.borrowed_value, 2848258.44)
assert.equal(
state.body.data.available_equipment +
state.body.data.borrowed_equipment +
state.body.data.maintenance_equipment +
state.body.data.pending_disposal_equipment +
state.body.data.damaged_equipment +
state.body.data.other_equipment,
state.body.data.total_equipment
)
assert.ok(Math.abs(state.body.data.usage_rate - 80.06750755018653) < 0.000001)
assert.ok(Math.abs(state.body.data.value_usage_rate - 87.021180377732) < 0.000001)
assert.equal(borrowRanges.length, 2)
for (const range of borrowRanges) {
assert.equal(range.gte.getUTCHours(), 16)
assert.equal(range.lt.getTime() - range.gte.getTime(), 24 * 60 * 60 * 1000)
}
} finally {
;(prisma.assetType.findMany as any) = originalAssetTypeFindMany
;(prisma.equipment.groupBy as any) = originalEquipmentGroupBy
;(prisma.equipment.findMany as any) = originalEquipmentFindMany
;(prisma.equipment.aggregate as any) = originalEquipmentAggregate
;(prisma.equipment.count as any) = originalEquipmentCount
;(prisma.borrowRecord.findMany as any) = originalBorrowRecordFindMany
}
})
test('价值统计应按当前资产本体汇总并忽略已报废和过期组合金额缓存', async () => {
const originalEquipmentFindMany = prisma.equipment.findMany
let capturedWhere: any
;(prisma.equipment.findMany as any) = async (args: any) => {
capturedWhere = args.where
return [
{
equipment_name: '显示器',
equipment_type: '显示器',
unit_price: 1500,
total_quantity: 1,
total_value: 1500,
borrowed_value: 1500,
available_value: 0,
total_value_with_children: 2240,
status: '已借出'
},
{
equipment_name: '扩展坞',
equipment_type: '扩展坞',
unit_price: 740,
total_quantity: 1,
total_value: 740,
borrowed_value: 0,
available_value: 740,
total_value_with_children: 0,
status: '可用'
}
]
}
const state = { status: 200, body: null as any }
const response = {
status(code: number) {
state.status = code
return this
},
json(body: any) {
state.body = body
return this
}
}
try {
await getValueStatistics({ query: { start_date: '2026-07-17', end_date: '2026-07-17' } } as any, response as any)
assert.equal(capturedWhere.status.not, '已报废')
assert.equal(capturedWhere.created_at.gte.toISOString(), '2026-07-16T16:00:00.000Z')
assert.equal(capturedWhere.created_at.lt.toISOString(), '2026-07-17T16:00:00.000Z')
assert.equal(state.body.data.total_value, 2240)
assert.equal(state.body.data.borrowed_value, 1500)
assert.equal(state.body.data.available_value, 740)
assert.equal(state.body.data.value_by_type.length, 2)
assert.equal(state.body.data.top_equipment[0].item_value, 1500)
assert.equal(state.body.data.top_equipment[0].stock_total_value, 1500)
} finally {
;(prisma.equipment.findMany as any) = originalEquipmentFindMany
}
})

View File

@ -5,6 +5,7 @@ import { successResponse, errorResponse } from '../utils/response'
import {
calculateEquipmentValues,
calculateChildrenTotalValueFromRecords,
calculateCurrentTotalValueWithChildren,
refreshParentTotalValueWithChildren
} from '../utils/assetValue'
import {
@ -450,13 +451,17 @@ export const getChildren = async (req: Request, res: Response) => {
}
const totalChildrenValue = calculateChildrenTotalValueFromRecords(parent.children)
const currentTotalValueWithChildren = calculateCurrentTotalValueWithChildren(
parent.unit_price,
parent.children
)
successResponse(
res,
{
children: parent.children,
total_children_value: totalChildrenValue,
total_value_with_children: Number(parent.total_value_with_children)
total_value_with_children: currentTotalValueWithChildren
},
'获取成功'
)

View File

@ -2,35 +2,24 @@ import { Request, Response } from 'express'
import { Prisma } from '@prisma/client'
import prisma from '../utils/prisma'
import { successResponse, errorResponse } from '../utils/response'
import { sumSnapshotChildrenValue } from '../utils/assetValue'
import {
getEquipmentDisplayAvailableValue,
getEquipmentDisplayBorrowedValue,
getEquipmentDisplayItemValue,
getEquipmentDisplayTotalValue,
sumSnapshotChildrenValue
} from '../utils/assetValue'
type DisplayValueRow = {
equipment_type?: string | null
status?: string | null
total_value?: unknown
borrowed_value?: unknown
available_value?: unknown
total_value_with_children?: unknown
}
type DisplayValueSummary = {
total_value: number
borrowed_value: number
available_value: number
maintenance_value: number
}
DASHBOARD_TIME_ZONE,
getInventoryStatusBreakdown,
getShanghaiDateRange,
getShanghaiDayRange,
summarizeInventoryStatusRows
} from '../utils/dashboardStatistics'
type InventoryByTypeRow = {
equipment_type: string
total_quantity: number
available_quantity: number
borrowed_quantity: number
maintenance_quantity: number
pending_disposal_quantity: number
damaged_quantity: number
other_quantity: number
mountable_available_quantity: number
is_child_type: boolean
}
@ -51,56 +40,36 @@ const buildEquipmentWhere = (equipmentType?: string, startDate?: string, endDate
if (startDate || endDate) {
where.created_at = {}
if (startDate) {
where.created_at.gte = new Date(startDate)
where.created_at.gte = getShanghaiDateRange(startDate).start
}
if (endDate) {
const endOfDay = new Date(endDate)
endOfDay.setHours(23, 59, 59, 999)
where.created_at.lte = endOfDay
where.created_at.lt = getShanghaiDateRange(endDate).end
}
if (
where.created_at.gte instanceof Date &&
where.created_at.lt instanceof Date &&
where.created_at.gte >= where.created_at.lt
) {
throw new Error('开始日期不能晚于结束日期')
}
}
return where
}
const summarizeDisplayValues = (rows: DisplayValueRow[]): DisplayValueSummary =>
rows.reduce<DisplayValueSummary>(
(summary, row) => {
const totalValue = getEquipmentDisplayTotalValue(row)
summary.total_value += totalValue
summary.borrowed_value += getEquipmentDisplayBorrowedValue(row)
summary.available_value += getEquipmentDisplayAvailableValue(row)
if (row.status === '维护中') {
summary.maintenance_value += totalValue
}
return summary
},
{
total_value: 0,
borrowed_value: 0,
available_value: 0,
maintenance_value: 0
}
)
// 获取首页统计数据
export const getDashboardStats = async (req: Request, res: Response) => {
try {
const startOfToday = new Date(new Date().setHours(0, 0, 0, 0))
const generatedAt = new Date()
const todayRange = getShanghaiDayRange(generatedAt)
const [
childAssetTypes,
equipmentTotals,
availableInventoryTotals,
borrowableInventoryTotals,
maintenanceTotals,
equipmentStatusTotals,
inventoryByType,
mountableInventoryByType,
displayValueRows,
todayBorrowCount,
todayReturnCount,
currentRootTotals,
borrowedWithoutActiveRecord,
todayBorrowRecords,
todayReturnRecords
] = await Promise.all([
@ -113,42 +82,21 @@ export const getDashboardStats = async (req: Request, res: Response) => {
name: true
}
}),
prisma.equipment.aggregate({
prisma.equipment.groupBy({
by: ['status'],
_sum: {
total_quantity: true,
remaining_quantity: true,
total_value: true,
borrowed_value: true,
available_value: true
}
}),
prisma.equipment.aggregate({
where: {
status: '可用'
},
_sum: {
remaining_quantity: true
}
}),
prisma.equipment.aggregate({
where: {
status: {
in: ['可用', '已借出']
}
},
_sum: {
total_quantity: true,
remaining_quantity: true
}
}),
prisma.equipment.aggregate({
where: { status: '维护中' },
_sum: {
total_quantity: true,
total_value: true
}
}),
prisma.equipment.groupBy({
by: ['equipment_type', 'status'],
where: {
status: { not: '已报废' }
},
_sum: {
total_quantity: true,
remaining_quantity: true
@ -171,36 +119,35 @@ export const getDashboardStats = async (req: Request, res: Response) => {
remaining_quantity: true
}
}),
prisma.equipment.findMany({
prisma.equipment.aggregate({
where: {
parent_id: null
parent_id: null,
status: { not: '已报废' }
},
select: {
status: true,
total_value: true,
borrowed_value: true,
available_value: true,
total_value_with_children: true
_count: {
id: true
},
_sum: {
total_quantity: true
}
}),
prisma.borrowRecord.count({
prisma.equipment.count({
where: {
borrow_time: {
gte: startOfToday
parent_id: null,
status: '已借出',
borrow_records: {
none: {
status: '已借出',
return_time: null
}
}
}),
prisma.borrowRecord.count({
where: {
return_time: {
gte: startOfToday
}
}
}),
prisma.borrowRecord.findMany({
where: {
borrow_time: {
gte: startOfToday
gte: todayRange.start,
lt: todayRange.end
}
},
select: {
@ -215,7 +162,8 @@ export const getDashboardStats = async (req: Request, res: Response) => {
prisma.borrowRecord.findMany({
where: {
return_time: {
gte: startOfToday
gte: todayRange.start,
lt: todayRange.end
}
},
select: {
@ -229,27 +177,50 @@ export const getDashboardStats = async (req: Request, res: Response) => {
})
])
const totalEquipmentQuantity = toNumber(equipmentTotals._sum.total_quantity)
const availableEquipmentQuantity = toNumber(availableInventoryTotals._sum.remaining_quantity)
const borrowedEquipmentQuantity = Math.max(
toNumber(borrowableInventoryTotals._sum.total_quantity) - toNumber(borrowableInventoryTotals._sum.remaining_quantity),
0
const inventorySummary = summarizeInventoryStatusRows(
equipmentStatusTotals.map((item) => ({
status: item.status,
total_quantity: item._sum.total_quantity,
remaining_quantity: item._sum.remaining_quantity
}))
)
const maintenanceEquipmentQuantity = toNumber(maintenanceTotals._sum.total_quantity)
const displayValueSummary = summarizeDisplayValues(displayValueRows)
const totalValue = displayValueSummary.total_value
const borrowedValue = displayValueSummary.borrowed_value
const availableValue = displayValueSummary.available_value
const maintenanceValue = displayValueSummary.maintenance_value
const totalEquipmentQuantity = inventorySummary.current
const availableEquipmentQuantity = inventorySummary.available
const borrowedEquipmentQuantity = inventorySummary.borrowed
const maintenanceEquipmentQuantity = inventorySummary.maintenance
const rootEquipmentQuantity = toNumber(currentRootTotals._sum.total_quantity)
const accessoryEquipmentQuantity = Math.max(totalEquipmentQuantity - rootEquipmentQuantity, 0)
const currentValueSummary = equipmentStatusTotals.reduce((summary, item) => {
if (item.status === '已报废') return summary
const totalValue = toNumber(item._sum.total_value)
summary.total_value += totalValue
summary.borrowed_value += toNumber(item._sum.borrowed_value)
summary.available_value += toNumber(item._sum.available_value)
if (item.status === '维护中') summary.maintenance_value += totalValue
return summary
}, {
total_value: 0,
borrowed_value: 0,
available_value: 0,
maintenance_value: 0
})
const totalValue = currentValueSummary.total_value
const borrowedValue = currentValueSummary.borrowed_value
const availableValue = currentValueSummary.available_value
const maintenanceValue = currentValueSummary.maintenance_value
const todayBorrowValue = todayBorrowRecords.reduce((sum, record) => {
return sum + toNumber(record.equipment?.unit_price) + sumSnapshotChildrenValue(record.children_snapshot)
}, 0)
const todayReturnValue = todayReturnRecords.reduce((sum, record) => {
return sum + toNumber(record.equipment?.unit_price) + sumSnapshotChildrenValue(record.children_snapshot)
}, 0)
const usageRate =
totalEquipmentQuantity > 0 ? (borrowedEquipmentQuantity / totalEquipmentQuantity) * 100 : 0
const valueUsageRate = totalValue > 0 ? (borrowedValue / totalValue) * 100 : 0
const operationalEquipmentQuantity = availableEquipmentQuantity + borrowedEquipmentQuantity
const operationalValue = availableValue + borrowedValue
const usageRate = operationalEquipmentQuantity > 0
? (borrowedEquipmentQuantity / operationalEquipmentQuantity) * 100
: 0
const valueUsageRate = operationalValue > 0 ? (borrowedValue / operationalValue) * 100 : 0
const childTypeNames = new Set(childAssetTypes.map((item) => item.name))
const mountableInventoryByTypeMap = mountableInventoryByType.reduce((map, item) => {
map.set(item.equipment_type || '其他', toNumber(item._sum.remaining_quantity))
@ -258,12 +229,20 @@ export const getDashboardStats = async (req: Request, res: Response) => {
successResponse(res, {
total_equipment: totalEquipmentQuantity,
root_asset_records: toNumber(currentRootTotals._count.id),
root_equipment: rootEquipmentQuantity,
accessory_equipment: accessoryEquipmentQuantity,
available_equipment: availableEquipmentQuantity,
borrowed_equipment: borrowedEquipmentQuantity,
maintenance_equipment: maintenanceEquipmentQuantity,
pending_disposal_equipment: inventorySummary.pendingDisposal,
damaged_equipment: inventorySummary.damaged,
other_equipment: inventorySummary.other,
disposed_equipment: inventorySummary.disposed,
borrowed_without_active_record: borrowedWithoutActiveRecord,
usage_rate: usageRate,
today_borrow: todayBorrowCount,
today_return: todayReturnCount,
today_borrow: todayBorrowRecords.length,
today_return: todayReturnRecords.length,
total_value: totalValue,
borrowed_value: borrowedValue,
available_value: availableValue,
@ -271,6 +250,9 @@ export const getDashboardStats = async (req: Request, res: Response) => {
value_usage_rate: valueUsageRate,
today_borrow_value: todayBorrowValue,
today_return_value: todayReturnValue,
generated_at: generatedAt.toISOString(),
business_timezone: DASHBOARD_TIME_ZONE,
inventory_scope: 'current_excluding_disposed',
inventory_by_type: Array.from(
inventoryByType.reduce((map, item) => {
const equipmentType = item.equipment_type || '其他'
@ -279,20 +261,28 @@ export const getDashboardStats = async (req: Request, res: Response) => {
total_quantity: 0,
available_quantity: 0,
borrowed_quantity: 0,
maintenance_quantity: 0,
pending_disposal_quantity: 0,
damaged_quantity: 0,
other_quantity: 0,
mountable_available_quantity: mountableInventoryByTypeMap.get(equipmentType) || 0,
is_child_type: childTypeNames.has(equipmentType)
}
const totalQuantity = toNumber(item._sum.total_quantity)
const remainingQuantity = toNumber(item._sum.remaining_quantity)
const breakdown = getInventoryStatusBreakdown(
item.status,
totalQuantity,
remainingQuantity
)
current.total_quantity += totalQuantity
if (item.status === '可用') {
current.available_quantity += remainingQuantity
current.borrowed_quantity += Math.max(totalQuantity - remainingQuantity, 0)
} else if (item.status === '已借出') {
current.borrowed_quantity += Math.max(totalQuantity - remainingQuantity, 0)
}
current.total_quantity += breakdown.current
current.available_quantity += breakdown.available
current.borrowed_quantity += breakdown.borrowed
current.maintenance_quantity += breakdown.maintenance
current.pending_disposal_quantity += breakdown.pendingDisposal
current.damaged_quantity += breakdown.damaged
current.other_quantity += breakdown.other
map.set(equipmentType, current)
return map
@ -317,41 +307,40 @@ export const getValueStatistics = async (req: Request, res: Response) => {
typeof end_date === 'string' ? end_date : undefined
)
const displayValueWhere: Prisma.EquipmentWhereInput = {
const currentInventoryWhere: Prisma.EquipmentWhereInput = {
...where,
parent_id: null
status: { not: '已报废' }
}
const [displayValueRows, topEquipmentRows] = await Promise.all([
prisma.equipment.findMany({
where: displayValueWhere,
select: {
equipment_type: true,
status: true,
total_value: true,
borrowed_value: true,
available_value: true,
total_value_with_children: true
}
}),
prisma.equipment.findMany({
where,
const equipmentRows = await prisma.equipment.findMany({
where: currentInventoryWhere,
select: {
equipment_name: true,
equipment_type: true,
unit_price: true,
total_quantity: true,
total_value: true,
total_value_with_children: true,
borrowed_value: true,
available_value: true,
status: true
},
}
})
])
const summary = summarizeDisplayValues(displayValueRows)
const summary = equipmentRows.reduce((current, item) => {
const totalValue = toNumber(item.total_value)
current.total_value += totalValue
current.borrowed_value += toNumber(item.borrowed_value)
current.available_value += toNumber(item.available_value)
if (item.status === '维护中') current.maintenance_value += totalValue
return current
}, {
total_value: 0,
borrowed_value: 0,
available_value: 0,
maintenance_value: 0
})
const valueByType = Array.from(
displayValueRows.reduce((map, item) => {
equipmentRows.reduce((map, item) => {
const equipmentType = item.equipment_type || '未分类'
const current = map.get(equipmentType) || {
equipment_type: equipmentType,
@ -360,9 +349,9 @@ export const getValueStatistics = async (req: Request, res: Response) => {
available_value: 0
}
current.total_value += getEquipmentDisplayTotalValue(item)
current.borrowed_value += getEquipmentDisplayBorrowedValue(item)
current.available_value += getEquipmentDisplayAvailableValue(item)
current.total_value += toNumber(item.total_value)
current.borrowed_value += toNumber(item.borrowed_value)
current.available_value += toNumber(item.available_value)
map.set(equipmentType, current)
return map
@ -376,24 +365,29 @@ export const getValueStatistics = async (req: Request, res: Response) => {
)
.sort((left, right) => right.total_value - left.total_value)
.filter((item) => item.total_value > 0)
const topEquipment = topEquipmentRows
const topEquipment = equipmentRows
.map((eq) => ({
equipment_name: eq.equipment_name,
quantity: toNumber(eq.total_quantity) || 1,
unit_price: toNumber(eq.unit_price),
item_value: getEquipmentDisplayItemValue(eq),
stock_total_value: getEquipmentDisplayTotalValue(eq),
stock_borrowed_value: getEquipmentDisplayBorrowedValue(eq),
stock_available_value: getEquipmentDisplayAvailableValue(eq)
item_value: toNumber(eq.unit_price),
stock_total_value: toNumber(eq.total_value),
stock_borrowed_value: toNumber(eq.borrowed_value),
stock_available_value: toNumber(eq.available_value)
}))
.sort((a, b) => b.item_value - a.item_value || b.stock_total_value - a.stock_total_value)
.slice(0, 10)
const otherValue = Math.max(
summary.total_value - summary.borrowed_value - summary.available_value - summary.maintenance_value,
0
)
successResponse(res, {
total_value: summary.total_value,
borrowed_value: summary.borrowed_value,
available_value: summary.available_value,
maintenance_value: summary.maintenance_value,
other_value: otherValue,
value_by_type: valueByType,
top_equipment: topEquipment
}, '获取成功')

View File

@ -1,7 +1,22 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { refreshParentTotalValueWithChildren, syncMountedChildrenState } from '../assetValue'
import {
calculateCurrentTotalValueWithChildren,
refreshParentTotalValueWithChildren,
syncMountedChildrenState
} from '../assetValue'
test('当前组合价值只根据实时父子关系计算', () => {
assert.equal(calculateCurrentTotalValueWithChildren(1500, []), 1500)
assert.equal(
calculateCurrentTotalValueWithChildren(1500, [
{ unit_price: 300 },
{ unit_price: '440' }
]),
2240
)
})
test('syncMountedChildrenState 发现父子环时在写入前中止', async () => {
let updateCount = 0

View File

@ -0,0 +1,66 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
getShanghaiDateRange,
getInventoryStatusBreakdown,
getShanghaiDayRange,
summarizeInventoryStatusRows
} from '../dashboardStatistics'
test('日期筛选应覆盖完整的上海自然日并拒绝不存在的日期', () => {
const range = getShanghaiDateRange('2026-07-17')
assert.equal(range.start.toISOString(), '2026-07-16T16:00:00.000Z')
assert.equal(range.end.toISOString(), '2026-07-17T16:00:00.000Z')
assert.throws(() => getShanghaiDateRange('2026-02-30'), /日期不存在/)
assert.throws(() => getShanghaiDateRange('2026\/07\/17'), /YYYY-MM-DD/)
})
test('今日边界应固定按 Asia/Shanghai 计算,不受服务器 UTC 时区影响', () => {
const beforeMidnight = getShanghaiDayRange(new Date('2026-07-16T15:59:59.999Z'))
assert.equal(beforeMidnight.start.toISOString(), '2026-07-15T16:00:00.000Z')
assert.equal(beforeMidnight.end.toISOString(), '2026-07-16T16:00:00.000Z')
const atMidnight = getShanghaiDayRange(new Date('2026-07-16T16:00:00.000Z'))
assert.equal(atMidnight.start.toISOString(), '2026-07-16T16:00:00.000Z')
assert.equal(atMidnight.end.toISOString(), '2026-07-17T16:00:00.000Z')
})
test('可用和已借出记录应按剩余数量拆分,保证在册总量可对账', () => {
assert.deepEqual(getInventoryStatusBreakdown('可用', 10, 4), {
current: 10,
available: 4,
borrowed: 6,
maintenance: 0,
pendingDisposal: 0,
damaged: 0,
other: 0,
disposed: 0
})
const summary = summarizeInventoryStatusRows([
{ status: '可用', total_quantity: 10, remaining_quantity: 4 },
{ status: '已借出', total_quantity: 3, remaining_quantity: 0 },
{ status: '维护中', total_quantity: 2, remaining_quantity: 2 },
{ status: '待报废', total_quantity: 1, remaining_quantity: 1 },
{ status: '已损坏', total_quantity: 1, remaining_quantity: 1 },
{ status: '自定义状态', total_quantity: 1, remaining_quantity: 1 },
{ status: '已报废', total_quantity: 5, remaining_quantity: 5 }
])
assert.deepEqual(summary, {
current: 18,
available: 4,
borrowed: 9,
maintenance: 2,
pendingDisposal: 1,
damaged: 1,
other: 1,
disposed: 5
})
assert.equal(
summary.available + summary.borrowed + summary.maintenance +
summary.pendingDisposal + summary.damaged + summary.other,
summary.current
)
})

View File

@ -84,6 +84,13 @@ export const calculateChildrenTotalValueFromRecords = (
return children.reduce((sum, child) => sum + toSafeNumber(child.unit_price), 0)
}
export const calculateCurrentTotalValueWithChildren = (
parentUnitPrice: unknown,
children: Array<{ unit_price?: unknown }>
) => {
return toSafeNumber(parentUnitPrice) + calculateChildrenTotalValueFromRecords(children)
}
export const refreshParentTotalValueWithChildren = async (client: EquipmentClient, parentId: number) => {
const parent = await client.equipment.findUnique({
where: { id: parentId },
@ -100,8 +107,10 @@ export const refreshParentTotalValueWithChildren = async (client: EquipmentClien
return
}
const totalValueWithChildren =
toSafeNumber(parent.unit_price) + calculateChildrenTotalValueFromRecords(parent.children)
const totalValueWithChildren = calculateCurrentTotalValueWithChildren(
parent.unit_price,
parent.children
)
await client.equipment.update({
where: { id: parentId },

View File

@ -0,0 +1,136 @@
const SHANGHAI_UTC_OFFSET_MS = 8 * 60 * 60 * 1000
const DAY_MS = 24 * 60 * 60 * 1000
export const DASHBOARD_TIME_ZONE = 'Asia/Shanghai'
export type InventoryStatusBreakdown = {
current: number
available: number
borrowed: number
maintenance: number
pendingDisposal: number
damaged: number
other: number
disposed: number
}
export type InventoryStatusTotalRow = {
status?: string | null
total_quantity?: unknown
remaining_quantity?: unknown
}
const toSafeQuantity = (value: unknown) => {
const parsed = Number(value)
return Number.isFinite(parsed) ? Math.max(0, parsed) : 0
}
export const getShanghaiDayRange = (now = new Date()) => {
const shifted = new Date(now.getTime() + SHANGHAI_UTC_OFFSET_MS)
const startTimestamp = Date.UTC(
shifted.getUTCFullYear(),
shifted.getUTCMonth(),
shifted.getUTCDate()
) - SHANGHAI_UTC_OFFSET_MS
return {
start: new Date(startTimestamp),
end: new Date(startTimestamp + DAY_MS)
}
}
export const getShanghaiDateRange = (dateText: string) => {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateText)
if (!match) throw new Error('日期格式必须为 YYYY-MM-DD')
const year = Number(match[1])
const month = Number(match[2])
const day = Number(match[3])
const calendarTimestamp = Date.UTC(year, month - 1, day)
const calendarDate = new Date(calendarTimestamp)
if (
calendarDate.getUTCFullYear() !== year ||
calendarDate.getUTCMonth() !== month - 1 ||
calendarDate.getUTCDate() !== day
) {
throw new Error('日期不存在')
}
const startTimestamp = calendarTimestamp - SHANGHAI_UTC_OFFSET_MS
return {
start: new Date(startTimestamp),
end: new Date(startTimestamp + DAY_MS)
}
}
export const getInventoryStatusBreakdown = (
status: string | null | undefined,
totalQuantityValue: unknown,
remainingQuantityValue: unknown
): InventoryStatusBreakdown => {
const totalQuantity = toSafeQuantity(totalQuantityValue)
const remainingQuantity = Math.min(toSafeQuantity(remainingQuantityValue), totalQuantity)
const empty: InventoryStatusBreakdown = {
current: 0,
available: 0,
borrowed: 0,
maintenance: 0,
pendingDisposal: 0,
damaged: 0,
other: 0,
disposed: 0
}
if (status === '已报废') {
return { ...empty, disposed: totalQuantity }
}
empty.current = totalQuantity
if (status === '可用' || status === '已借出') {
empty.available = remainingQuantity
empty.borrowed = Math.max(totalQuantity - remainingQuantity, 0)
} else if (status === '维护中') {
empty.maintenance = totalQuantity
} else if (status === '待报废') {
empty.pendingDisposal = totalQuantity
} else if (status === '已损坏') {
empty.damaged = totalQuantity
} else {
empty.other = totalQuantity
}
return empty
}
export const summarizeInventoryStatusRows = (
rows: InventoryStatusTotalRow[]
): InventoryStatusBreakdown => rows.reduce<InventoryStatusBreakdown>(
(summary, row) => {
const current = getInventoryStatusBreakdown(
row.status,
row.total_quantity,
row.remaining_quantity
)
summary.current += current.current
summary.available += current.available
summary.borrowed += current.borrowed
summary.maintenance += current.maintenance
summary.pendingDisposal += current.pendingDisposal
summary.damaged += current.damaged
summary.other += current.other
summary.disposed += current.disposed
return summary
},
{
current: 0,
available: 0,
borrowed: 0,
maintenance: 0,
pendingDisposal: 0,
damaged: 0,
other: 0,
disposed: 0
}
)

View File

@ -258,12 +258,12 @@
.page-header {
display: flex;
flex-direction: column;
gap: var(--space-4);
padding: var(--space-4);
gap: var(--space-3);
padding: var(--space-3);
border: 1px solid var(--border-color);
border-radius: var(--radius-xl);
border-radius: var(--radius-lg);
background: var(--bg-secondary);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.12);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.1);
}
.page-kicker {
@ -275,24 +275,26 @@
}
.page-title {
margin-top: var(--space-2);
margin-top: var(--space-1);
color: var(--text-primary);
font-size: 24px;
font-size: 22px;
font-weight: 600;
line-height: 1.2;
}
.page-meta-grid {
display: grid;
gap: var(--space-3);
grid-template-columns: repeat(1, minmax(0, 1fr));
width: 100%;
gap: var(--space-2);
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.page-meta {
min-width: 0;
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
border-radius: var(--radius-md);
background: var(--bg-elevated);
padding: var(--space-3);
padding: 8px 10px;
}
.page-meta__label {
@ -303,16 +305,22 @@
}
.page-meta__value {
margin-top: var(--space-1);
font-size: 18px;
margin-top: 2px;
overflow: hidden;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
text-overflow: ellipsis;
white-space: nowrap;
}
.page-meta__subvalue {
margin-top: 2px;
font-size: 11px;
margin-top: 1px;
overflow: hidden;
font-size: 10px;
color: var(--text-secondary);
text-overflow: ellipsis;
white-space: nowrap;
}
.section-title {
@ -749,7 +757,7 @@
}
.page-meta-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: repeat(auto-fit, minmax(112px, 1fr));
}
.list-header {
@ -787,14 +795,16 @@
@media (min-width: 1024px) {
.page-header {
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
padding: var(--space-6);
display: grid;
grid-template-columns: minmax(160px, auto) minmax(0, 1fr);
align-items: center;
padding: var(--space-4);
}
.page-meta-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
width: min(100%, 640px);
justify-self: end;
grid-template-columns: repeat(auto-fit, minmax(112px, 1fr));
}
.filter-bar {
@ -863,7 +873,7 @@
.page-header {
padding: var(--space-3);
gap: var(--space-3);
gap: var(--space-2);
}
.page-title {
@ -875,11 +885,11 @@
}
.page-meta {
padding: var(--space-3);
padding: 8px 10px;
}
.page-meta__value {
font-size: 16px;
font-size: 15px;
}
.list-header {

View File

@ -556,9 +556,17 @@ export interface PaginatedResponse<T> {
// 统计数据接口
export interface DashboardStats {
total_equipment: number
root_asset_records: number
root_equipment: number
accessory_equipment: number
available_equipment: number
borrowed_equipment: number
maintenance_equipment: number
pending_disposal_equipment: number
damaged_equipment: number
other_equipment: number
disposed_equipment: number
borrowed_without_active_record: number
usage_rate: number
today_borrow: number
today_return: number
@ -569,11 +577,18 @@ export interface DashboardStats {
value_usage_rate: number
today_borrow_value: number
today_return_value: number
generated_at: string
business_timezone: 'Asia/Shanghai'
inventory_scope: 'current_excluding_disposed'
inventory_by_type: Array<{
equipment_type: string
total_quantity: number
available_quantity: number
borrowed_quantity: number
maintenance_quantity: number
pending_disposal_quantity: number
damaged_quantity: number
other_quantity: number
mountable_available_quantity: number
is_child_type: boolean
}>
@ -585,6 +600,7 @@ export interface ValueStatistics {
borrowed_value: number
available_value: number
maintenance_value: number
other_value: number
value_by_type: Array<{
equipment_type: string
total_value: number

View File

@ -1,5 +1,5 @@
<template>
<div class="space-y-6">
<div class="space-y-4">
<!-- 加载状态 -->
<div
v-if="canViewReports && isLoading"
@ -18,7 +18,7 @@
<!-- 页面头部 -->
<section class="page-header">
<div>
<p class="page-kicker">Role Workspace</p>
<p class="page-kicker">资产运营</p>
<h2 class="page-title">{{ roleTitle }}</h2>
</div>
@ -38,12 +38,12 @@
<p class="mt-1 text-xs text-text-tertiary">按当前角色展示最常用的工作入口</p>
</div>
</div>
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<router-link
v-for="action in roleActions"
:key="action.id"
:to="action.to"
class="panel group flex min-h-[112px] flex-col justify-between transition hover:border-border-hover hover:bg-bg-hover"
class="panel group flex min-h-[88px] flex-col justify-between transition hover:border-border-hover hover:bg-bg-hover"
>
<div class="flex items-start justify-between gap-3">
<p class="text-sm font-semibold text-text-primary">{{ action.label }}</p>
@ -63,8 +63,16 @@
</p>
</section>
<section v-if="dashboardStatsError" class="panel flex flex-wrap items-center justify-between gap-3 border-status-error/40">
<div>
<p class="text-sm font-medium text-status-error">首页统计读取失败</p>
<p class="mt-1 text-xs text-text-secondary">当前不展示 0 值,避免把接口异常误认为真实库存。</p>
</div>
<button type="button" class="btn-secondary" @click="fetchDashboardData">重新读取</button>
</section>
<!-- 统计卡片 -->
<div class="grid gap-2 sm:gap-3 grid-cols-2 lg:grid-cols-4">
<div v-if="stats" class="grid grid-cols-2 gap-2 sm:gap-3 lg:grid-cols-4">
<div v-for="card in statCards.slice(0, 4)" :key="card.title" class="card">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
@ -80,26 +88,21 @@
</div>
<!-- 图表区域 -->
<div class="grid gap-4 lg:grid-cols-2">
<div v-if="stats" class="grid gap-4 lg:grid-cols-2">
<!-- 资产状态分布 -->
<section class="card">
<div class="list-header">
<h3 class="section-title">资产状态分布</h3>
<span class="badge badge-neutral">实时</span>
<div>
<h3 class="section-title">当前在册状态</h3>
<p class="mt-1 text-xs text-text-tertiary">不含已完成报废资产,状态合计等于在册总量</p>
</div>
<span class="badge badge-neutral">已报废历史 {{ formatNumber(stats.disposed_equipment) }}</span>
</div>
<div ref="statusChartRef" class="h-64"></div>
<div class="mt-3 grid grid-cols-3 gap-2">
<div class="panel text-center">
<p class="text-xs text-text-tertiary uppercase">可用</p>
<p class="text-lg font-semibold text-status-success mt-1">{{ formatNumber(stats?.available_equipment || 0) }}</p>
</div>
<div class="panel text-center">
<p class="text-xs text-text-tertiary uppercase">借出</p>
<p class="text-lg font-semibold text-status-warning mt-1">{{ formatNumber(stats?.borrowed_equipment || 0) }}</p>
</div>
<div class="panel text-center">
<p class="text-xs text-text-tertiary uppercase">维护</p>
<p class="text-lg font-semibold text-status-error mt-1">{{ formatNumber(stats?.maintenance_equipment || 0) }}</p>
<div class="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-4">
<div v-for="item in statusSummaryItems" :key="item.label" class="panel text-center">
<p class="text-xs text-text-tertiary">{{ item.label }}</p>
<p class="mt-1 text-lg font-semibold" :class="item.valueClass">{{ formatNumber(item.value) }}</p>
</div>
</div>
</section>
@ -107,7 +110,10 @@
<!-- 库存结构 -->
<section class="card">
<div class="list-header">
<h3 class="section-title">库存结构</h3>
<div>
<h3 class="section-title">在册资产结构</h3>
<p class="mt-1 text-xs text-text-tertiary">按资产类型统计,不含已完成报废资产</p>
</div>
<span class="badge badge-neutral">TOP 5</span>
</div>
<div ref="categoryChartRef" class="h-64"></div>
@ -119,16 +125,12 @@
>
<div>
<p class="text-sm font-medium text-text-primary">{{ item.equipment_type }}</p>
<p class="text-xs text-text-tertiary">
<template v-if="item.is_child_type">
可用 {{ formatNumber(item.available_quantity) }} · 空闲可挂载 {{ formatNumber(item.mountable_available_quantity) }} · 借出 {{ formatNumber(item.borrowed_quantity) }}
</template>
<template v-else>
可用 {{ formatNumber(item.available_quantity) }} · 借出 {{ formatNumber(item.borrowed_quantity) }}
</template>
</p>
<p class="text-xs text-text-tertiary">{{ getCategoryBreakdown(item) }}</p>
</div>
<div class="text-right">
<span class="text-lg font-semibold text-text-primary">{{ formatNumber(item.total_quantity) }}</span>
<p class="text-[10px] text-text-tertiary">在册</p>
</div>
</div>
<div v-if="topCategories.length === 0" class="text-sm text-text-secondary py-3 text-center">
暂无数据
@ -138,15 +140,19 @@
</div>
<!-- 最近借用记录 -->
<div class="grid gap-6 lg:grid-cols-3">
<div v-if="stats" class="grid gap-4 lg:grid-cols-3">
<section class="card lg:col-span-2">
<div class="list-header">
<h3 class="section-title">最近借用记录</h3>
<router-link v-if="canBorrow" to="/borrow" class="btn-secondary text-sm">查看全部</router-link>
</div>
<div v-if="recentRecordsError" class="panel border-status-error/40">
<p class="text-sm text-status-error">最近借用记录读取失败,库存统计不受影响。</p>
</div>
<!-- 移动端:卡片列表 -->
<div class="mobile-card-list md:hidden">
<div v-if="!recentRecordsError" class="mobile-card-list md:hidden">
<article v-for="record in recentRecords" :key="record.id" class="mobile-card">
<div class="mobile-card__row">
<div class="min-w-0 flex-1">
@ -178,7 +184,7 @@
</div>
<!-- 桌面端:表格 -->
<div class="hidden md:block">
<div v-if="!recentRecordsError" class="hidden md:block">
<div class="table-container">
<table class="data-table">
<thead>
@ -227,39 +233,47 @@
<section class="card">
<h3 class="section-title mb-3">运营概览</h3>
<div class="space-y-3">
<div v-if="stats.borrowed_without_active_record > 0" class="panel border-status-warning/40">
<p class="text-xs font-medium text-status-warning">数据核对提醒</p>
<p class="mt-1 text-xs leading-5 text-text-secondary">
{{ formatNumber(stats.borrowed_without_active_record) }} 个主资产标为使用中,但没有当前借用单;已计入使用中,不会伪装成借用记录。
</p>
</div>
<div class="panel">
<p class="text-xs text-text-tertiary uppercase">总资产价值</p>
<p class="text-xs text-text-tertiary">在册资产价值</p>
<p class="text-xl font-semibold text-text-primary mt-1">
{{ formatCurrencyNoFraction(stats?.total_value || 0) }}
{{ formatCurrencyNoFraction(stats.total_value) }}
</p>
<p class="mt-0.5 text-xs text-text-secondary">不含已完成报废资产</p>
</div>
<div class="panel">
<p class="text-xs text-text-tertiary uppercase">可用资产</p>
<p class="text-xs text-text-tertiary">可分配资产</p>
<p class="text-base font-semibold text-status-success mt-1">
{{ formatNumber(stats?.available_equipment || 0) }}
{{ formatNumber(stats.available_equipment) }}
</p>
<p class="text-xs text-text-secondary mt-0.5">
价值 {{ formatCurrencyNoFraction(stats?.available_value || 0) }}
价值 {{ formatCurrencyNoFraction(stats.available_value) }}
</p>
</div>
<div class="panel">
<p class="text-xs text-text-tertiary uppercase">借出资产</p>
<p class="text-xs text-text-tertiary">使用中资产</p>
<p class="text-base font-semibold text-status-warning mt-1">
{{ formatNumber(stats?.borrowed_equipment || 0) }} 件
{{ formatNumber(stats.borrowed_equipment) }} 件
</p>
<p class="text-xs text-text-secondary mt-0.5">
价值 {{ formatCurrencyNoFraction(stats?.borrowed_value || 0) }}
价值 {{ formatCurrencyNoFraction(stats.borrowed_value) }}
</p>
</div>
<div class="panel">
<p class="text-xs text-text-tertiary uppercase">维护中</p>
<p class="text-xs text-text-tertiary">维修中</p>
<p class="text-base font-semibold text-status-error mt-1">
{{ formatNumber(stats?.maintenance_equipment || 0) }} 件
{{ formatNumber(stats.maintenance_equipment) }} 件
</p>
<p class="text-xs text-text-secondary mt-0.5">
价值 {{ formatCurrencyNoFraction(stats?.maintenance_value || 0) }}
价值 {{ formatCurrencyNoFraction(stats.maintenance_value) }}
</p>
</div>
<p class="text-[10px] text-text-tertiary">统计时区:Asia/Shanghai(中国标准时间)</p>
</div>
</section>
</div>
@ -268,7 +282,7 @@
<template v-else>
<section class="page-header">
<div>
<p class="page-kicker">My Assets</p>
<p class="page-kicker">本人资产</p>
<h2 class="page-title">我的资产</h2>
<router-link to="/my-assets" class="btn-primary mt-3 text-sm">查看全部</router-link>
</div>
@ -312,7 +326,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
import * as echarts from 'echarts'
import { getDashboardStats } from '@/api/statistics'
@ -325,8 +339,6 @@ import { PERMISSIONS } from '@/auth/permissions'
import { getDashboardRoleActions, getDashboardRoleTitle } from './dashboardRoleConfig'
import { formatCurrencyNoFraction, formatDateTime, formatNumber, formatPercent } from '@/utils/format'
import {
BanknotesIcon,
ChartBarSquareIcon,
CheckCircleIcon,
CubeIcon,
WrenchScrewdriverIcon,
@ -339,8 +351,11 @@ const recentRecords = ref<BorrowRecord[]>([])
const statusChartRef = ref<HTMLElement>()
const categoryChartRef = ref<HTMLElement>()
const isLoading = ref(false)
const pendingDisposals = ref(0)
const failedDeliveries = ref(0)
const dashboardStatsError = ref('')
const recentRecordsError = ref('')
const pendingDisposals = ref<number | null>(null)
const pendingDisposalsError = ref(false)
const failedDeliveries = ref<number | null>(null)
const deliveryStatusError = ref(false)
const myAssets = ref<MyAssetRecord[]>([])
const myAssetsLoading = ref(false)
@ -363,42 +378,62 @@ const roleActionHeading = computed(() => {
const roleActionStates = computed<Record<string, { label: string; badgeClass: string }>>(() => ({
'disposal-approval': {
label: `${pendingDisposals.value} 待审批`,
badgeClass: pendingDisposals.value > 0 ? 'badge-warning' : 'badge-neutral'
label: pendingDisposalsError.value
? '读取失败'
: pendingDisposals.value === null ? '读取中' : `${pendingDisposals.value} 待审批`,
badgeClass: pendingDisposalsError.value
? 'badge-error'
: Number(pendingDisposals.value || 0) > 0 ? 'badge-warning' : 'badge-neutral'
},
maintenance: {
label: `${formatNumber(stats.value?.maintenance_equipment || 0)} 维修中`,
badgeClass: Number(stats.value?.maintenance_equipment || 0) > 0 ? 'badge-warning' : 'badge-neutral'
label: dashboardStatsError.value
? '读取失败'
: `${formatNumber(stats.value?.maintenance_equipment || 0)} 维修中`,
badgeClass: dashboardStatsError.value
? 'badge-error'
: Number(stats.value?.maintenance_equipment || 0) > 0 ? 'badge-warning' : 'badge-neutral'
},
delivery: {
label: `${failedDeliveries.value} 失败`,
badgeClass: failedDeliveries.value > 0 ? 'badge-error' : 'badge-success'
label: deliveryStatusError.value
? '读取失败'
: failedDeliveries.value === null ? '读取中' : `${failedDeliveries.value} 失败`,
badgeClass: deliveryStatusError.value
? 'badge-error'
: Number(failedDeliveries.value || 0) > 0 ? 'badge-error' : 'badge-success'
}
}))
const dashboardMetaCards = computed(() => {
const data = stats.value
const numberValue = (value: number | undefined) => data ? formatNumber(value || 0) : '—'
const currencyValue = (value: number | undefined) => data ? `账面 ${formatCurrencyNoFraction(value || 0)}` : '统计读取失败'
const pendingValue = pendingDisposalsError.value || pendingDisposals.value === null
? '—'
: formatNumber(pendingDisposals.value)
const pendingSubvalue = pendingDisposalsError.value
? '读取失败'
: pendingDisposals.value === null ? '正在读取' : '报废申请'
if (currentRole.value === 'administrative') {
return [
{ label: '待我审批', value: formatNumber(pendingDisposals.value), subvalue: '报废申请', valueClass: pendingDisposals.value > 0 ? 'text-status-warning' : '' },
{ label: '库存总量', value: formatNumber(data?.total_equipment || 0), subvalue: '资产记录', valueClass: '' },
{ label: '可用库存', value: formatNumber(data?.available_equipment || 0), subvalue: '可入库分配', valueClass: 'text-status-success' },
{ label: '今日归还', value: formatNumber(data?.today_return || 0), subvalue: formatCurrencyNoFraction(data?.today_return_value || 0), valueClass: '' }
{ label: '待我审批', value: pendingValue, subvalue: pendingSubvalue, valueClass: pendingDisposalsError.value ? 'text-status-error' : Number(pendingDisposals.value || 0) > 0 ? 'text-status-warning' : '' },
{ label: '主资产档案', value: numberValue(data?.root_asset_records), subvalue: data ? `配件 ${formatNumber(data.accessory_equipment)}` : '统计读取失败', valueClass: '' },
{ label: '可分配', value: numberValue(data?.available_equipment), subvalue: data ? '当前可入库分配' : '统计读取失败', valueClass: data ? 'text-status-success' : '' },
{ label: '今日归还单', value: numberValue(data?.today_return), subvalue: currencyValue(data?.today_return_value), valueClass: '' }
]
}
if (currentRole.value === 'it') {
return [
{ label: '今日借出', value: formatNumber(data?.today_borrow || 0), subvalue: formatCurrencyNoFraction(data?.today_borrow_value || 0), valueClass: '' },
{ label: '今日归还', value: formatNumber(data?.today_return || 0), subvalue: formatCurrencyNoFraction(data?.today_return_value || 0), valueClass: '' },
{ label: '维修中', value: formatNumber(data?.maintenance_equipment || 0), subvalue: '待 IT 跟进', valueClass: Number(data?.maintenance_equipment || 0) > 0 ? 'text-status-warning' : '' },
{ label: '已借出', value: formatNumber(data?.borrowed_equipment || 0), subvalue: `利用率 ${formatPercent(data?.usage_rate || 0)}`, valueClass: '' }
{ label: '今日借出单', value: numberValue(data?.today_borrow), subvalue: currencyValue(data?.today_borrow_value), valueClass: '' },
{ label: '今日归还单', value: numberValue(data?.today_return), subvalue: currencyValue(data?.today_return_value), valueClass: '' },
{ label: '维修中', value: numberValue(data?.maintenance_equipment), subvalue: data ? '待 IT 跟进' : '统计读取失败', valueClass: Number(data?.maintenance_equipment || 0) > 0 ? 'text-status-warning' : '' },
{ label: '使用中', value: numberValue(data?.borrowed_equipment), subvalue: data ? `利用率 ${formatPercent(data.usage_rate)}` : '统计读取失败', valueClass: '' }
]
}
return [
{ label: '库存总量', value: formatNumber(data?.total_equipment || 0), subvalue: formatCurrencyNoFraction(data?.total_value || 0), valueClass: '' },
{ label: '待审批', value: formatNumber(pendingDisposals.value), subvalue: '报废申请', valueClass: pendingDisposals.value > 0 ? 'text-status-warning' : '' },
{ label: '失败投递', value: formatNumber(failedDeliveries.value), subvalue: deliveryStatusError.value ? '状态读取失败' : '钉钉通知', valueClass: failedDeliveries.value > 0 ? 'text-status-error' : 'text-status-success' },
{ label: '利用率', value: formatPercent(data?.usage_rate || 0), subvalue: `价值 ${formatPercent(data?.value_usage_rate || 0)}`, valueClass: '' }
{ label: '在册资产', value: numberValue(data?.total_equipment), subvalue: data ? `档案 ${formatNumber(data.root_asset_records)} · 配件 ${formatNumber(data.accessory_equipment)}` : '统计读取失败', valueClass: '' },
{ label: '待审批', value: pendingValue, subvalue: pendingSubvalue, valueClass: pendingDisposalsError.value ? 'text-status-error' : Number(pendingDisposals.value || 0) > 0 ? 'text-status-warning' : '' },
{ label: '失败投递', value: deliveryStatusError.value || failedDeliveries.value === null ? '—' : formatNumber(failedDeliveries.value), subvalue: deliveryStatusError.value ? '状态读取失败' : failedDeliveries.value === null ? '正在读取' : '钉钉通知', valueClass: deliveryStatusError.value || Number(failedDeliveries.value || 0) > 0 ? 'text-status-error' : failedDeliveries.value === null ? '' : 'text-status-success' },
{ label: '利用率', value: data ? formatPercent(data.usage_rate) : '—', subvalue: data ? `价值 ${formatPercent(data.value_usage_rate)}` : '统计读取失败', valueClass: '' }
]
})
@ -406,98 +441,151 @@ const statCards = computed(() => {
const data = stats.value
return [
{
title: '资产库存总量',
title: '在册资产',
value: formatNumber(data?.total_equipment || 0),
icon: CubeIcon,
color: 'primary' as const,
subtitle: `总价值 ${formatCurrencyNoFraction(data?.total_value || 0)}`
subtitle: `主资产档案 ${formatNumber(data?.root_asset_records || 0)} · 配件 ${formatNumber(data?.accessory_equipment || 0)}`
},
{
title: '可用库存',
title: '可分配',
value: formatNumber(data?.available_equipment || 0),
icon: CheckCircleIcon,
color: 'success' as const,
subtitle: `可用价值 ${formatCurrencyNoFraction(data?.available_value || 0)}`
},
{
title: '已借出库存',
title: '使用中',
value: formatNumber(data?.borrowed_equipment || 0),
icon: ArrowPathRoundedSquareIcon,
color: 'warning' as const,
subtitle: `借出价值 ${formatCurrencyNoFraction(data?.borrowed_value || 0)}`
},
{
title: '维护中库存',
title: '维修中',
value: formatNumber(data?.maintenance_equipment || 0),
icon: WrenchScrewdriverIcon,
color: 'error' as const,
subtitle: `维护价值 ${formatCurrencyNoFraction(data?.maintenance_value || 0)}`
},
{
title: '今日借出',
value: formatNumber(data?.today_borrow || 0),
icon: ChartBarSquareIcon,
color: 'primary' as const,
subtitle: `数量利用率 ${formatPercent(data?.usage_rate || 0)}`
},
{
title: '今日归还',
value: formatNumber(data?.today_return || 0),
icon: BanknotesIcon,
color: 'success' as const,
subtitle: `价值利用率 ${formatPercent(data?.value_usage_rate || 0)}`
}
]
})
const statusSummaryItems = computed(() => {
const data = stats.value
if (!data) return []
return [
{ label: '可分配', value: data.available_equipment, valueClass: 'text-status-success', always: true },
{ label: '使用中', value: data.borrowed_equipment, valueClass: 'text-status-warning', always: true },
{ label: '维修中', value: data.maintenance_equipment, valueClass: 'text-status-error', always: true },
{ label: '待报废', value: data.pending_disposal_equipment, valueClass: 'text-status-warning', always: false },
{ label: '已损坏', value: data.damaged_equipment, valueClass: 'text-status-error', always: false },
{ label: '其他状态', value: data.other_equipment, valueClass: 'text-text-primary', always: false }
].filter((item) => item.always || item.value > 0)
})
const statusChartData = computed(() => statusSummaryItems.value
.filter((item) => item.value > 0)
.map((item) => ({
value: item.value,
name: item.label,
itemStyle: {
color: {
可分配: '#10a37f',
使用中: '#f59e0b',
维修中: '#ef4444',
待报废: '#8b5cf6',
已损坏: '#dc2626',
其他状态: '#6b7280'
}[item.label]
}
})))
const topCategories = computed(() =>
[...(stats.value?.inventory_by_type || [])]
.sort((a, b) => b.total_quantity - a.total_quantity)
.slice(0, 5)
)
const getCategoryBreakdown = (item: DashboardStats['inventory_by_type'][number]) => {
const parts = [
`可分配 ${formatNumber(item.available_quantity)}`,
`使用中 ${formatNumber(item.borrowed_quantity)}`
]
if (item.is_child_type) {
parts.splice(1, 0, `空闲可挂载 ${formatNumber(item.mountable_available_quantity)}`)
}
if (item.maintenance_quantity > 0) parts.push(`维修 ${formatNumber(item.maintenance_quantity)}`)
if (item.pending_disposal_quantity > 0) parts.push(`待报废 ${formatNumber(item.pending_disposal_quantity)}`)
if (item.damaged_quantity > 0) parts.push(`损坏 ${formatNumber(item.damaged_quantity)}`)
if (item.other_quantity > 0) parts.push(`其他 ${formatNumber(item.other_quantity)}`)
return parts.join(' · ')
}
const fetchDashboardData = async () => {
isLoading.value = true
dashboardStatsError.value = ''
recentRecordsError.value = ''
try {
const [statsResponse, recordsResponse] = await Promise.all([
const [statsResult, recordsResult] = await Promise.allSettled([
getDashboardStats(),
getBorrowRecordList({ page: 1, pageSize: 8 })
])
if (statsResponse.data) {
stats.value = statsResponse.data
updateStatusChart()
updateCategoryChart()
if (statsResult.status === 'fulfilled' && statsResult.value.data) {
stats.value = statsResult.value.data
} else {
const reason = statsResult.status === 'rejected' ? statsResult.reason : new Error('统计接口未返回数据')
console.error('获取首页统计失败:', reason)
stats.value = null
dashboardStatsError.value = '首页统计读取失败'
statusChart?.dispose()
categoryChart?.dispose()
statusChart = null
categoryChart = null
}
if (recordsResponse.data) {
recentRecords.value = recordsResponse.data.list
if (recordsResult.status === 'fulfilled' && recordsResult.value.data) {
recentRecords.value = recordsResult.value.data.list
} else {
const reason = recordsResult.status === 'rejected' ? recordsResult.reason : new Error('借用记录接口未返回数据')
console.error('获取最近借用记录失败:', reason)
recentRecords.value = []
recentRecordsError.value = '最近借用记录读取失败'
}
} catch (error) {
console.error('获取仪表板数据失败:', error)
} finally {
isLoading.value = false
}
if (stats.value) {
await nextTick()
initCharts()
}
}
const fetchRoleSummary = async () => {
if (canApproveDisposals.value) {
pendingDisposalsError.value = false
try {
const response = await getDisposalList({ page: 1, pageSize: 1, scope: 'pending' })
pendingDisposals.value = response.data?.summary?.pending || response.data?.total || 0
pendingDisposals.value = response.data?.summary?.pending ?? response.data?.total ?? 0
} catch (error) {
console.error('获取待审批数量失败:', error)
pendingDisposals.value = null
pendingDisposalsError.value = true
}
}
if (isAdminRole.value) {
deliveryStatusError.value = false
try {
const response = await getDingTalkDeliveryStatus()
failedDeliveries.value = response.data?.failed || 0
failedDeliveries.value = response.data?.failed ?? 0
} catch (error) {
console.error('获取通知投递状态失败:', error)
deliveryStatusError.value = true
failedDeliveries.value = 0
failedDeliveries.value = null
}
}
}
@ -546,14 +634,10 @@ const updateStatusChart = () => {
formatter: '{b}\n{d}%',
color: '#a1a1a1'
},
data: [
{ value: stats.value.available_equipment || 0, name: '可用', itemStyle: { color: '#10a37f' } },
{ value: stats.value.borrowed_equipment || 0, name: '已借出', itemStyle: { color: '#f59e0b' } },
{ value: stats.value.maintenance_equipment || 0, name: '维护中', itemStyle: { color: '#ef4444' } }
]
data: statusChartData.value
}
]
})
}, true)
}
const updateCategoryChart = () => {
@ -617,7 +701,6 @@ const resizeHandler = () => {
onMounted(async () => {
if (canViewReports.value) {
await Promise.all([fetchDashboardData(), fetchRoleSummary()])
initCharts()
window.addEventListener('resize', resizeHandler)
} else {
await fetchMyAssets()

View File

@ -13,9 +13,9 @@
<div class="page-meta-grid">
<div class="page-meta">
<p class="page-meta__label">总记录</p>
<p class="page-meta__label">匹配主资产</p>
<p class="page-meta__value">{{ formatNumber(total) }}</p>
<p class="page-meta__subvalue">第 {{ currentPage }} 页</p>
<p class="page-meta__subvalue">筛选结果 · 第 {{ currentPage }} 页</p>
</div>
<div class="page-meta">
<p class="page-meta__label">当前页可用</p>
@ -23,9 +23,9 @@
<p class="page-meta__subvalue">借出 {{ formatNumber(pageSummary.borrowed) }}</p>
</div>
<div class="page-meta">
<p class="page-meta__label">挂载关系</p>
<p class="page-meta__label">当前页组合资产</p>
<p class="page-meta__value text-accent">{{ formatNumber(pageSummary.parentAssets) }}</p>
<p class="page-meta__subvalue">父 · 配件 {{ formatNumber(pageSummary.childAssets) }}</p>
<p class="page-meta__subvalue">挂载配件 {{ formatNumber(pageSummary.childAssets) }}</p>
</div>
</div>
</section>
@ -160,92 +160,22 @@
</div>
</section>
<!-- 使用操作区 - 页面级核心工作区 -->
<section v-if="canSelectAssets" class="card card-elevated border-accent-primary/20">
<div class="flex items-center gap-3 border-b border-border pb-3 mb-4">
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-accent-primary/15">
<svg class="h-4 w-4 text-accent-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
</svg>
</div>
<h3 class="text-base font-semibold text-text-primary">批量操作</h3>
<span class="badge badge-neutral">按权限显示</span>
</div>
<!-- 空状态:未选择资产 -->
<div v-if="selectedIds.length === 0" class="flex flex-col items-center justify-center py-8 lg:py-6">
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-bg-elevated">
<svg class="h-6 w-6 text-text-tertiary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
</svg>
</div>
<p class="mt-3 text-sm text-text-secondary">请在下方资产列表中选择需要处理的资产</p>
<p class="mt-1 text-xs text-text-tertiary">可执行操作会按当前账号权限显示。</p>
</div>
<!-- 已选择状态:展示选中资产摘要和操作按钮 -->
<div v-else class="space-y-4">
<!-- 选中摘要 -->
<div class="flex flex-wrap items-center gap-3">
<div class="flex items-center gap-2 rounded-lg bg-accent-primary/10 px-3 py-2">
<span class="text-lg font-semibold text-accent-primary">{{ selectedIds.length }}</span>
<span class="text-sm text-text-secondary">项资产已选择</span>
</div>
<button @click="selectedIds = []" class="text-sm text-text-tertiary hover:text-status-error transition">清空选择</button>
</div>
<!-- 选中资产列表(紧凑展示) -->
<div class="max-h-32 overflow-y-auto rounded-lg border border-border bg-bg-elevated/50">
<div class="flex flex-wrap gap-2 p-3">
<span
v-for="id in selectedIds.slice(0, 8)"
:key="id"
class="inline-flex items-center gap-1 rounded-md bg-bg-tertiary px-2 py-1 text-xs text-text-secondary"
<!-- 仅在实际勾选资产后出现,避免闲置操作区长期占用页面 -->
<section
v-if="canSelectAssets && selectedIds.length > 0"
class="panel flex flex-col gap-3 border-accent-primary/30 lg:flex-row lg:items-center lg:justify-between"
>
{{ getEquipmentBrief(id) }}
</span>
<span v-if="selectedIds.length > 8" class="inline-flex items-center rounded-md bg-bg-tertiary px-2 py-1 text-xs text-text-tertiary">
+{{ selectedIds.length - 8 }} 项
</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="badge badge-success">已选择 {{ selectedIds.length }} 项</span>
<span class="hidden text-xs text-text-secondary sm:inline">批量操作仅作用于当前勾选资产</span>
<button @click="selectedIds = []" class="text-xs text-text-tertiary transition hover:text-status-error">清空</button>
</div>
<!-- 操作按钮组 -->
<div class="flex flex-wrap gap-3 pt-2">
<button v-if="canBorrow" @click="openBatchBorrowModal" class="btn-primary">
<span class="flex items-center gap-2">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
批量借出
</span>
</button>
<button v-if="canBorrow" @click="openBatchReturnModal" class="btn-secondary btn-secondary--highlight">
<span class="flex items-center gap-2">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1" />
</svg>
批量归还
</span>
</button>
<button v-if="canTransfer" @click="openBatchTransferModal" class="btn-secondary">
<span class="flex items-center gap-2">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
</svg>
批量转移
</span>
</button>
<button v-if="canPrintLabels" @click="handleBatchPrintBarcode" class="btn-secondary">
<span class="flex items-center gap-2">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v1m6 11h2m-6 0h-2v4h2v-4zM8 12h2v4H8v-4zm10-4h2v4h-2V8zM6 8h2v4H6V8zM16 4h2v4h-2V4zM8 4h2v4H8V4z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
</svg>
打印标签
</span>
</button>
</div>
<div class="flex flex-wrap gap-2">
<button v-if="canBorrow" @click="openBatchBorrowModal" class="btn-primary">批量借出</button>
<button v-if="canBorrow" @click="openBatchReturnModal" class="btn-secondary btn-secondary--highlight">批量归还</button>
<button v-if="canTransfer" @click="openBatchTransferModal" class="btn-secondary">批量转移</button>
<button v-if="canPrintLabels" @click="handleBatchPrintBarcode" class="btn-secondary">打印标签</button>
</div>
</section>

View File

@ -3,25 +3,30 @@
<!-- 页面头部 -->
<section class="page-header">
<div>
<p class="page-kicker">Value Analytics</p>
<p class="page-kicker">资产价值</p>
<h2 class="page-title">资产统计分析</h2>
</div>
<div class="page-meta-grid">
<div class="page-meta">
<p class="page-meta__label">总资产价值</p>
<p class="page-meta__value">{{ formatCurrencyNoFraction(summary.total_value) }}</p>
<p class="page-meta__subvalue">当前筛选范围</p>
<p class="page-meta__label">在册资产价值</p>
<p class="page-meta__value">{{ formatSummaryCurrency(summary.total_value) }}</p>
<p class="page-meta__subvalue">当前筛选 · 不含已报废</p>
</div>
<div class="page-meta">
<p class="page-meta__label">可用价值</p>
<p class="page-meta__value text-status-success">{{ formatCurrencyNoFraction(summary.available_value) }}</p>
<p class="page-meta__value text-status-success">{{ formatSummaryCurrency(summary.available_value) }}</p>
<p class="page-meta__subvalue">可立即调拨与借用</p>
</div>
<div class="page-meta">
<p class="page-meta__label">借出价值</p>
<p class="page-meta__value text-status-warning">{{ formatCurrencyNoFraction(summary.borrowed_value) }}</p>
<p class="page-meta__value text-status-warning">{{ formatSummaryCurrency(summary.borrowed_value) }}</p>
<p class="page-meta__subvalue">当前在外资产</p>
</div>
<div class="page-meta">
<p class="page-meta__label">维修价值</p>
<p class="page-meta__value text-status-error">{{ formatSummaryCurrency(summary.maintenance_value) }}</p>
<p class="page-meta__subvalue">当前维修中的资产</p>
</div>
</div>
</section>
@ -59,36 +64,16 @@
</div>
</section>
<!-- 统计卡片 -->
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div class="card">
<p class="text-xs font-semibold uppercase tracking-wider text-text-tertiary">总资产价值</p>
<p class="mt-3 text-2xl font-semibold tracking-tight text-text-primary">
{{ formatCurrencyNoFraction(summary.total_value) }}
</p>
</div>
<div class="card">
<p class="text-xs font-semibold uppercase tracking-wider text-text-tertiary">可用价值</p>
<p class="mt-3 text-2xl font-semibold tracking-tight text-status-success">
{{ formatCurrencyNoFraction(summary.available_value) }}
</p>
</div>
<div class="card">
<p class="text-xs font-semibold uppercase tracking-wider text-text-tertiary">借出价值</p>
<p class="mt-3 text-2xl font-semibold tracking-tight text-status-warning">
{{ formatCurrencyNoFraction(summary.borrowed_value) }}
</p>
</div>
<div class="card">
<p class="text-xs font-semibold uppercase tracking-wider text-text-tertiary">维护价值</p>
<p class="mt-3 text-2xl font-semibold tracking-tight text-status-error">
{{ formatCurrencyNoFraction(summary.maintenance_value) }}
</p>
</div>
<section v-if="statisticsError" class="panel flex flex-wrap items-center justify-between gap-3 border-status-error/40">
<div>
<p class="text-sm font-medium text-status-error">价值统计读取失败</p>
<p class="mt-1 text-xs text-text-secondary">当前不展示 0 值,避免把接口异常误认为真实金额。</p>
</div>
<button type="button" class="btn-secondary" @click="fetchStatistics">重新读取</button>
</section>
<!-- 图表 -->
<div class="grid gap-6 lg:grid-cols-2">
<div v-if="statistics" class="grid gap-6 lg:grid-cols-2">
<section class="card">
<div class="list-header">
<h3 class="section-title">价值结构占比</h3>
@ -107,7 +92,7 @@
</div>
<!-- Top 10 表格 -->
<section class="card">
<section v-if="statistics" class="card">
<div class="list-header">
<h3 class="section-title">高价值单件资产 Top 10</h3>
<span class="badge badge-neutral">按单件价值排序</span>
@ -187,7 +172,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from 'vue'
import * as echarts from 'echarts'
import { getValueStatistics } from '@/api/statistics'
import { getAssetTypeOptions } from '@/api/assetType'
@ -197,6 +182,7 @@ import { formatCurrencyNoFraction } from '@/utils/format'
const valueChartRef = ref<HTMLElement>()
const typeChartRef = ref<HTMLElement>()
const loading = ref(false)
const statisticsError = ref('')
const assetTypes = ref<AssetType[]>([])
const statistics = ref<ValueStatistics | null>(null)
@ -213,9 +199,14 @@ const summary = computed(() => ({
total_value: statistics.value?.total_value || 0,
borrowed_value: statistics.value?.borrowed_value || 0,
available_value: statistics.value?.available_value || 0,
maintenance_value: statistics.value?.maintenance_value || 0
maintenance_value: statistics.value?.maintenance_value || 0,
other_value: statistics.value?.other_value || 0
}))
const formatSummaryCurrency = (value: number) => statistics.value
? formatCurrencyNoFraction(value)
: '—'
const fetchAssetTypes = async () => {
try {
const response = await getAssetTypeOptions()
@ -229,6 +220,7 @@ const fetchAssetTypes = async () => {
const fetchStatistics = async () => {
loading.value = true
statisticsError.value = ''
try {
const response = await getValueStatistics({
equipment_type: filters.equipment_type || undefined,
@ -237,10 +229,19 @@ const fetchStatistics = async () => {
})
if (response.data) {
statistics.value = response.data
await nextTick()
initCharts(response.data)
} else {
throw new Error('价值统计接口未返回数据')
}
} catch (error) {
console.error('获取统计数据失败:', error)
statistics.value = null
statisticsError.value = '价值统计读取失败'
valueChart?.dispose()
typeChart?.dispose()
valueChart = null
typeChart = null
} finally {
loading.value = false
}
@ -259,6 +260,7 @@ const initCharts = (data: ValueStatistics) => {
available: '#10b981', // status-success
borrowed: '#f59e0b', // status-warning
maintenance: '#ef4444', // status-danger
other: '#6b7280', // status-neutral
total: '#6366f1', // accent-primary
text: '#e2e8f0', // text-primary
textSecondary: '#94a3b8', // text-secondary
@ -292,7 +294,8 @@ const initCharts = (data: ValueStatistics) => {
data: [
{ value: data.available_value, name: '可用价值', itemStyle: { color: darkColors.available } },
{ value: data.borrowed_value, name: '借出价值', itemStyle: { color: darkColors.borrowed } },
{ value: data.maintenance_value, name: '维护价值', itemStyle: { color: darkColors.maintenance } }
{ value: data.maintenance_value, name: '维修价值', itemStyle: { color: darkColors.maintenance } },
{ value: data.other_value, name: '其他在册价值', itemStyle: { color: darkColors.other } }
]
}
]