fix: 校准首页统计与压缩页面布局
This commit is contained in:
parent
149d4510df
commit
a65de1d52f
@ -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
|
||||
}
|
||||
})
|
||||
@ -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
|
||||
},
|
||||
'获取成功'
|
||||
)
|
||||
|
||||
@ -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
|
||||
}, '获取成功')
|
||||
|
||||
@ -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
|
||||
|
||||
66
backend/src/utils/__tests__/dashboardStatistics.test.ts
Normal file
66
backend/src/utils/__tests__/dashboardStatistics.test.ts
Normal 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
|
||||
)
|
||||
})
|
||||
@ -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 },
|
||||
|
||||
136
backend/src/utils/dashboardStatistics.ts
Normal file
136
backend/src/utils/dashboardStatistics.ts
Normal 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
|
||||
}
|
||||
)
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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 } }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user