备份当前功能修复与界面改版基线

This commit is contained in:
WuYongJie 2026-03-16 14:29:28 +08:00
parent b8132cce4c
commit 6d371f347b
25 changed files with 2082 additions and 844 deletions

20
.env.example Normal file
View File

@ -0,0 +1,20 @@
MYSQL_ROOT_PASSWORD=change-me-local-password
MYSQL_DATABASE=equipment_management
SESSION_SECRET=change-me-session-secret
AUTH_MODE=local
PORT=3000
VITE_API_BASE_URL=/api
VITE_AUTH_MODE=local
SQLSERVER_HOST=
SQLSERVER_PORT=1433
SQLSERVER_DATABASE=
SQLSERVER_USER=
SQLSERVER_PASSWORD=
SQLSERVER_ENCRYPT=false
SQLSERVER_TRUST_CERT=true
HR_SYNC_ENABLED=false
HR_SYNC_SCHEDULE=03:00
HR_SYNC_ALLOWED_STATUS_VALUES=3
HR_SYNC_INACTIVE_STATUS_VALUES=5
HR_SYNC_EXCLUDED_DEPARTMENTS=供应商,快递公司,外部交流
HR_SYNC_EXCLUDED_NAME_KEYWORDS=

3
.gitignore vendored
View File

@ -1,4 +1,7 @@
hr_sync_cookie.txt
.env
.env.*
!.env.example
backend/node_modules/
backend/dist/
frontend/node_modules/

View File

@ -20,6 +20,12 @@
## 本地开发
先复制环境变量模板:
```bash
cp .env.example .env
```
分别安装依赖:
```bash
@ -61,7 +67,8 @@ cd /opt/equipment-management
在当前这台 Windows 开发机上,推荐直接使用:
```powershell
$env:DEPLOY_PASSWORD="jsjj8888"
$env:DEPLOY_HOST="<服务器地址>"
$env:DEPLOY_PASSWORD="<部署密码>"
.\scripts\publish-to-server.ps1
```
@ -112,11 +119,11 @@ POST /api/sync/hr/run
建议配置的环境变量:
```bash
SQLSERVER_HOST=218.93.191.16
SQLSERVER_HOST=<sqlserver-host>
SQLSERVER_PORT=1433
SQLSERVER_DATABASE=JSDataMiddlePlatform
SQLSERVER_USER=nanting
SQLSERVER_PASSWORD=***
SQLSERVER_DATABASE=<sqlserver-database>
SQLSERVER_USER=<sqlserver-user>
SQLSERVER_PASSWORD=<sqlserver-password>
SQLSERVER_ENCRYPT=false
SQLSERVER_TRUST_CERT=true
HR_SYNC_ENABLED=false
@ -137,9 +144,9 @@ HR_SYNC_EXCLUDED_NAME_KEYWORDS=
## 当前线上资源
- 前端:`http://1.13.22.133`
- 后端:`http://1.13.22.133:3000/api`
- 健康检查:`http://1.13.22.133:3000/api/health`
- 前端:`http://<server-host>`
- 后端:`http://<server-host>:3000/api`
- 健康检查:`http://<server-host>:3000/api/health`
## 注意事项

View File

@ -275,8 +275,8 @@ export const batchTransfer = async (req: Request, res: Response) => {
}
const department = await prisma.department.findUnique({ where: { id: Number(department_id) } })
if (!department) {
return errorResponse(res, '目标部门不存在')
if (!department || department.status !== 'active') {
return errorResponse(res, '目标部门不存在或已停用')
}
const results: any[] = []

View File

@ -2,6 +2,7 @@ import { Request, Response } from 'express'
import prisma from '../utils/prisma'
import { successResponse, errorResponse } from '../utils/response'
import { logOperation } from './operationLogController'
import { ensureNoActiveBorrowRecords } from '../utils/equipmentGuards'
// 创建报废申请
export const createDisposal = async (req: Request, res: Response) => {
@ -21,28 +22,35 @@ export const createDisposal = async (req: Request, res: Response) => {
return errorResponse(res, '资产不存在', 404)
}
if (equipment.status === '已借出') {
return errorResponse(res, '资产已借出,无法申请报废')
}
if (equipment.status === '待报废' || equipment.status === '已报废') {
return errorResponse(res, '资产已在报废流程中')
}
const disposal = await prisma.disposalRecord.create({
data: {
equipment_id: equipment.id,
equipment_code: equipment.equipment_code,
disposal_reason,
disposal_method,
estimated_value: Number(estimated_value) || 0,
applied_by: userId,
notes
}
})
try {
await ensureNoActiveBorrowRecords(equipment.id, '资产存在未归还记录,无法申请报废')
} catch (error: any) {
return errorResponse(res, error.message || '资产借用状态校验失败', 422)
}
await prisma.equipment.update({
where: { id: equipment.id },
data: { status: '待报废' }
const disposal = await prisma.$transaction(async (tx) => {
const created = await tx.disposalRecord.create({
data: {
equipment_id: equipment.id,
equipment_code: equipment.equipment_code,
disposal_reason,
disposal_method,
estimated_value: Number(estimated_value) || 0,
applied_by: userId,
notes
}
})
await tx.equipment.update({
where: { id: equipment.id },
data: { status: '待报废' }
})
return created
})
// 记录操作日志
@ -148,21 +156,24 @@ export const approveDisposal = async (req: Request, res: Response) => {
return errorResponse(res, '该申请已处理')
}
if (approved) {
await prisma.disposalRecord.update({
where: { id: Number(id) },
data: { status: 'approved', approved_by: userId, approved_at: new Date() }
})
} else {
await prisma.disposalRecord.update({
await prisma.$transaction(async (tx) => {
if (approved) {
await tx.disposalRecord.update({
where: { id: Number(id) },
data: { status: 'approved', approved_by: userId, approved_at: new Date() }
})
return
}
await tx.disposalRecord.update({
where: { id: Number(id) },
data: { status: 'rejected', approved_by: userId, approved_at: new Date(), notes: reject_reason }
})
await prisma.equipment.update({
await tx.equipment.update({
where: { id: disposal.equipment_id },
data: { status: '可用' }
})
}
})
// 记录操作日志
const username = res.locals.user?.username || res.locals.user?.name || 'unknown'
@ -200,19 +211,21 @@ export const completeDisposal = async (req: Request, res: Response) => {
return errorResponse(res, '只有已审批的申请才能完成处置')
}
await prisma.disposalRecord.update({
where: { id: Number(id) },
data: {
status: 'completed',
actual_value: actual_value !== undefined ? Number(actual_value) : disposal.estimated_value,
completed_at: new Date(),
notes: notes || disposal.notes
}
})
await prisma.$transaction(async (tx) => {
await tx.disposalRecord.update({
where: { id: Number(id) },
data: {
status: 'completed',
actual_value: actual_value !== undefined ? Number(actual_value) : disposal.estimated_value,
completed_at: new Date(),
notes: notes || disposal.notes
}
})
await prisma.equipment.update({
where: { id: disposal.equipment_id },
data: { status: '已报废' }
await tx.equipment.update({
where: { id: disposal.equipment_id },
data: { status: '已报废' }
})
})
// 记录操作日志

View File

@ -2,6 +2,7 @@ import { Request, Response } from 'express'
import prisma from '../utils/prisma'
import { successResponse, errorResponse } from '../utils/response'
import { logOperation } from './operationLogController'
import { ensureNoActiveBorrowRecords } from '../utils/equipmentGuards'
// 创建维修记录
export const createMaintenance = async (req: Request, res: Response) => {
@ -21,9 +22,6 @@ export const createMaintenance = async (req: Request, res: Response) => {
return errorResponse(res, '资产不存在', 404)
}
if (equipment.status === '已借出') {
return errorResponse(res, '资产已借出,无法送修')
}
if (equipment.status === '维护中') {
return errorResponse(res, '资产已在维修中')
}
@ -31,21 +29,31 @@ export const createMaintenance = async (req: Request, res: Response) => {
return errorResponse(res, '资产已报废,无法送修')
}
const maintenance = await prisma.maintenanceRecord.create({
data: {
equipment_id: equipment.id,
equipment_code: equipment.equipment_code,
issue_description,
repair_vendor,
estimated_cost: Number(estimated_cost) || 0,
expected_end_date: expected_end_date ? new Date(expected_end_date) : null,
created_by: userId
}
})
try {
await ensureNoActiveBorrowRecords(equipment.id, '资产存在未归还记录,无法送修')
} catch (error: any) {
return errorResponse(res, error.message || '资产借用状态校验失败', 422)
}
await prisma.equipment.update({
where: { id: equipment.id },
data: { status: '维护中' }
const maintenance = await prisma.$transaction(async (tx) => {
const created = await tx.maintenanceRecord.create({
data: {
equipment_id: equipment.id,
equipment_code: equipment.equipment_code,
issue_description,
repair_vendor,
estimated_cost: Number(estimated_cost) || 0,
expected_end_date: expected_end_date ? new Date(expected_end_date) : null,
created_by: userId
}
})
await tx.equipment.update({
where: { id: equipment.id },
data: { status: '维护中' }
})
return created
})
// 记录操作日志
@ -177,19 +185,21 @@ export const completeMaintenance = async (req: Request, res: Response) => {
return errorResponse(res, '只能完成进行中的维修记录')
}
await prisma.maintenanceRecord.update({
where: { id: Number(id) },
data: {
status: 'completed',
actual_cost: actual_cost !== undefined ? Number(actual_cost) : maintenance.estimated_cost,
actual_end_date: new Date(),
repair_result
}
})
await prisma.$transaction(async (tx) => {
await tx.maintenanceRecord.update({
where: { id: Number(id) },
data: {
status: 'completed',
actual_cost: actual_cost !== undefined ? Number(actual_cost) : maintenance.estimated_cost,
actual_end_date: new Date(),
repair_result
}
})
await prisma.equipment.update({
where: { id: maintenance.equipment_id },
data: { status: '可用' }
await tx.equipment.update({
where: { id: maintenance.equipment_id },
data: { status: '可用' }
})
})
// 记录操作日志
@ -229,14 +239,16 @@ export const cancelMaintenance = async (req: Request, res: Response) => {
return errorResponse(res, '只能取消进行中的维修记录')
}
await prisma.maintenanceRecord.update({
where: { id: Number(id) },
data: { status: 'cancelled', repair_result: reason || '已取消' }
})
await prisma.$transaction(async (tx) => {
await tx.maintenanceRecord.update({
where: { id: Number(id) },
data: { status: 'cancelled', repair_result: reason || '已取消' }
})
await prisma.equipment.update({
where: { id: maintenance.equipment_id },
data: { status: '可用' }
await tx.equipment.update({
where: { id: maintenance.equipment_id },
data: { status: '可用' }
})
})
// 记录操作日志

View File

@ -1,9 +1,9 @@
import { Request, Response } from 'express'
import { Decimal } from '@prisma/client/runtime/library'
import prisma from '../utils/prisma'
import { successResponse, errorResponse } from '../utils/response'
import { Decimal } from '@prisma/client/runtime/library'
import { ensureNoActiveBorrowRecords } from '../utils/equipmentGuards'
// 计算父资产含配件总价值
async function calculateTotalValueWithChildren(parentId: number): Promise<Decimal> {
const parent = await prisma.equipment.findUnique({
where: { id: parentId },
@ -24,7 +24,6 @@ async function calculateTotalValueWithChildren(parentId: number): Promise<Decima
return parent.unit_price.add(childrenValue)
}
// 更新父资产的含配件总价值
async function updateParentTotalValue(parentId: number): Promise<void> {
const totalValue = await calculateTotalValueWithChildren(parentId)
await prisma.equipment.update({
@ -33,31 +32,72 @@ async function updateParentTotalValue(parentId: number): Promise<void> {
})
}
// 挂载子资产到父资产
const ensureMountableEquipment = async (
equipment: { id: number; status: string; remaining_quantity: number },
label: string
) => {
if (equipment.status !== '可用' || equipment.remaining_quantity <= 0) {
throw new Error(`${label}当前状态不可挂载`)
}
await ensureNoActiveBorrowRecords(equipment.id, `${label}存在未归还记录,当前不可挂载`)
}
const ensureNoMountCycle = async (parentId: number, childId: number) => {
if (parentId === childId) {
throw new Error('父资产和子资产不能是同一条记录')
}
let current = await prisma.equipment.findUnique({
where: { id: parentId },
select: { parent_id: true }
})
while (current?.parent_id) {
if (current.parent_id === childId) {
throw new Error('不能把资产挂载到自己的下级节点上')
}
current = await prisma.equipment.findUnique({
where: { id: current.parent_id },
select: { parent_id: true }
})
}
}
export const mountChild = async (req: Request, res: Response) => {
try {
const { parentId } = req.params
const numericParentId = Number(req.params.parentId)
const { child_id, notes } = req.body as { child_id: number; notes?: string }
const userId = Number(res.locals.user?.id)
if (!userId) {
return errorResponse(res, '用户未登录', 401)
}
// 验证父资产存在
try {
await ensureNoMountCycle(numericParentId, child_id)
} catch (error: any) {
return errorResponse(res, error.message || '挂载关系校验失败', 422)
}
const parent = await prisma.equipment.findUnique({
where: { id: Number(parentId) },
where: { id: numericParentId },
include: { assetType: true }
})
if (!parent) {
return errorResponse(res, '父资产不存在', 404)
}
// 验证父资产类型支持挂载
if (!parent.assetType?.can_be_parent) {
return errorResponse(res, '该资产类型不支持挂载配件', 400)
}
// 验证子资产存在
try {
await ensureMountableEquipment(parent, '父资产')
} catch (error: any) {
return errorResponse(res, error.message || '父资产状态校验失败', 422)
}
const child = await prisma.equipment.findUnique({
where: { id: child_id },
include: { assetType: true }
@ -65,13 +105,16 @@ export const mountChild = async (req: Request, res: Response) => {
if (!child) {
return errorResponse(res, '子资产不存在', 404)
}
// 验证子资产类型支持被挂载
if (!child.assetType?.can_be_child) {
return errorResponse(res, '该资产类型不能作为配件挂载', 400)
}
// 验证子资产未被挂载
try {
await ensureMountableEquipment(child, '子资产')
} catch (error: any) {
return errorResponse(res, error.message || '子资产状态校验失败', 422)
}
if (child.parent_id) {
const currentParent = await prisma.equipment.findUnique({
where: { id: child.parent_id }
@ -79,22 +122,19 @@ export const mountChild = async (req: Request, res: Response) => {
return errorResponse(res, `该配件已挂载到 ${currentParent?.equipment_name || '其他资产'}`, 400)
}
// 执行挂载操作
await prisma.$transaction(async (tx) => {
// 更新子资产
await tx.equipment.update({
where: { id: child_id },
data: {
parent_id: Number(parentId),
parent_id: numericParentId,
mount_status: 'mounted'
}
})
// 记录挂载历史
await tx.mountHistory.create({
data: {
child_id,
parent_id: Number(parentId),
parent_id: numericParentId,
operation_type: 'mount',
operated_by: userId,
notes
@ -102,12 +142,10 @@ export const mountChild = async (req: Request, res: Response) => {
})
})
// 更新父资产含配件总价值
await updateParentTotalValue(Number(parentId))
await updateParentTotalValue(numericParentId)
// 获取更新后的数据
const updatedParent = await prisma.equipment.findUnique({
where: { id: Number(parentId) },
where: { id: numericParentId },
include: {
children: {
select: {
@ -129,30 +167,41 @@ export const mountChild = async (req: Request, res: Response) => {
}
}
// 从父资产卸载子资产
export const unmountChild = async (req: Request, res: Response) => {
try {
const { parentId } = req.params
const numericParentId = Number(req.params.parentId)
const { child_id, notes } = req.body as { child_id: number; notes?: string }
const userId = Number(res.locals.user?.id)
if (!userId) {
return errorResponse(res, '用户未登录', 401)
}
// 验证子资产存在且挂载在该父资产上
const parent = await prisma.equipment.findUnique({
where: { id: numericParentId }
})
if (!parent) {
return errorResponse(res, '父资产不存在', 404)
}
const child = await prisma.equipment.findUnique({
where: { id: child_id }
})
if (!child) {
return errorResponse(res, '子资产不存在', 404)
}
if (child.parent_id !== Number(parentId)) {
if (child.parent_id !== numericParentId) {
return errorResponse(res, '该配件未挂载到此资产', 400)
}
// 执行卸载操作
try {
await ensureMountableEquipment(parent, '父资产')
await ensureMountableEquipment(child, '子资产')
} catch (error: any) {
return errorResponse(res, error.message || '卸载状态校验失败', 422)
}
await prisma.$transaction(async (tx) => {
// 更新子资产
await tx.equipment.update({
where: { id: child_id },
data: {
@ -161,11 +210,10 @@ export const unmountChild = async (req: Request, res: Response) => {
}
})
// 记录卸载历史
await tx.mountHistory.create({
data: {
child_id,
parent_id: Number(parentId),
parent_id: numericParentId,
operation_type: 'unmount',
operated_by: userId,
notes
@ -173,8 +221,7 @@ export const unmountChild = async (req: Request, res: Response) => {
})
})
// 更新父资产含配件总价值
await updateParentTotalValue(Number(parentId))
await updateParentTotalValue(numericParentId)
successResponse(res, null, '卸载成功')
} catch (error: any) {
@ -182,29 +229,54 @@ export const unmountChild = async (req: Request, res: Response) => {
}
}
// 更换配件(卸载旧配件 + 挂载新配件)
export const replaceChild = async (req: Request, res: Response) => {
try {
const { parentId } = req.params
const numericParentId = Number(req.params.parentId)
const { old_child_id, new_child_id, notes } = req.body as {
old_child_id: number
new_child_id: number
notes?: string
}
const userId = Number(res.locals.user?.id)
if (!userId) {
return errorResponse(res, '用户未登录', 401)
}
if (old_child_id === new_child_id) {
return errorResponse(res, '旧配件和新配件不能相同', 422)
}
try {
await ensureNoMountCycle(numericParentId, new_child_id)
} catch (error: any) {
return errorResponse(res, error.message || '更换关系校验失败', 422)
}
// 验证旧配件挂载在该父资产上
const oldChild = await prisma.equipment.findUnique({
where: { id: old_child_id }
})
if (!oldChild || oldChild.parent_id !== Number(parentId)) {
if (!oldChild || oldChild.parent_id !== numericParentId) {
return errorResponse(res, '旧配件未挂载到此资产', 400)
}
// 验证新配件存在且未被挂载
const parent = await prisma.equipment.findUnique({
where: { id: numericParentId },
include: { assetType: true }
})
if (!parent) {
return errorResponse(res, '父资产不存在', 404)
}
if (!parent.assetType?.can_be_parent) {
return errorResponse(res, '该资产类型不支持挂载配件', 400)
}
try {
await ensureMountableEquipment(parent, '父资产')
await ensureMountableEquipment(oldChild, '旧配件')
} catch (error: any) {
return errorResponse(res, error.message || '挂载状态校验失败', 422)
}
const newChild = await prisma.equipment.findUnique({
where: { id: new_child_id },
include: { assetType: true }
@ -219,9 +291,13 @@ export const replaceChild = async (req: Request, res: Response) => {
return errorResponse(res, '新配件已挂载到其他资产', 400)
}
// 在事务中执行更换操作
try {
await ensureMountableEquipment(newChild, '新配件')
} catch (error: any) {
return errorResponse(res, error.message || '新配件状态校验失败', 422)
}
await prisma.$transaction(async (tx) => {
// 卸载旧配件
await tx.equipment.update({
where: { id: old_child_id },
data: {
@ -230,24 +306,22 @@ export const replaceChild = async (req: Request, res: Response) => {
}
})
// 挂载新配件
await tx.equipment.update({
where: { id: new_child_id },
data: {
parent_id: Number(parentId),
parent_id: numericParentId,
mount_status: 'mounted'
}
})
// 记录更换历史
await tx.mountHistory.create({
data: {
child_id: old_child_id,
parent_id: Number(parentId),
parent_id: numericParentId,
operation_type: 'replace',
operated_by: userId,
notes,
old_parent_id: Number(parentId),
old_parent_id: numericParentId,
new_parent_id: null
}
})
@ -255,18 +329,17 @@ export const replaceChild = async (req: Request, res: Response) => {
await tx.mountHistory.create({
data: {
child_id: new_child_id,
parent_id: Number(parentId),
parent_id: numericParentId,
operation_type: 'replace',
operated_by: userId,
notes,
old_parent_id: null,
new_parent_id: Number(parentId)
new_parent_id: numericParentId
}
})
})
// 更新父资产含配件总价值
await updateParentTotalValue(Number(parentId))
await updateParentTotalValue(numericParentId)
successResponse(res, null, '更换成功')
} catch (error: any) {
@ -274,7 +347,6 @@ export const replaceChild = async (req: Request, res: Response) => {
}
}
// 获取父资产的子资产列表
export const getChildren = async (req: Request, res: Response) => {
try {
const { parentId } = req.params
@ -307,17 +379,20 @@ export const getChildren = async (req: Request, res: Response) => {
new Decimal(0)
)
successResponse(res, {
children: parent.children,
total_children_value: Number(totalChildrenValue),
total_value_with_children: Number(parent.total_value_with_children)
}, '获取成功')
successResponse(
res,
{
children: parent.children,
total_children_value: Number(totalChildrenValue),
total_value_with_children: Number(parent.total_value_with_children)
},
'获取成功'
)
} catch (error: any) {
errorResponse(res, error.message || '获取子资产列表失败')
}
}
// 获取资产的挂载历史
export const getMountHistory = async (req: Request, res: Response) => {
try {
const { id } = req.params
@ -348,18 +423,16 @@ export const getMountHistory = async (req: Request, res: Response) => {
orderBy: { operated_at: 'desc' }
})
// 补充父资产信息
const historyWithParent = await Promise.all(
history.map(async (h) => {
history.map(async (item) => {
let parentInfo = null
if (h.parent_id) {
const parent = await prisma.equipment.findUnique({
where: { id: h.parent_id },
if (item.parent_id) {
parentInfo = await prisma.equipment.findUnique({
where: { id: item.parent_id },
select: { id: true, equipment_code: true, equipment_name: true }
})
parentInfo = parent
}
return { ...h, parent: parentInfo }
return { ...item, parent: parentInfo }
})
)
@ -369,23 +442,27 @@ export const getMountHistory = async (req: Request, res: Response) => {
}
}
// 获取可挂载的子资产列表(未挂载的配件类型资产)
export const getAvailableChildren = async (req: Request, res: Response) => {
try {
const { keyword, equipment_type } = req.query
// 获取可作为子资产的类型
const childTypes = await prisma.assetType.findMany({
where: { can_be_child: true, status: 'active' },
select: { name: true }
})
const childTypeNames = childTypes.map(t => t.name)
const childTypeNames = childTypes.map((item) => item.name)
const where: any = {
parent_id: null,
mount_status: { in: ['none', 'unmounted'] },
equipment_type: { in: childTypeNames },
status: '可用'
status: '可用',
remaining_quantity: { gt: 0 },
borrow_records: {
none: {
status: '已借出'
}
}
}
if (keyword) {

View File

@ -1,14 +1,78 @@
import { Request, Response } from 'express'
import { Prisma } from '@prisma/client'
import prisma from '../utils/prisma'
import { successResponse, errorResponse } from '../utils/response'
const toNumber = (value: unknown) => {
if (value === null || value === undefined) return 0
const parsed = Number(value)
return Number.isNaN(parsed) ? 0 : parsed
}
const buildEquipmentWhere = (equipmentType?: string, startDate?: string, endDate?: string) => {
const where: Prisma.EquipmentWhereInput = {}
if (equipmentType) {
where.equipment_type = equipmentType
}
if (startDate || endDate) {
where.created_at = {}
if (startDate) {
where.created_at.gte = new Date(startDate)
}
if (endDate) {
const endOfDay = new Date(endDate)
endOfDay.setHours(23, 59, 59, 999)
where.created_at.lte = endOfDay
}
}
return where
}
// 获取首页统计数据
export const getDashboardStats = async (req: Request, res: Response) => {
try {
const startOfToday = new Date(new Date().setHours(0, 0, 0, 0))
const [equipmentList, todayBorrowCount, todayReturnCount] = await Promise.all([
prisma.equipment.findMany(),
const [
equipmentTotals,
maintenanceTotals,
inventoryByType,
todayBorrowCount,
todayReturnCount,
todayBorrowValueRows,
todayReturnValueRows
] = await Promise.all([
prisma.equipment.aggregate({
_sum: {
total_quantity: true,
remaining_quantity: true,
total_value: true,
borrowed_value: true,
available_value: true
}
}),
prisma.equipment.aggregate({
where: { status: '维护中' },
_sum: {
total_quantity: true,
available_value: true
}
}),
prisma.equipment.groupBy({
by: ['equipment_type'],
_sum: {
total_quantity: true,
remaining_quantity: true
},
orderBy: {
_sum: {
total_quantity: 'desc'
}
}
}),
prisma.borrowRecord.count({
where: {
borrow_time: {
@ -22,92 +86,34 @@ export const getDashboardStats = async (req: Request, res: Response) => {
gte: startOfToday
}
}
})
}),
prisma.$queryRaw<Array<{ total: unknown }>>(Prisma.sql`
SELECT COALESCE(SUM(e.unit_price), 0) AS total
FROM borrow_record br
INNER JOIN equipment e ON e.id = br.equipment_id
WHERE br.borrow_time >= ${startOfToday}
`),
prisma.$queryRaw<Array<{ total: unknown }>>(Prisma.sql`
SELECT COALESCE(SUM(e.unit_price), 0) AS total
FROM borrow_record br
INNER JOIN equipment e ON e.id = br.equipment_id
WHERE br.return_time >= ${startOfToday}
`)
])
const totalEquipmentQuantity = equipmentList.reduce((sum, eq) => sum + (eq.total_quantity || 0), 0)
const availableEquipmentQuantity = equipmentList.reduce(
(sum, eq) => sum + (eq.remaining_quantity || 0),
const totalEquipmentQuantity = toNumber(equipmentTotals._sum.total_quantity)
const availableEquipmentQuantity = toNumber(equipmentTotals._sum.remaining_quantity)
const borrowedEquipmentQuantity = Math.max(
totalEquipmentQuantity - availableEquipmentQuantity,
0
)
const borrowedEquipmentQuantity = equipmentList.reduce(
(sum, eq) => sum + Math.max((eq.total_quantity || 0) - (eq.remaining_quantity || 0), 0),
0
)
const maintenanceEquipmentQuantity = equipmentList
.filter((eq) => eq.status === '维护中')
.reduce((sum, eq) => sum + (eq.total_quantity || 0), 0)
const inventoryByTypeMap = equipmentList.reduce((acc, eq) => {
const type = eq.equipment_type || '其他'
if (!acc[type]) {
acc[type] = {
equipment_type: type,
total_quantity: 0,
available_quantity: 0,
borrowed_quantity: 0
}
}
const availableQuantity = eq.remaining_quantity || 0
const totalQuantity = eq.total_quantity || 0
acc[type].total_quantity += totalQuantity
acc[type].available_quantity += availableQuantity
acc[type].borrowed_quantity += Math.max(totalQuantity - availableQuantity, 0)
return acc
}, {} as Record<
string,
{
equipment_type: string
total_quantity: number
available_quantity: number
borrowed_quantity: number
}
>)
const inventoryByType = Object.values(inventoryByTypeMap).filter(
(item) => item.total_quantity > 0
)
const totalValue = equipmentList.reduce((sum, eq) => sum + Number(eq.total_value), 0)
const borrowedValue = equipmentList.reduce((sum, eq) => sum + Number(eq.borrowed_value), 0)
const availableValue = equipmentList.reduce((sum, eq) => sum + Number(eq.available_value), 0)
const maintenanceValue = equipmentList
.filter(eq => eq.status === '维护中')
.reduce((sum, eq) => sum + Number(eq.available_value), 0)
// 获取今日借用/归还价值
const todayBorrowRecords = await prisma.borrowRecord.findMany({
where: {
borrow_time: {
gte: startOfToday
}
},
include: {
equipment: true
}
})
const todayBorrowValue = todayBorrowRecords.reduce(
(sum, record) => sum + Number(record.equipment.unit_price),
0
)
const todayReturnRecords = await prisma.borrowRecord.findMany({
where: {
return_time: {
gte: startOfToday
}
},
include: {
equipment: true
}
})
const todayReturnValue = todayReturnRecords.reduce(
(sum, record) => sum + Number(record.equipment.unit_price),
0
)
const maintenanceEquipmentQuantity = toNumber(maintenanceTotals._sum.total_quantity)
const totalValue = toNumber(equipmentTotals._sum.total_value)
const borrowedValue = toNumber(equipmentTotals._sum.borrowed_value)
const availableValue = toNumber(equipmentTotals._sum.available_value)
const maintenanceValue = toNumber(maintenanceTotals._sum.available_value)
const todayBorrowValue = toNumber(todayBorrowValueRows[0]?.total)
const todayReturnValue = toNumber(todayReturnValueRows[0]?.total)
const usageRate =
totalEquipmentQuantity > 0 ? (borrowedEquipmentQuantity / totalEquipmentQuantity) * 100 : 0
const valueUsageRate = totalValue > 0 ? (borrowedValue / totalValue) * 100 : 0
@ -128,6 +134,17 @@ export const getDashboardStats = async (req: Request, res: Response) => {
today_borrow_value: todayBorrowValue,
today_return_value: todayReturnValue,
inventory_by_type: inventoryByType
.map((item) => {
const totalQuantity = toNumber(item._sum.total_quantity)
const availableQuantity = toNumber(item._sum.remaining_quantity)
return {
equipment_type: item.equipment_type || '其他',
total_quantity: totalQuantity,
available_quantity: availableQuantity,
borrowed_quantity: Math.max(totalQuantity - availableQuantity, 0)
}
})
.filter((item) => item.total_quantity > 0)
}, '获取成功')
} catch (error: any) {
errorResponse(res, error.message || '获取统计数据失败')
@ -138,70 +155,80 @@ export const getDashboardStats = async (req: Request, res: Response) => {
export const getValueStatistics = async (req: Request, res: Response) => {
try {
const { equipment_type, start_date, end_date } = req.query
const where: any = {}
if (equipment_type) {
where.equipment_type = equipment_type
}
if (start_date || end_date) {
where.created_at = {}
if (start_date) {
where.created_at.gte = new Date(start_date as string)
}
if (end_date) {
where.created_at.lte = new Date(end_date as string)
}
}
const equipmentList = await prisma.equipment.findMany({ where })
const totalValue = equipmentList.reduce((sum, eq) => sum + Number(eq.total_value), 0)
const borrowedValue = equipmentList.reduce((sum, eq) => sum + Number(eq.borrowed_value), 0)
const availableValue = equipmentList.reduce((sum, eq) => sum + Number(eq.available_value), 0)
const maintenanceValue = equipmentList
.filter(eq => eq.status === '维护中')
.reduce((sum, eq) => sum + Number(eq.available_value), 0)
// 按资产类型统计,避免沿用旧项目中的固定类型名单
const valueByTypeMap = equipmentList.reduce((acc, eq) => {
const type = eq.equipment_type || '未分类'
if (!acc[type]) {
acc[type] = {
equipment_type: type,
total_value: 0,
borrowed_value: 0,
available_value: 0
const where = buildEquipmentWhere(
typeof equipment_type === 'string' ? equipment_type : undefined,
typeof start_date === 'string' ? start_date : undefined,
typeof end_date === 'string' ? end_date : undefined
)
const [summary, maintenanceSummary, valueByTypeGroups, topEquipmentRows] = await Promise.all([
prisma.equipment.aggregate({
where,
_sum: {
total_value: true,
borrowed_value: true,
available_value: true
}
}
}),
prisma.equipment.aggregate({
where: {
...where,
status: '维护中'
},
_sum: {
available_value: true
}
}),
prisma.equipment.groupBy({
by: ['equipment_type'],
where,
_sum: {
total_value: true,
borrowed_value: true,
available_value: true
},
orderBy: {
_sum: {
total_value: 'desc'
}
}
}),
prisma.equipment.findMany({
where,
select: {
equipment_name: true,
unit_price: true,
total_value: true,
borrowed_value: true,
available_value: true
},
orderBy: {
total_value: 'desc'
},
take: 10
})
])
acc[type].total_value += Number(eq.total_value)
acc[type].borrowed_value += Number(eq.borrowed_value)
acc[type].available_value += Number(eq.available_value)
return acc
}, {} as Record<string, {
equipment_type: string
total_value: number
borrowed_value: number
available_value: number
}>)
const valueByType = Object.values(valueByTypeMap)
.filter((item) => item.total_value > 0)
.sort((a, b) => b.total_value - a.total_value)
// 价值最高的资产(Top 10)
const topEquipment = equipmentList
.map(eq => ({
equipment_name: eq.equipment_name,
unit_price: Number(eq.unit_price),
total_value: Number(eq.total_value),
borrowed_value: Number(eq.borrowed_value),
available_value: Number(eq.available_value)
const totalValue = toNumber(summary._sum.total_value)
const borrowedValue = toNumber(summary._sum.borrowed_value)
const availableValue = toNumber(summary._sum.available_value)
const maintenanceValue = toNumber(maintenanceSummary._sum.available_value)
const valueByType = valueByTypeGroups
.map((item) => ({
equipment_type: item.equipment_type || '未分类',
total_value: toNumber(item._sum.total_value),
borrowed_value: toNumber(item._sum.borrowed_value),
available_value: toNumber(item._sum.available_value)
}))
.sort((a, b) => b.total_value - a.total_value)
.slice(0, 10)
.filter((item) => item.total_value > 0)
const topEquipment = topEquipmentRows.map((eq) => ({
equipment_name: eq.equipment_name,
unit_price: toNumber(eq.unit_price),
total_value: toNumber(eq.total_value),
borrowed_value: toNumber(eq.borrowed_value),
available_value: toNumber(eq.available_value)
}))
successResponse(res, {
total_value: totalValue,

View File

@ -75,7 +75,7 @@ app.use('/api/batch-import', batchImportRoutes)
app.use('/api/mount', protect(), mountRoutes)
app.use('/api/disposal', protect(), disposalRoutes)
app.use('/api/maintenance', protect(), maintenanceRoutes)
app.use('/api/batch', protect(), batchRoutes)
app.use('/api/batch', protect(), requireAdmin(), batchRoutes)
app.use('/api/export', protect(), exportRoutes)
app.use('/api/search', protect(), searchRoutes)
app.use('/api/sync', protect(), requireAdmin(), syncRoutes)

View File

@ -1,5 +1,6 @@
import type { Request, Response, NextFunction, RequestHandler } from 'express'
import { keycloak } from '../utils/keycloak'
import { resolveOrProvisionAuthUser } from '../utils/authIdentity'
const isLocalAuth = () => process.env.AUTH_MODE === 'local'
@ -19,17 +20,23 @@ export function protect(): RequestHandler {
const keycloakMiddleware = keycloak.protect()
return function keycloakWrapper(req: Request, res: Response, next: NextFunction) {
keycloakMiddleware(req, res, (err?: any) => {
keycloakMiddleware(req, res, async (err?: any) => {
if (err) {
return next(err)
}
const tokenContent = (req as any).kauth?.grant?.access_token?.content as Record<string, any> | undefined
if (tokenContent) {
res.locals.user = {
id: tokenContent.sub,
username: tokenContent.preferred_username,
name: tokenContent.name || tokenContent.preferred_username,
roles: tokenContent.realm_access?.roles || []
try {
const resolvedUser = await resolveOrProvisionAuthUser({
username: tokenContent.preferred_username,
name: tokenContent.name || tokenContent.preferred_username,
email: tokenContent.email,
roles: tokenContent.realm_access?.roles || []
})
res.locals.user = resolvedUser
} catch (resolveError) {
return next(resolveError)
}
}
next()

View File

@ -0,0 +1,97 @@
import bcrypt from 'bcryptjs'
import prisma from './prisma'
type AuthIdentityInput = {
username?: string
name?: string
email?: string
roles?: string[]
}
type AuthIdentityUser = {
id: number
username: string
name: string
email?: string | null
role: string
roles: string[]
status: string
}
const SHADOW_PASSWORD_ROUNDS = 10
const buildShadowPassword = async (username: string) => {
const seed = `keycloak-shadow:${username}:${Date.now()}:${Math.random()}`
return bcrypt.hash(seed, SHADOW_PASSWORD_ROUNDS)
}
export const resolveOrProvisionAuthUser = async (
identity: AuthIdentityInput
): Promise<AuthIdentityUser> => {
const username = identity.username?.trim()
if (!username) {
throw new Error('认证信息缺少用户名')
}
const existing = await prisma.user.findUnique({
where: { username }
})
if (existing) {
return {
id: existing.id,
username: existing.username,
name: existing.name,
email: existing.email,
role: existing.role,
roles: identity.roles || [existing.role],
status: existing.status
}
}
try {
const created = await prisma.user.create({
data: {
username,
password: await buildShadowPassword(username),
name: identity.name?.trim() || username,
email: identity.email?.trim() || null,
role: identity.roles?.includes('admin') ? 'admin' : 'user',
status: 'active'
}
})
return {
id: created.id,
username: created.username,
name: created.name,
email: created.email,
role: created.role,
roles: identity.roles || [created.role],
status: created.status
}
} catch (error: any) {
if (error?.code !== 'P2002') {
throw error
}
const conflicted = await prisma.user.findUnique({
where: { username }
})
if (!conflicted) {
throw error
}
return {
id: conflicted.id,
username: conflicted.username,
name: conflicted.name,
email: conflicted.email,
role: conflicted.role,
roles: identity.roles || [conflicted.role],
status: conflicted.status
}
}
}

View File

@ -0,0 +1,20 @@
import prisma from './prisma'
export const countActiveBorrowRecords = async (equipmentId: number) => {
return prisma.borrowRecord.count({
where: {
equipment_id: equipmentId,
status: '已借出'
}
})
}
export const ensureNoActiveBorrowRecords = async (
equipmentId: number,
message = '资产存在未归还记录,当前不可操作'
) => {
const activeBorrowCount = await countActiveBorrowRecords(equipmentId)
if (activeBorrowCount > 0) {
throw new Error(message)
}
}

View File

@ -80,8 +80,9 @@ curl -fsS http://localhost:3000/api/health || echo "后端服务启动中..."
echo ""
echo "=========================================="
echo " 部署完成"
echo " 固定资产前端: http://1.13.22.133"
echo " 固定资产后端: http://1.13.22.133:3000/api"
echo " 摄影设备前端: http://1.13.22.133:8081"
echo " 摄影设备后端: http://1.13.22.133:3001"
SERVER_HOST="${SERVER_HOST:-<server-host>}"
echo " 固定资产前端: http://$SERVER_HOST"
echo " 固定资产后端: http://$SERVER_HOST:3000/api"
echo " 摄影设备前端: http://$SERVER_HOST:8081"
echo " 摄影设备后端: http://$SERVER_HOST:3001"
echo "=========================================="

View File

@ -7,8 +7,8 @@ services:
container_name: equipment-mysql
restart: always
environment:
MYSQL_ROOT_PASSWORD: jsjj8888
MYSQL_DATABASE: equipment_management
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-change-me-local-password}
MYSQL_DATABASE: ${MYSQL_DATABASE:-equipment_management}
MYSQL_CHARSET: utf8mb4
MYSQL_COLLATION: utf8mb4_unicode_ci
ports:
@ -17,7 +17,7 @@ services:
- mysql_data:/var/lib/mysql
command: --default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-pjsjj8888"]
test: ["CMD-SHELL", "mysqladmin ping -h localhost -u root -p\"$MYSQL_ROOT_PASSWORD\""]
interval: 10s
timeout: 5s
retries: 5
@ -32,10 +32,10 @@ services:
ports:
- "3000:3000"
environment:
DATABASE_URL: mysql://root:jsjj8888@mysql:3306/equipment_management?charset=utf8mb4
SESSION_SECRET: equipment-management-secret-prod
AUTH_MODE: local
PORT: 3000
DATABASE_URL: mysql://root:${MYSQL_ROOT_PASSWORD:-change-me-local-password}@mysql:3306/${MYSQL_DATABASE:-equipment_management}?charset=utf8mb4
SESSION_SECRET: ${SESSION_SECRET:-change-me-session-secret}
AUTH_MODE: ${AUTH_MODE:-local}
PORT: ${PORT:-3000}
SQLSERVER_HOST: ${SQLSERVER_HOST:-}
SQLSERVER_PORT: ${SQLSERVER_PORT:-1433}
SQLSERVER_DATABASE: ${SQLSERVER_DATABASE:-}
@ -61,8 +61,8 @@ services:
context: ./frontend
dockerfile: Dockerfile
args:
VITE_API_BASE_URL: /api
VITE_AUTH_MODE: local
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-/api}
VITE_AUTH_MODE: ${VITE_AUTH_MODE:-local}
container_name: equipment-frontend
restart: always
ports:

View File

@ -1,12 +1,12 @@
<template>
<div class="relative">
<div class="relative w-full">
<div class="relative">
<MagnifyingGlassIcon class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-system-label-tertiary" />
<input
v-model="keyword"
type="text"
placeholder="搜索资产..."
class="input-apple pl-9 pr-4 py-2 w-64 text-sm"
placeholder="搜索资产编码 / 名称 / 型号"
class="input-apple w-full min-w-[220px] pl-9 pr-4 py-2.5 text-sm"
@focus="showResults = true"
@blur="handleBlur"
@input="handleSearch"
@ -16,7 +16,7 @@
<!-- 搜索结果下拉 -->
<div
v-if="showResults && (results.length > 0 || loading)"
class="absolute top-full left-0 right-0 mt-2 bg-system-background-primary rounded-apple shadow-apple-lg border border-system-gray-5 overflow-hidden z-50"
class="absolute left-0 right-0 top-full z-50 mt-2 overflow-hidden rounded-[18px] border border-[color:var(--border-soft)] bg-white/95 shadow-[0_18px_36px_rgba(35,28,20,0.14)] backdrop-blur"
>
<div v-if="loading" class="p-4 text-center text-system-label-secondary">
搜索中...
@ -25,16 +25,19 @@
<div
v-for="item in results"
:key="item.id"
class="px-4 py-3 hover:bg-system-gray-6 cursor-pointer border-b border-system-gray-5 last:border-b-0"
class="cursor-pointer border-b border-[color:var(--border-soft)] px-4 py-3 transition-colors last:border-b-0 hover:bg-[#fbf3ea]"
@mousedown.prevent="goToDetail(item)"
>
<div class="flex items-center justify-between">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm font-medium text-system-label-primary">{{ item.name }}</div>
<div class="text-xs text-system-label-secondary">{{ item.code }} · {{ item.type }}</div>
<div class="flex flex-wrap items-center gap-2">
<div class="text-sm font-medium text-system-label-primary">{{ item.name }}</div>
<span class="chip-muted !px-2 !py-0.5 !text-[11px]">资产</span>
</div>
<div class="mt-1 text-xs text-system-label-secondary">{{ item.code }} · {{ item.type }}</div>
</div>
<span
class="px-2 py-0.5 text-xs rounded-full"
class="rounded-full px-2 py-0.5 text-xs"
:class="getStatusClass(item.status)"
>
{{ item.status }}
@ -51,8 +54,10 @@ import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { MagnifyingGlassIcon } from '@heroicons/vue/24/outline'
import { globalSearch, type SearchResult } from '@/api/search'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const authStore = useAuthStore()
const keyword = ref('')
const results = ref<SearchResult[]>([])
const showResults = ref(false)
@ -90,7 +95,16 @@ const goToDetail = (item: SearchResult) => {
showResults.value = false
keyword.value = ''
results.value = []
router.push(`/equipment?id=${item.id}`)
if (authStore.user?.role === 'admin') {
router.push(`/equipment/edit/${item.id}`)
return
}
router.push({
path: '/borrow/scan',
query: { equipment_code: item.code }
})
}
const getStatusClass = (status: string) => {

View File

@ -1,22 +1,34 @@
<template>
<div class="card-apple hover:shadow-apple-lg transition-shadow">
<div class="flex items-center justify-between mb-2">
<span v-if="iconString" class="text-2xl">{{ iconString }}</span>
<component
v-else-if="iconComponent"
:is="iconComponent"
class="h-6 w-6 text-system-label-tertiary"
/>
<div class="card-apple h-full">
<div class="flex items-start justify-between gap-3">
<div
class="flex h-11 w-11 items-center justify-center rounded-[16px] border border-white/70 shadow-sm"
:style="badgeStyle"
>
<span v-if="iconString" class="text-2xl">{{ iconString }}</span>
<component
v-else-if="iconComponent"
:is="iconComponent"
class="h-5 w-5"
:class="iconColorClass"
/>
</div>
<span
v-if="trend"
class="text-xs px-2 py-1 rounded-apple"
class="rounded-full px-2.5 py-1 text-xs font-medium"
:class="trend > 0 ? 'bg-apple-green text-white' : 'bg-apple-red text-white'"
>
{{ trend > 0 ? '+' : '' }}{{ trend }}%
</span>
</div>
<h3 class="text-apple-title mb-1">{{ title }}</h3>
<p class="stat-value" :class="getColorClass()">{{ value }}</p>
<p class="mt-4 text-[12px] font-semibold uppercase tracking-[0.14em] text-system-label-tertiary">
{{ title }}
</p>
<p class="stat-value mt-2" :class="getColorClass()">{{ value }}</p>
<p v-if="subtitle" class="mt-2 text-sm leading-6 text-system-label-secondary">
{{ subtitle }}
</p>
</div>
</template>
@ -30,6 +42,7 @@ interface Props {
icon?: string | Component
color?: string
trend?: number | null
subtitle?: string
}
const props = withDefaults(defineProps<Props>(), {
@ -45,15 +58,29 @@ const iconComponent = computed<Component | null>(() =>
props.icon && typeof props.icon !== 'string' ? props.icon : null
)
const colorMap: Record<string, { text: string; bg: string }> = {
'apple-blue': { text: 'text-apple-blue', bg: 'rgba(0, 122, 255, 0.12)' },
'apple-green': { text: 'text-apple-green', bg: 'rgba(52, 199, 89, 0.12)' },
'apple-orange': { text: 'text-apple-orange', bg: 'rgba(255, 149, 0, 0.14)' },
'apple-yellow': { text: 'text-apple-yellow', bg: 'rgba(255, 204, 0, 0.16)' },
'apple-red': { text: 'text-apple-red', bg: 'rgba(255, 59, 48, 0.12)' }
}
const iconColorClass = computed(() => colorMap[props.color]?.text || 'text-system-label-primary')
const badgeStyle = computed(() => ({
background: colorMap[props.color]?.bg || 'rgba(61, 70, 86, 0.08)'
}))
const getColorClass = () => {
const colorMap: Record<string, string> = {
const textColorMap: Record<string, string> = {
'apple-blue': 'text-apple-blue',
'apple-green': 'text-apple-green',
'apple-orange': 'text-apple-orange',
'apple-yellow': 'text-apple-yellow',
'apple-red': 'text-apple-red'
}
return colorMap[props.color] || 'text-system-label-primary'
return textColorMap[props.color] || 'text-system-label-primary'
}
</script>
@ -65,4 +92,3 @@ const getColorClass = () => {
overflow-wrap: anywhere;
}
</style>

View File

@ -1,50 +1,96 @@
<template>
<div class="min-h-screen bg-system-background-secondary">
<!-- Navigation Bar - Apple Style -->
<nav class="nav-apple sticky top-0 z-50 px-6 py-4">
<div class="max-w-7xl mx-auto flex items-center justify-between">
<div class="flex items-center space-x-3">
<img
v-if="siteStore.logoUrl"
:src="siteStore.logoUrl"
alt="logo"
class="h-10 w-10 rounded-apple object-cover"
/>
<h1 class="text-apple-title text-system-label-primary">
{{ siteStore.siteName }}
</h1>
</div>
<div class="flex items-center space-x-4">
<GlobalSearch />
<button
@click="handleLogout"
class="btn-apple-secondary text-sm"
>
退出登录
</button>
</div>
</div>
</nav>
<div class="app-shell">
<header class="nav-apple sticky top-0 z-50">
<div class="mx-auto flex max-w-[1600px] flex-col gap-4 px-4 py-4 sm:px-6">
<div class="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<div class="flex min-w-0 items-center gap-4">
<div class="flex h-14 w-14 items-center justify-center overflow-hidden rounded-[18px] border border-white/50 bg-white/70 shadow-sm">
<img
v-if="siteStore.logoUrl"
:src="siteStore.logoUrl"
alt="logo"
class="h-full w-full object-cover"
/>
<span v-else class="text-xl font-bold text-[color:var(--theme-primary)]">资</span>
</div>
<div class="min-w-0">
<p class="page-kicker text-[color:var(--ink-faint)]">{{ currentSection }}</p>
<div class="mt-1 flex flex-wrap items-center gap-3">
<h1 class="text-[1.55rem] font-semibold tracking-tight text-system-label-primary">
{{ siteStore.siteName }}
</h1>
<span class="chip-muted">
{{ authStore.user?.role === 'admin' ? '管理员工作台' : '使用者工作台' }}
</span>
</div>
<p class="mt-1 text-sm text-system-label-secondary">
固定资产、借还、维修、报废与统计分析统一工作台
</p>
</div>
</div>
<!-- Sidebar Navigation - Apple Style -->
<div class="flex max-w-7xl mx-auto overflow-hidden">
<aside class="w-[220px] flex-shrink-0 bg-system-background-primary border-r border-system-gray-5 min-h-screen p-5">
<nav class="space-y-2">
<div class="flex flex-col gap-3 xl:min-w-[440px] xl:items-end">
<GlobalSearch class="w-full xl:w-[360px]" />
<div class="flex flex-wrap items-center gap-3">
<div class="rounded-[18px] border border-[color:var(--border-soft)] bg-white/80 px-4 py-2 shadow-sm">
<p class="text-[11px] uppercase tracking-[0.18em] text-system-label-tertiary">当前用户</p>
<p class="text-sm font-medium text-system-label-primary">
{{ authStore.user?.name || authStore.user?.username || '未命名用户' }}
</p>
</div>
<button @click="handleLogout" class="btn-apple-secondary text-sm">
退出登录
</button>
</div>
</div>
</div>
<nav class="flex gap-2 overflow-x-auto pb-1 lg:hidden">
<router-link
v-for="item in visibleNavItems"
:key="item.path"
:to="item.path"
class="nav-link flex items-center space-x-3 px-3 py-3 rounded-apple transition-colors text-system-label-secondary hover:bg-system-gray-6"
:class="{ 'nav-link--active': $route.path === item.path }"
class="app-nav-link min-w-fit"
:class="{ 'app-nav-link--active': isActive(item.path) }"
>
<component :is="item.icon" class="w-5 h-5" />
<span class="text-apple-body">{{ item.title }}</span>
<component :is="item.icon" class="app-nav-link__icon h-4 w-4" />
<span>{{ item.title }}</span>
</router-link>
</nav>
</div>
</header>
<div class="app-frame">
<aside class="app-sidebar hidden lg:block">
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-system-label-tertiary">
工作区导航
</p>
<nav class="mt-4 space-y-2">
<router-link
v-for="item in visibleNavItems"
:key="item.path"
:to="item.path"
class="app-nav-link"
:class="{ 'app-nav-link--active': isActive(item.path) }"
>
<component :is="item.icon" class="app-nav-link__icon h-5 w-5 text-system-label-tertiary" />
<span>{{ item.title }}</span>
</router-link>
</nav>
<div class="panel-muted mt-6">
<p class="text-[11px] font-semibold uppercase tracking-[0.16em] text-system-label-tertiary">
使用建议
</p>
<div class="mt-3 space-y-3 text-sm leading-6 text-system-label-secondary">
<p>首页看整体健康度,先判断库存、借出和价值结构是否异常。</p>
<p>资产页适合做批量维护,使用页适合处理扫码出入库和借还追踪。</p>
<p>统计页可以快速筛出高价值类别和重点资产,方便汇报与盘点。</p>
</div>
</div>
</aside>
<!-- Main Content -->
<main class="flex-1 p-6 min-w-0 overflow-hidden">
<main class="app-main">
<router-view />
</main>
</div>
@ -53,13 +99,14 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useSiteStore } from '@/stores/site'
import { HomeIcon, CubeIcon, ArrowRightIcon, ChartBarIcon, Cog6ToothIcon } from '@heroicons/vue/24/outline'
import GlobalSearch from '@/components/GlobalSearch.vue'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const siteStore = useSiteStore()
@ -76,6 +123,13 @@ const visibleNavItems = computed(() => {
return navItems.filter((item) => !item.requiresAdmin || isAdmin)
})
const currentSection = computed(() => {
const matchedNav = navItems.find((item) => route.path.startsWith(item.path))
return (route.meta.title as string) || matchedNav?.title || '工作台'
})
const isActive = (path: string) => route.path === path || route.path.startsWith(`${path}/`)
const handleLogout = async () => {
await authStore.logout()
router.push('/login')
@ -85,12 +139,3 @@ onMounted(() => {
siteStore.fetchSiteConfig()
})
</script>
<style scoped>
.nav-link--active {
background-color: var(--theme-primary);
color: #ffffff;
box-shadow: 0 6px 18px var(--theme-primary-ring);
}
</style>

View File

@ -2,13 +2,26 @@
@tailwind components;
@tailwind utilities;
/* Apple Design System Base Styles */
@layer base {
:root {
--theme-primary: #007AFF;
--theme-primary-hover: #2F8CFF;
--theme-primary-active: #0063CC;
--theme-primary-ring: rgba(0, 122, 255, 0.35);
--theme-primary: #9f5f2c;
--theme-primary-hover: #bc7540;
--theme-primary-active: #84461c;
--theme-primary-ring: rgba(159, 95, 44, 0.28);
--surface-base: rgba(255, 251, 245, 0.92);
--surface-muted: rgba(249, 242, 232, 0.86);
--surface-strong: rgba(255, 255, 255, 0.95);
--border-soft: rgba(79, 56, 34, 0.12);
--border-strong: rgba(79, 56, 34, 0.2);
--shadow-soft: 0 18px 40px rgba(64, 44, 24, 0.08);
--shadow-strong: 0 20px 44px rgba(34, 25, 18, 0.12);
--ink-strong: #20252f;
--ink-soft: #5f6675;
--ink-faint: #868c98;
}
html {
scroll-behavior: smooth;
}
* {
@ -16,14 +29,21 @@
}
body {
@apply bg-system-background-secondary text-system-label-primary font-sans;
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text', 'Helvetica Neue', Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@apply text-system-label-primary font-sans;
min-height: 100vh;
font-family: "Segoe UI Variable Display", "PingFang SC", "Microsoft YaHei UI",
"Noto Sans SC", sans-serif;
background:
radial-gradient(circle at top left, rgba(255, 222, 189, 0.42), transparent 32%),
radial-gradient(circle at top right, rgba(222, 232, 255, 0.34), transparent 26%),
linear-gradient(180deg, #f6efe5 0%, #f2ece5 45%, #efe8dd 100%);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1, h2, h3, h4, h5, h6 {
@apply font-semibold text-system-label-primary;
color: var(--ink-strong);
@apply font-semibold;
}
h1 {
@ -39,88 +59,304 @@
}
}
/* Apple Design System Components */
@layer components {
/* Apple Button Styles */
.btn-apple {
@apply px-4 py-2 rounded-apple font-medium text-sm transition-all duration-200;
@apply active:scale-95;
}
@apply inline-flex items-center justify-center gap-2 rounded-apple px-4 py-2.5 text-sm font-medium transition-all duration-200 whitespace-nowrap;
transform: translateZ(0);
}
.btn-apple:hover {
transform: translateY(-1px);
}
.btn-apple:active {
transform: translateY(0);
}
.btn-apple-primary {
@apply btn-apple text-white;
background-color: var(--theme-primary);
background: linear-gradient(135deg, var(--theme-primary) 0%, var(--theme-primary-hover) 100%);
box-shadow: 0 12px 22px var(--theme-primary-ring);
}
.btn-apple-primary:hover {
background-color: var(--theme-primary-hover);
box-shadow: 0 16px 28px var(--theme-primary-ring);
}
.btn-apple-primary:active {
background-color: var(--theme-primary-active);
background: linear-gradient(135deg, var(--theme-primary-active) 0%, var(--theme-primary) 100%);
}
.btn-apple-primary:disabled {
@apply opacity-60 cursor-not-allowed;
.btn-apple-primary:disabled,
.btn-apple-secondary:disabled {
@apply cursor-not-allowed opacity-60;
transform: none;
}
.btn-apple-secondary {
@apply btn-apple bg-system-background-primary text-system-label-primary border border-system-gray-5;
@apply hover:bg-system-gray-6;
@apply active:bg-system-gray-5;
@apply btn-apple;
color: var(--ink-strong);
background: rgba(255, 255, 255, 0.78);
border: 1px solid var(--border-soft);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
}
.btn-apple-secondary:hover {
background: rgba(255, 255, 255, 0.92);
border-color: var(--border-strong);
}
.btn-apple-danger {
@apply btn-apple bg-apple-red text-white;
@apply hover:bg-opacity-90;
@apply active:bg-opacity-80;
}
@apply btn-apple text-white;
background: linear-gradient(135deg, #d54f4a 0%, #c4332a 100%);
box-shadow: 0 12px 22px rgba(197, 51, 42, 0.18);
}
/* Apple Card Styles */
.card-apple {
@apply bg-system-background-primary rounded-apple-lg shadow-apple p-6;
@apply border border-system-gray-5;
@apply rounded-apple-xl p-5 md:p-6;
background: linear-gradient(180deg, var(--surface-strong) 0%, var(--surface-base) 100%);
border: 1px solid var(--border-soft);
box-shadow: var(--shadow-soft);
}
.card-apple-elevated {
@apply card-apple shadow-apple-lg;
}
@apply card-apple;
box-shadow: var(--shadow-strong);
}
/* Apple Input Styles */
.input-apple {
@apply w-full px-4 py-3 rounded-apple border border-system-gray-5 bg-system-background-primary;
@apply text-system-label-primary placeholder-system-label-quaternary;
@apply focus:outline-none;
@apply transition-all duration-200;
box-shadow: 0 0 0 0 transparent;
@apply w-full rounded-apple border text-system-label-primary placeholder-system-label-quaternary transition-all duration-200;
background: rgba(255, 255, 255, 0.84);
border-color: var(--border-soft);
padding: 0.78rem 0.95rem;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.85);
}
.input-apple:focus {
outline: none;
border-color: transparent;
box-shadow: 0 0 0 2px var(--theme-primary);
box-shadow: 0 0 0 2px var(--theme-primary-ring), 0 0 0 1px var(--theme-primary);
background: rgba(255, 255, 255, 0.98);
}
/* Apple Glass Effect */
.glass-apple {
@apply bg-white bg-opacity-80 backdrop-blur-apple;
@apply border border-white border-opacity-20;
}
/* Apple Navigation Bar */
.nav-apple {
@apply bg-system-background-primary border-b border-system-gray-5;
@apply backdrop-blur-apple bg-opacity-80;
background: rgba(248, 242, 233, 0.82);
border-bottom: 1px solid var(--border-soft);
backdrop-filter: blur(18px);
box-shadow: 0 8px 22px rgba(76, 56, 36, 0.05);
}
/* Apple Tab Bar */
.tab-bar-apple {
@apply bg-system-background-primary border-t border-system-gray-5;
@apply backdrop-blur-apple bg-opacity-80;
}
.app-shell {
min-height: 100vh;
}
.app-frame {
width: min(1600px, calc(100% - 1.5rem));
margin: 1.1rem auto 0;
display: grid;
grid-template-columns: 260px minmax(0, 1fr);
gap: 1.1rem;
align-items: start;
}
.app-sidebar {
position: sticky;
top: 108px;
border-radius: 24px;
padding: 1rem;
background: rgba(255, 250, 244, 0.82);
border: 1px solid var(--border-soft);
box-shadow: var(--shadow-soft);
}
.app-main {
min-width: 0;
padding-bottom: 2.2rem;
}
.app-nav-link {
@apply flex items-center gap-3 rounded-apple-lg px-3 py-3 text-sm font-medium transition-all duration-200;
color: var(--ink-soft);
background: transparent;
}
.app-nav-link:hover {
color: var(--ink-strong);
background: rgba(255, 255, 255, 0.64);
}
.app-nav-link--active {
color: #ffffff;
background: linear-gradient(135deg, var(--theme-primary) 0%, var(--theme-primary-hover) 100%);
box-shadow: 0 14px 24px var(--theme-primary-ring);
}
.app-nav-link--active .app-nav-link__icon {
color: #ffffff;
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1.35rem;
border-radius: 26px;
background: linear-gradient(135deg, rgba(28, 36, 49, 0.96) 0%, rgba(67, 79, 98, 0.92) 100%);
box-shadow: 0 20px 44px rgba(27, 34, 47, 0.16);
}
.page-kicker {
font-size: 0.72rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.62);
}
.page-title {
margin-top: 0.3rem;
color: #ffffff;
font-size: clamp(1.45rem, 1.15rem + 1.2vw, 2.1rem);
font-weight: 700;
letter-spacing: -0.02em;
}
.page-subtitle {
margin-top: 0.65rem;
max-width: 56rem;
color: rgba(255, 255, 255, 0.76);
line-height: 1.65;
font-size: 0.95rem;
}
.page-meta-grid {
display: grid;
width: min(100%, 420px);
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
gap: 0.75rem;
}
.page-meta {
border-radius: 18px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.08);
padding: 0.85rem 0.95rem;
}
.page-meta__label {
font-size: 0.72rem;
color: rgba(255, 255, 255, 0.64);
}
.page-meta__value {
margin-top: 0.45rem;
color: #ffffff;
font-size: 1.35rem;
font-weight: 700;
letter-spacing: -0.03em;
}
.page-meta__subvalue {
margin-top: 0.3rem;
font-size: 0.78rem;
color: rgba(255, 255, 255, 0.7);
}
.metric-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.panel-muted {
border-radius: 18px;
border: 1px solid rgba(97, 74, 49, 0.08);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.62) 0%, rgba(247, 240, 231, 0.92) 100%);
padding: 1rem;
}
.toolbar-panel {
display: flex;
flex-wrap: wrap;
align-items: end;
justify-content: space-between;
gap: 1rem;
border-radius: 22px;
border: 1px solid var(--border-soft);
background: rgba(255, 250, 243, 0.76);
padding: 1rem 1.1rem;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.72);
}
.table-shell {
overflow: hidden;
border-radius: 20px;
border: 1px solid rgba(79, 56, 34, 0.08);
background: rgba(255, 255, 255, 0.65);
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table thead th {
padding: 0.85rem 1rem;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--ink-faint);
background: rgba(244, 236, 226, 0.86);
border-bottom: 1px solid rgba(79, 56, 34, 0.08);
}
.data-table tbody td {
padding: 0.9rem 1rem;
border-bottom: 1px solid rgba(79, 56, 34, 0.06);
vertical-align: top;
}
.data-table tbody tr:last-child td {
border-bottom: none;
}
.data-table tbody tr:hover {
background: rgba(255, 247, 238, 0.86);
}
.chip-muted {
@apply inline-flex items-center gap-2 rounded-full px-3 py-1 text-xs font-medium;
color: var(--ink-soft);
background: rgba(255, 255, 255, 0.78);
border: 1px solid rgba(79, 56, 34, 0.08);
}
.chip-inverse {
@apply inline-flex items-center gap-2 rounded-full px-3 py-1 text-xs font-medium;
color: rgba(255, 255, 255, 0.88);
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.section-title {
color: var(--ink-strong);
font-size: 1.15rem;
font-weight: 700;
letter-spacing: -0.01em;
}
.section-subtitle {
margin-top: 0.25rem;
font-size: 0.88rem;
color: var(--ink-soft);
}
}
/* Apple Design System Utilities */
@layer utilities {
.text-apple-large-title {
@apply text-4xl font-bold;
}
}
.text-apple-title {
@apply text-2xl font-semibold;
@ -136,11 +372,11 @@
.text-apple-headline {
@apply text-base font-semibold;
}
}
.text-apple-body {
@apply text-base font-normal;
}
}
.text-apple-callout {
@apply text-base font-medium;
@ -156,35 +392,50 @@
.text-apple-caption {
@apply text-xs font-normal;
}
}
}
/* Custom Scrollbar - Apple Style */
::-webkit-scrollbar {
width: 8px;
height: 8px;
width: 9px;
height: 9px;
}
::-webkit-scrollbar-track {
@apply bg-system-background-secondary;
background: rgba(233, 225, 215, 0.7);
}
::-webkit-scrollbar-thumb {
@apply bg-system-gray-4 rounded-full;
}
background: rgba(125, 108, 89, 0.45);
border-radius: 999px;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-system-gray-3;
background: rgba(105, 88, 70, 0.64);
}
/* Focus Ring - Apple Style */
*:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--theme-primary);
box-shadow: 0 0 0 3px var(--theme-primary-ring);
}
/* Selection - Apple Style */
::selection {
background-color: var(--theme-primary);
color: #ffffff;
}
@media (max-width: 1023px) {
.app-frame {
width: min(100% - 1rem, 1600px);
grid-template-columns: 1fr;
gap: 0.95rem;
margin-top: 0.95rem;
}
.page-header {
flex-direction: column;
}
.page-meta-grid {
width: 100%;
}
}

View File

@ -124,25 +124,22 @@ const handleLocalLogin = async () => {
username: username.value,
password: password.value
})
const payload = resp?.data
authStore.setToken(payload?.token || 'session')
const payload = resp?.data?.data
const rawUser = payload?.user as Partial<User> | undefined
const normalizedUser: User = rawUser
? {
...rawUser,
id: String(rawUser.id ?? rawUser.username ?? ''),
username: rawUser.username || '',
name: rawUser.name || rawUser.username || '',
role: rawUser.role || (rawUser.roles?.includes('admin') ? 'admin' : 'user'),
roles: rawUser.roles || []
}
: {
id: '0',
username: username.value,
name: username.value,
role: username.value === 'admin' ? 'admin' : 'user',
roles: []
}
if (!payload?.token || !rawUser) {
throw new Error('登录响应数据不完整')
}
authStore.setToken(payload.token)
const normalizedUser: User = {
...rawUser,
id: String(rawUser.id ?? rawUser.username ?? ''),
username: rawUser.username || '',
name: rawUser.name || rawUser.username || '',
role: rawUser.role || (rawUser.roles?.includes('admin') ? 'admin' : 'user'),
roles: rawUser.roles || []
}
authStore.setUser(normalizedUser)
window.alert('登录成功')
await router.push('/dashboard')
@ -167,4 +164,3 @@ onMounted(() => {
siteStore.fetchSiteConfig()
})
</script>

View File

@ -1,101 +1,238 @@
<template>
<div class="space-y-6">
<!-- Actions -->
<div class="flex justify-between items-center">
<button
class="btn-apple-secondary flex items-center gap-2"
:disabled="exporting"
@click="handleExport"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
{{ exporting ? '导出中...' : '导出借用记录' }}
</button>
<div class="flex space-x-4">
<router-link to="/borrow/scan" class="btn-apple-primary">
扫码出库
</router-link>
<router-link to="/return/scan" class="btn-apple-secondary">
扫码入库
</router-link>
<section class="page-header">
<div>
<p class="page-kicker">Usage Flow</p>
<h2 class="page-title">借用与归还管理</h2>
<p class="page-subtitle">
把最近的借用动态、状态筛选和扫码入口放在一个页面里,方便仓库和管理员快速处理日常流转。
</p>
<div class="mt-5 flex flex-wrap gap-3">
<router-link to="/borrow/scan" class="btn-apple-primary">扫码出库</router-link>
<router-link to="/return/scan" class="btn-apple-secondary">扫码入库</router-link>
<button class="btn-apple-secondary" :disabled="exporting" @click="handleExport">
{{ exporting ? '导出中...' : '导出借用记录' }}
</button>
</div>
</div>
</div>
<!-- Borrow Records Table -->
<div class="card-apple">
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b border-system-gray-5">
<th class="text-left py-3 px-4 text-apple-subhead text-system-label-secondary">资产编码</th>
<th class="text-left py-3 px-4 text-apple-subhead text-system-label-secondary">资产名称</th>
<th class="text-left py-3 px-4 text-apple-subhead text-system-label-secondary">使用者</th>
<th class="text-left py-3 px-4 text-apple-subhead text-system-label-secondary">借用时间</th>
<th class="text-left py-3 px-4 text-apple-subhead text-system-label-secondary">预计归还时间</th>
<th class="text-left py-3 px-4 text-apple-subhead text-system-label-secondary">归还时间</th>
<th class="text-left py-3 px-4 text-apple-subhead text-system-label-secondary">状态</th>
</tr>
</thead>
<tbody>
<tr
v-for="record in borrowRecords"
:key="record.id"
class="border-b border-system-gray-6 hover:bg-system-gray-6 transition-colors"
>
<td class="py-3 px-4 text-apple-body font-mono">{{ record.equipment_code }}</td>
<td class="py-3 px-4 text-apple-body">{{ record.equipment_name }}</td>
<td class="py-3 px-4 text-apple-body">
{{ record.person_nickname }} · {{ record.person_name }}
</td>
<td class="py-3 px-4 text-apple-subhead">{{ formatDateTime(record.borrow_time) }}</td>
<td class="py-3 px-4 text-apple-subhead">
{{
record.is_long_term
? '在职长期'
: record.expected_return_time
? formatDateTime(record.expected_return_time)
: '-'
}}
</td>
<td class="py-3 px-4 text-apple-subhead">
{{ record.return_time ? formatDateTime(record.return_time) : '-' }}
</td>
<td class="py-3 px-4">
<span
class="px-3 py-1 rounded-apple text-apple-footnote font-medium"
:class="getStatusClass(record.status)"
>
{{ record.status }}
</span>
</td>
</tr>
</tbody>
</table>
<div class="page-meta-grid">
<div class="page-meta">
<p class="page-meta__label">匹配记录</p>
<p class="page-meta__value">{{ formatNumber(total) }}</p>
<p class="page-meta__subvalue">当前第 {{ currentPage }} 页</p>
</div>
<div class="page-meta">
<p class="page-meta__label">当前页借出</p>
<p class="page-meta__value">{{ formatNumber(pageSummary.borrowed) }}</p>
<p class="page-meta__subvalue">长期 {{ formatNumber(pageSummary.longTerm) }} 条</p>
</div>
<div class="page-meta">
<p class="page-meta__label">当前页已归还</p>
<p class="page-meta__value">{{ formatNumber(pageSummary.returned) }}</p>
<p class="page-meta__subvalue">已加载 {{ borrowRecords.length }} 条</p>
</div>
</div>
</div>
</section>
<section class="toolbar-panel">
<div class="grid flex-1 grid-cols-1 gap-4 md:grid-cols-3">
<div>
<label class="mb-2 block text-sm font-medium text-system-label-primary">状态</label>
<select v-model="filters.status" class="input-apple" @change="handleSearch">
<option value="">全部状态</option>
<option value="已借出">已借出</option>
<option value="已归还">已归还</option>
<option value="逾期">逾期</option>
</select>
</div>
<div class="md:col-span-2">
<label class="mb-2 block text-sm font-medium text-system-label-primary">使用者 / 证件号</label>
<input
v-model.trim="filters.person_name"
class="input-apple"
type="text"
placeholder="输入花名、姓名或证件号"
@keyup.enter="handleSearch"
/>
</div>
</div>
<div class="flex flex-wrap gap-3">
<button class="btn-apple-secondary" @click="handleReset">重置</button>
<button class="btn-apple-primary" :disabled="loading" @click="handleSearch">
{{ loading ? '查询中...' : '查询' }}
</button>
</div>
</section>
<section class="grid grid-cols-1 gap-6 xl:grid-cols-[1.25fr,0.75fr]">
<div class="card-apple">
<div class="flex items-start justify-between gap-3">
<div>
<h3 class="section-title">借用记录清单</h3>
<p class="section-subtitle">资产、使用者、位置和归还计划同屏显示,减少反复点开查看详情。</p>
</div>
<span class="chip-muted">每页 {{ pageSize }} 条</span>
</div>
<div class="table-shell mt-5">
<div class="overflow-x-auto">
<table class="data-table min-w-[980px]">
<thead>
<tr>
<th class="text-left">资产</th>
<th class="text-left">使用者</th>
<th class="text-left">位置</th>
<th class="text-left">借用时间</th>
<th class="text-left">归还计划</th>
<th class="text-left">实际归还</th>
<th class="text-left">状态</th>
</tr>
</thead>
<tbody>
<tr v-for="record in borrowRecords" :key="record.id">
<td>
<p class="font-medium text-system-label-primary">{{ record.equipment_name || '-' }}</p>
<p class="mt-1 font-mono text-xs text-system-label-tertiary">{{ record.equipment_code }}</p>
</td>
<td>
<p class="font-medium text-system-label-primary">{{ record.person_nickname }}</p>
<p class="mt-1 text-xs text-system-label-secondary">
{{ record.person_name }}
<span v-if="record.person?.department"> · {{ record.person.department }}</span>
</p>
</td>
<td>
<p class="text-sm text-system-label-primary">{{ record.storageLocation?.name || '-' }}</p>
<p v-if="record.storageLocation?.code" class="mt-1 text-xs text-system-label-tertiary">
{{ record.storageLocation.code }}
</p>
</td>
<td class="text-sm text-system-label-secondary">{{ formatDateTime(record.borrow_time) }}</td>
<td class="text-sm text-system-label-secondary">
<span v-if="record.is_long_term" class="chip-muted">在职长期</span>
<template v-else>
{{ record.expected_return_time ? formatDateTime(record.expected_return_time) : '-' }}
</template>
</td>
<td class="text-sm text-system-label-secondary">
{{ record.return_time ? formatDateTime(record.return_time) : '-' }}
</td>
<td>
<span class="inline-flex rounded-full px-3 py-1 text-xs font-medium" :class="getStatusClass(record.status)">
{{ record.status }}
</span>
</td>
</tr>
<tr v-if="!loading && borrowRecords.length === 0">
<td colspan="7" class="text-center text-sm text-system-label-secondary">当前筛选条件下暂无记录。</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="mt-4 flex items-center justify-between border-t border-[color:var(--border-soft)] pt-4">
<p class="text-sm text-system-label-secondary">共 {{ formatNumber(total) }} 条记录</p>
<div class="flex items-center gap-2">
<button @click="handlePrevPage" :disabled="currentPage === 1" class="btn-apple-secondary">上一页</button>
<span class="text-sm font-medium text-system-label-primary">{{ currentPage }} / {{ totalPages }}</span>
<button @click="handleNextPage" :disabled="currentPage >= totalPages" class="btn-apple-secondary">下一页</button>
</div>
</div>
</div>
<div class="space-y-6">
<section class="card-apple">
<h3 class="section-title">当前页状态快照</h3>
<p class="section-subtitle">不用切图表也能快速判断这一页记录的流转结构。</p>
<div class="mt-5 space-y-3">
<div class="panel-muted flex items-center justify-between">
<div>
<p class="text-xs uppercase tracking-[0.14em] text-system-label-tertiary">已借出</p>
<p class="mt-2 text-lg font-semibold text-apple-orange">{{ formatNumber(pageSummary.borrowed) }}</p>
</div>
<span class="chip-muted">待归还</span>
</div>
<div class="panel-muted flex items-center justify-between">
<div>
<p class="text-xs uppercase tracking-[0.14em] text-system-label-tertiary">已归还</p>
<p class="mt-2 text-lg font-semibold text-apple-green">{{ formatNumber(pageSummary.returned) }}</p>
</div>
<span class="chip-muted">已完成</span>
</div>
<div class="panel-muted flex items-center justify-between">
<div>
<p class="text-xs uppercase tracking-[0.14em] text-system-label-tertiary">长期借用</p>
<p class="mt-2 text-lg font-semibold text-system-label-primary">{{ formatNumber(pageSummary.longTerm) }}</p>
</div>
<span class="chip-muted">在职长期</span>
</div>
</div>
</section>
<section class="card-apple">
<h3 class="section-title">处理提示</h3>
<p class="section-subtitle">这块保留给仓库和管理员做日常巡检时快速参考。</p>
<div class="mt-5 space-y-3 text-sm leading-6 text-system-label-secondary">
<div class="panel-muted">状态筛选建议先看“已借出”,能最快定位需要催还和追踪的记录。</div>
<div class="panel-muted">长期借用数量偏高时,建议回到资产页核查是否存在长期占用未回收的设备。</div>
<div class="panel-muted">需要现场处理时,优先使用上方扫码出库/扫码入库入口,避免手工录入。</div>
</div>
</section>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { getBorrowRecordList } from '@/api/borrow'
import type { BorrowRecord } from '@/types'
import { formatDateTime } from '@/utils/format'
import { formatDateTime, formatNumber } from '@/utils/format'
const borrowRecords = ref<BorrowRecord[]>([])
const loading = ref(false)
const exporting = ref(false)
const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(20)
const filters = reactive({
status: '',
person_name: ''
})
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
const pageSummary = computed(() => {
const summary = {
borrowed: 0,
returned: 0,
longTerm: 0
}
for (const record of borrowRecords.value) {
if (record.status === '已借出') summary.borrowed += 1
if (record.status === '已归还') summary.returned += 1
if (record.is_long_term) summary.longTerm += 1
}
return summary
})
const fetchBorrowRecords = async () => {
loading.value = true
try {
const response = await getBorrowRecordList({
page: 1,
pageSize: 100
page: currentPage.value,
pageSize: pageSize.value,
status: filters.status || undefined,
person_name: filters.person_name || undefined
})
if (response.data) {
borrowRecords.value = response.data.list
total.value = response.data.total
}
} catch (error) {
console.error('获取借用记录失败:', error)
@ -107,19 +244,22 @@ const fetchBorrowRecords = async () => {
const handleExport = async () => {
exporting.value = true
try {
const params = new URLSearchParams()
if (filters.status) params.append('status', filters.status)
if (filters.person_name) params.append('person_name', filters.person_name)
const baseUrl = import.meta.env.VITE_API_BASE_URL || '/api'
const token = localStorage.getItem('token')
const response = await fetch(`${baseUrl}/export/borrow-records`, {
const response = await fetch(`${baseUrl}/export/borrow-records?${params.toString()}`, {
headers: {
'Authorization': `Bearer ${token}`
Authorization: `Bearer ${token}`
}
})
if (!response.ok) {
throw new Error('导出失败')
}
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
@ -136,11 +276,34 @@ const handleExport = async () => {
}
}
const handleSearch = () => {
currentPage.value = 1
fetchBorrowRecords()
}
const handleReset = () => {
filters.status = ''
filters.person_name = ''
handleSearch()
}
const handlePrevPage = () => {
if (currentPage.value <= 1) return
currentPage.value -= 1
fetchBorrowRecords()
}
const handleNextPage = () => {
if (currentPage.value >= totalPages.value) return
currentPage.value += 1
fetchBorrowRecords()
}
const getStatusClass = (status: string) => {
const classes: Record<string, string> = {
'已借出': 'bg-apple-orange text-white',
'已归还': 'bg-apple-green text-white',
'逾期': 'bg-apple-red text-white'
'已借出': 'bg-apple-orange/15 text-apple-orange',
'已归还': 'bg-apple-green/15 text-apple-green',
'逾期': 'bg-apple-red/15 text-apple-red'
}
return classes[status] || 'bg-system-gray-5 text-system-label-secondary'
}
@ -149,4 +312,3 @@ onMounted(() => {
fetchBorrowRecords()
})
</script>

View File

@ -196,6 +196,7 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { scanEquipment } from '@/api/equipment'
import { scanBorrow } from '@/api/borrow'
import { getPersonList } from '@/api/person'
@ -206,6 +207,7 @@ import { formatCurrency, playBeepSound, playErrorSound } from '@/utils/format'
const scanCode = ref('')
const scanInputRef = ref<HTMLInputElement>()
const route = useRoute()
const equipmentInfo = ref<Equipment | null>(null)
const equipmentConfirmed = ref(false)
const mountedChildren = ref<ChildEquipment[]>([])
@ -366,6 +368,18 @@ const handleSubmit = async () => {
}
}
const loadEquipmentFromRoute = async () => {
const routeEquipmentCode =
typeof route.query.equipment_code === 'string' ? route.query.equipment_code.trim() : ''
if (!routeEquipmentCode) {
return
}
scanCode.value = routeEquipmentCode
await handleScan()
}
const fetchPersons = async (keyword = '') => {
const requestId = ++latestPersonRequestId
try {
@ -422,6 +436,7 @@ onMounted(() => {
nextTick(() => {
scanInputRef.value?.focus()
})
loadEquipmentFromRoute()
})
onUnmounted(() => {
@ -429,4 +444,11 @@ onUnmounted(() => {
window.clearTimeout(searchTimer)
}
})
watch(
() => route.query.equipment_code,
() => {
loadEquipmentFromRoute()
}
)
</script>

View File

@ -1,26 +1,54 @@
<template>
<div class="space-y-6 relative">
<div class="relative space-y-6">
<Transition name="fade">
<div
v-if="isLoading"
class="absolute inset-0 z-10 flex items-center justify-center bg-system-background-primary/70 backdrop-blur-sm"
class="absolute inset-0 z-10 flex items-center justify-center rounded-[24px] bg-white/70 backdrop-blur-sm"
>
<div class="flex flex-col items-center space-y-3 text-system-label-secondary">
<svg class="w-8 h-8 animate-spin text-apple-blue" viewBox="0 0 24 24" fill="none">
<svg class="h-8 w-8 animate-spin text-[color:var(--theme-primary)]" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"
/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
<span class="text-apple-footnote">数据加载中,请稍候…</span>
<span class="text-apple-footnote">正在整理今日资产运行数据…</span>
</div>
</div>
</Transition>
<!-- Statistics Cards Grid - Apple Style -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<section class="page-header">
<div>
<p class="page-kicker">Overview</p>
<h2 class="page-title">资产运行总览</h2>
<p class="page-subtitle">
把库存、借还、价值和近期流转集中在一个屏幕里,方便每天先看健康度,再决定去哪个模块处理问题。
</p>
<div class="mt-5 flex flex-wrap gap-3">
<router-link to="/borrow/scan" class="btn-apple-primary">扫码出库</router-link>
<router-link to="/return/scan" class="btn-apple-secondary">扫码入库</router-link>
<router-link v-if="isAdmin" to="/equipment" class="btn-apple-secondary">进入资产管理</router-link>
</div>
</div>
<div class="page-meta-grid">
<div class="page-meta">
<p class="page-meta__label">今日借出</p>
<p class="page-meta__value">{{ formatNumber(stats?.today_borrow || 0) }}</p>
<p class="page-meta__subvalue">流转价值 {{ formatCurrencyNoFraction(stats?.today_borrow_value || 0) }}</p>
</div>
<div class="page-meta">
<p class="page-meta__label">今日归还</p>
<p class="page-meta__value">{{ formatNumber(stats?.today_return || 0) }}</p>
<p class="page-meta__subvalue">回库价值 {{ formatCurrencyNoFraction(stats?.today_return_value || 0) }}</p>
</div>
<div class="page-meta">
<p class="page-meta__label">数量利用率</p>
<p class="page-meta__value">{{ formatPercent(stats?.usage_rate || 0) }}</p>
<p class="page-meta__subvalue">价值利用率 {{ formatPercent(stats?.value_usage_rate || 0) }}</p>
</div>
</div>
</section>
<div class="metric-grid">
<StatCard
v-for="card in statCards"
:key="card.title"
@ -28,116 +56,269 @@
:value="card.value"
:icon="card.icon"
:color="card.color"
:trend="card.trend"
:subtitle="card.subtitle"
/>
</div>
<!-- Charts Section -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Equipment Status Distribution -->
<div class="card-apple">
<h2 class="text-apple-title mb-4">资产状态分布</h2>
<div ref="statusChartRef" class="h-64"></div>
</div>
<div class="grid grid-cols-1 gap-6 xl:grid-cols-[1.2fr,0.9fr]">
<section class="card-apple">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 class="section-title">资产状态分布</h3>
<p class="section-subtitle">快速看出可用、借出和维护中的库存结构。</p>
</div>
<span class="chip-muted">按库存数量</span>
</div>
<div ref="statusChartRef" class="mt-5 h-72"></div>
<div class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-3">
<div class="panel-muted">
<p class="text-xs uppercase tracking-[0.14em] text-system-label-tertiary">可用</p>
<p class="mt-2 text-lg font-semibold text-apple-green">{{ formatNumber(stats?.available_equipment || 0) }}</p>
</div>
<div class="panel-muted">
<p class="text-xs uppercase tracking-[0.14em] text-system-label-tertiary">借出</p>
<p class="mt-2 text-lg font-semibold text-apple-orange">{{ formatNumber(stats?.borrowed_equipment || 0) }}</p>
</div>
<div class="panel-muted">
<p class="text-xs uppercase tracking-[0.14em] text-system-label-tertiary">维护</p>
<p class="mt-2 text-lg font-semibold text-apple-yellow">{{ formatNumber(stats?.maintenance_equipment || 0) }}</p>
</div>
</div>
</section>
<!-- Equipment Category Inventory Distribution -->
<div class="card-apple">
<h2 class="text-apple-title mb-4">资产分类库存占比</h2>
<div ref="categoryChartRef" class="h-64"></div>
</div>
<section class="card-apple">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 class="section-title">库存结构快照</h3>
<p class="section-subtitle">优先显示库存占比较高的资产类型。</p>
</div>
<span class="chip-muted">按资产类型</span>
</div>
<div ref="categoryChartRef" class="mt-5 h-72"></div>
<div class="mt-4 space-y-3">
<div
v-for="item in topCategories"
:key="item.equipment_type"
class="panel-muted flex items-center justify-between gap-4"
>
<div>
<p class="text-sm font-semibold text-system-label-primary">{{ item.equipment_type }}</p>
<p class="mt-1 text-xs text-system-label-secondary">
可用 {{ formatNumber(item.available_quantity) }} · 借出 {{ formatNumber(item.borrowed_quantity) }}
</p>
</div>
<div class="text-right">
<p class="text-lg font-semibold text-system-label-primary">{{ formatNumber(item.total_quantity) }}</p>
<p class="text-xs text-system-label-tertiary">总库存</p>
</div>
</div>
<div v-if="topCategories.length === 0" class="panel-muted text-sm text-system-label-secondary">
暂无可展示的分类库存数据。
</div>
</div>
</section>
</div>
<!-- Recent Borrow Records -->
<div class="card-apple">
<h2 class="text-apple-title mb-4">最近借用记录</h2>
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b border-system-gray-5">
<th class="text-left py-3 px-4 text-apple-subhead text-system-label-secondary">资产名称</th>
<th class="text-left py-3 px-4 text-apple-subhead text-system-label-secondary">使用者</th>
<th class="text-left py-3 px-4 text-apple-subhead text-system-label-secondary">借用时间</th>
<th class="text-left py-3 px-4 text-apple-subhead text-system-label-secondary">预计归还时间</th>
<th class="text-left py-3 px-4 text-apple-subhead text-system-label-secondary">状态</th>
</tr>
</thead>
<tbody>
<tr
v-for="record in recentRecords"
:key="record.id"
class="border-b border-system-gray-6 hover:bg-system-gray-6 transition-colors"
>
<td class="py-3 px-4 text-apple-body">{{ record.equipment_name }}</td>
<td class="py-3 px-4 text-apple-body">
{{ record.person_nickname }} · {{ record.person_name }}
</td>
<td class="py-3 px-4 text-apple-subhead">{{ formatDateTime(record.borrow_time) }}</td>
<td class="py-3 px-4 text-apple-subhead">
{{
record.is_long_term
? '在职长期'
: record.expected_return_time
? formatDateTime(record.expected_return_time)
: '-'
}}
</td>
<td class="py-3 px-4">
<span
class="px-3 py-1 rounded-apple text-apple-footnote font-medium"
:class="getStatusClass(record.status)"
>
{{ record.status }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
<div class="grid grid-cols-1 gap-6 xl:grid-cols-[1.35fr,0.85fr]">
<section class="card-apple">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 class="section-title">最近借用记录</h3>
<p class="section-subtitle">把资产、使用者、地点和归还计划放在一张表里,减少来回点开。</p>
</div>
<router-link to="/borrow" class="btn-apple-secondary text-sm">查看全部记录</router-link>
</div>
<div class="table-shell mt-5">
<div class="overflow-x-auto">
<table class="data-table min-w-[860px]">
<thead>
<tr>
<th class="text-left">资产</th>
<th class="text-left">使用者</th>
<th class="text-left">位置</th>
<th class="text-left">借用时间</th>
<th class="text-left">归还计划</th>
<th class="text-left">状态</th>
</tr>
</thead>
<tbody>
<tr v-for="record in recentRecords" :key="record.id">
<td>
<p class="font-medium text-system-label-primary">{{ record.equipment_name }}</p>
<p class="mt-1 font-mono text-xs text-system-label-tertiary">{{ record.equipment_code }}</p>
</td>
<td>
<p class="font-medium text-system-label-primary">{{ record.person_nickname }}</p>
<p class="mt-1 text-xs text-system-label-secondary">{{ record.person_name }}</p>
</td>
<td>
<p class="text-sm text-system-label-primary">{{ record.storageLocation?.name || '-' }}</p>
<p v-if="record.storageLocation?.code" class="mt-1 text-xs text-system-label-tertiary">
{{ record.storageLocation.code }}
</p>
</td>
<td class="text-sm text-system-label-secondary">{{ formatDateTime(record.borrow_time) }}</td>
<td class="text-sm text-system-label-secondary">
{{
record.is_long_term
? '在职长期'
: record.expected_return_time
? formatDateTime(record.expected_return_time)
: '-'
}}
</td>
<td>
<span
class="inline-flex rounded-full px-3 py-1 text-xs font-medium"
:class="getStatusClass(record.status)"
>
{{ record.status }}
</span>
</td>
</tr>
<tr v-if="recentRecords.length === 0">
<td colspan="6" class="text-center text-sm text-system-label-secondary">最近暂无借用记录。</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<section class="card-apple">
<div>
<h3 class="section-title">运营提醒</h3>
<p class="section-subtitle">给管理者一个可以快速判断是否异常的短面板。</p>
</div>
<div class="mt-5 space-y-3">
<div class="panel-muted">
<p class="text-xs uppercase tracking-[0.14em] text-system-label-tertiary">总资产价值</p>
<p class="mt-2 text-2xl font-semibold text-system-label-primary">
{{ formatCurrencyNoFraction(stats?.total_value || 0) }}
</p>
<p class="mt-2 text-sm text-system-label-secondary">
可用价值 {{ formatCurrencyNoFraction(stats?.available_value || 0) }}
</p>
</div>
<div class="panel-muted">
<p class="text-xs uppercase tracking-[0.14em] text-system-label-tertiary">借出压力</p>
<p class="mt-2 text-lg font-semibold text-system-label-primary">
{{ formatNumber(stats?.borrowed_equipment || 0) }} 件在外
</p>
<p class="mt-2 text-sm text-system-label-secondary">
对应价值 {{ formatCurrencyNoFraction(stats?.borrowed_value || 0) }}
</p>
</div>
<div class="panel-muted">
<p class="text-xs uppercase tracking-[0.14em] text-system-label-tertiary">维护提醒</p>
<p class="mt-2 text-lg font-semibold text-system-label-primary">
{{ formatNumber(stats?.maintenance_equipment || 0) }} 件处理中
</p>
<p class="mt-2 text-sm text-system-label-secondary">
当前维护价值 {{ formatCurrencyNoFraction(stats?.maintenance_value || 0) }}
</p>
</div>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
import * as echarts from 'echarts'
import { getDashboardStats } from '@/api/statistics'
import { getBorrowRecordList } from '@/api/borrow'
import type { DashboardStats, BorrowRecord } from '@/types'
import { formatDateTime } from '@/utils/format'
import { formatCurrencyNoFraction, formatDateTime, formatNumber, formatPercent } from '@/utils/format'
import StatCard from '@/components/StatCard.vue'
import {
CubeIcon,
BanknotesIcon,
ChartBarSquareIcon,
CheckCircleIcon,
ArrowUpTrayIcon,
WrenchScrewdriverIcon
CubeIcon,
WrenchScrewdriverIcon,
ArrowPathRoundedSquareIcon
} from '@heroicons/vue/24/outline'
const authStore = useAuthStore()
const stats = ref<DashboardStats | null>(null)
const recentRecords = ref<BorrowRecord[]>([])
const statusChartRef = ref<HTMLElement>()
const categoryChartRef = ref<HTMLElement>()
const isLoading = ref(false)
let statusChart: echarts.ECharts | null = null
let categoryChart: echarts.ECharts | null = null
const isLoading = ref(false)
const statCards = ref([
{ title: '资产库存总量', value: '0', icon: CubeIcon, color: 'apple-blue', trend: null },
{ title: '可用库存', value: '0', icon: CheckCircleIcon, color: 'apple-green', trend: null },
{ title: '已借出库存', value: '0', icon: ArrowUpTrayIcon, color: 'apple-orange', trend: null },
{ title: '维护中库存', value: '0', icon: WrenchScrewdriverIcon, color: 'apple-yellow', trend: null }
])
const isAdmin = computed(() => authStore.user?.role === 'admin')
const statCards = computed(() => {
const data = stats.value
return [
{
title: '资产库存总量',
value: formatNumber(data?.total_equipment || 0),
icon: CubeIcon,
color: 'apple-blue',
subtitle: `总资产价值 ${formatCurrencyNoFraction(data?.total_value || 0)}`
},
{
title: '可用库存',
value: formatNumber(data?.available_equipment || 0),
icon: CheckCircleIcon,
color: 'apple-green',
subtitle: `可用价值 ${formatCurrencyNoFraction(data?.available_value || 0)}`
},
{
title: '已借出库存',
value: formatNumber(data?.borrowed_equipment || 0),
icon: ArrowPathRoundedSquareIcon,
color: 'apple-orange',
subtitle: `借出价值 ${formatCurrencyNoFraction(data?.borrowed_value || 0)}`
},
{
title: '维护中库存',
value: formatNumber(data?.maintenance_equipment || 0),
icon: WrenchScrewdriverIcon,
color: 'apple-yellow',
subtitle: `维护价值 ${formatCurrencyNoFraction(data?.maintenance_value || 0)}`
},
{
title: '今日借出',
value: formatNumber(data?.today_borrow || 0),
icon: ChartBarSquareIcon,
color: 'apple-blue',
subtitle: `数量利用率 ${formatPercent(data?.usage_rate || 0)}`
},
{
title: '今日归还',
value: formatNumber(data?.today_return || 0),
icon: BanknotesIcon,
color: 'apple-green',
subtitle: `价值利用率 ${formatPercent(data?.value_usage_rate || 0)}`
}
]
})
const topCategories = computed(() =>
[...(stats.value?.inventory_by_type || [])]
.sort((a, b) => b.total_quantity - a.total_quantity)
.slice(0, 5)
)
const fetchDashboardData = async () => {
isLoading.value = true
try {
const [statsResponse, recordsResponse] = await Promise.all([
getDashboardStats(),
getBorrowRecordList({ page: 1, pageSize: 10 })
getBorrowRecordList({ page: 1, pageSize: 8 })
])
if (statsResponse.data) {
stats.value = statsResponse.data
updateStatCards(statsResponse.data)
updateStatusChart()
updateCategoryChart()
}
@ -152,106 +333,95 @@ const fetchDashboardData = async () => {
}
}
const updateStatCards = (data: DashboardStats) => {
statCards.value[0].value = data.total_equipment.toString()
statCards.value[1].value = data.available_equipment.toString()
statCards.value[2].value = data.borrowed_equipment.toString()
statCards.value[3].value = data.maintenance_equipment.toString()
}
const initCharts = () => {
if (statusChartRef.value && !statusChart) {
statusChart = echarts.init(statusChartRef.value)
}
if (categoryChartRef.value && !categoryChart) {
categoryChart = echarts.init(categoryChartRef.value)
}
updateStatusChart()
updateCategoryChart()
}
const updateStatusChart = () => {
if (!statusChart) return
if (!statusChart || !stats.value) return
statusChart.setOption({
tooltip: {
trigger: 'item'
},
series: [{
type: 'pie',
radius: '70%',
data: [
{ value: stats.value?.available_equipment || 0, name: '可用', itemStyle: { color: '#34C759' } },
{ value: stats.value?.borrowed_equipment || 0, name: '已借出', itemStyle: { color: '#FF9500' } },
{ value: stats.value?.maintenance_equipment || 0, name: '维护中', itemStyle: { color: '#FFCC00' } }
],
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)'
}
},
label: {
formatter: '{b}: {d}%'
tooltip: { trigger: 'item' },
legend: {
bottom: 0,
icon: 'circle',
textStyle: {
color: '#6b7280'
}
}]
},
series: [
{
type: 'pie',
radius: ['46%', '74%'],
center: ['50%', '45%'],
padAngle: 2,
label: {
formatter: '{b}\n{d}%',
color: '#5f6675'
},
data: [
{ value: stats.value.available_equipment || 0, name: '可用', itemStyle: { color: '#34C759' } },
{ value: stats.value.borrowed_equipment || 0, name: '已借出', itemStyle: { color: '#FF9500' } },
{ value: stats.value.maintenance_equipment || 0, name: '维护中', itemStyle: { color: '#FFCC00' } }
]
}
]
})
}
const updateCategoryChart = () => {
if (!categoryChart) return
const categories = stats.value?.inventory_by_type || []
if (!categoryChart || !stats.value) return
const categories = topCategories.value
categoryChart.setOption({
tooltip: {
trigger: 'item'
trigger: 'axis',
axisPointer: { type: 'shadow' }
},
series: [{
type: 'pie',
radius: ['45%', '70%'],
avoidLabelOverlap: false,
data: categories.length
? categories.map((item, index) => ({
value: item.total_quantity,
name: item.equipment_type,
itemStyle: {
color: getCategoryColor(index)
}
}))
: [{ value: 1, name: '暂无数据', itemStyle: { color: '#d1d1d6' } }],
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)'
}
},
label: {
formatter: '{b}: {d}%'
grid: {
left: 0,
right: 0,
top: 10,
bottom: 10,
containLabel: true
},
xAxis: {
type: 'value',
splitLine: { lineStyle: { color: 'rgba(99, 102, 112, 0.08)' } }
},
yAxis: {
type: 'category',
axisTick: { show: false },
data: categories.map((item) => item.equipment_type)
},
series: [
{
type: 'bar',
barWidth: 16,
data: categories.map((item, index) => ({
value: item.total_quantity,
itemStyle: {
borderRadius: 999,
color: ['#355C7D', '#4D8076', '#C06C84', '#F08A5D', '#8F6BB3'][index % 5]
}
}))
}
}]
]
})
}
const categoryColorPalette = [
'#0071e3',
'#34c759',
'#ff9500',
'#af52de',
'#ff2d55',
'#5ac8fa',
'#ff3b30',
'#30b0c7'
]
const getCategoryColor = (index: number) => categoryColorPalette[index % categoryColorPalette.length]
const getStatusClass = (status: string) => {
const classes: Record<string, string> = {
'已借出': 'bg-apple-orange text-white',
'已归还': 'bg-apple-green text-white',
'逾期': 'bg-apple-red text-white'
'已借出': 'bg-apple-orange/15 text-apple-orange',
'已归还': 'bg-apple-green/15 text-apple-green',
'逾期': 'bg-apple-red/15 text-apple-red'
}
return classes[status] || 'bg-system-gray-5 text-system-label-secondary'
}
@ -279,6 +449,7 @@ onUnmounted(() => {
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;

View File

@ -1,6 +1,34 @@
<template>
<div class="space-y-6 overflow-hidden">
<div class="flex flex-wrap justify-between gap-3">
<section class="page-header">
<div>
<p class="page-kicker">Asset Control</p>
<h2 class="page-title">资产管理中心</h2>
<p class="page-subtitle">
这里聚合了筛选、批量操作、导入导出和挂载信息,适合做日常维护、盘点与资产结构调整。
</p>
</div>
<div class="page-meta-grid">
<div class="page-meta">
<p class="page-meta__label">总记录</p>
<p class="page-meta__value">{{ formatNumber(total) }}</p>
<p class="page-meta__subvalue">当前第 {{ currentPage }} 页</p>
</div>
<div class="page-meta">
<p class="page-meta__label">当前页可用</p>
<p class="page-meta__value">{{ formatNumber(pageSummary.available) }}</p>
<p class="page-meta__subvalue">借出 {{ formatNumber(pageSummary.borrowed) }}</p>
</div>
<div class="page-meta">
<p class="page-meta__label">挂载关系</p>
<p class="page-meta__value">{{ formatNumber(pageSummary.parentAssets) }}</p>
<p class="page-meta__subvalue">父资产 · 配件 {{ formatNumber(pageSummary.childAssets) }}</p>
</div>
</div>
</section>
<div class="toolbar-panel">
<div class="flex items-center space-x-3">
<template v-if="selectedIds.length > 0">
<span class="text-sm text-system-label-secondary">已选 {{ selectedIds.length }} 项</span>
@ -54,37 +82,37 @@
</div>
<div class="card-apple overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full min-w-[1280px]">
<div class="table-shell">
<div class="overflow-x-auto">
<table class="data-table min-w-[1280px]">
<thead>
<tr class="border-b border-system-gray-5">
<th class="text-left py-3 px-4 text-apple-subhead text-system-label-secondary whitespace-nowrap">
<tr>
<th class="text-left whitespace-nowrap">
<input type="checkbox" :checked="isAllSelected" @change="toggleSelectAll" class="rounded" />
</th>
<th class="text-left py-3 px-4 text-apple-subhead text-system-label-secondary whitespace-nowrap">序号</th>
<th class="text-left py-3 px-4 text-apple-subhead text-system-label-secondary whitespace-nowrap">资产编码</th>
<th class="text-left py-3 px-4 text-apple-subhead text-system-label-secondary whitespace-nowrap">资产名称</th>
<th class="text-left py-3 px-4 text-apple-subhead text-system-label-secondary whitespace-nowrap">资产型号</th>
<th class="text-left py-3 px-4 text-apple-subhead text-system-label-secondary whitespace-nowrap">资产类型</th>
<th class="text-left py-3 px-4 text-apple-subhead text-system-label-secondary whitespace-nowrap">使用部门</th>
<th class="text-left py-3 px-4 text-apple-subhead text-system-label-secondary whitespace-nowrap">地址</th>
<th class="text-left py-3 px-4 text-apple-subhead text-system-label-secondary whitespace-nowrap">单价</th>
<th class="text-left py-3 px-4 text-apple-subhead text-system-label-secondary whitespace-nowrap">状态</th>
<th class="text-left py-3 px-4 text-apple-subhead text-system-label-secondary whitespace-nowrap">库存</th>
<th class="text-left py-3 px-4 text-apple-subhead text-system-label-secondary whitespace-nowrap">操作</th>
<th class="text-left whitespace-nowrap">序号</th>
<th class="text-left whitespace-nowrap">资产编码</th>
<th class="text-left whitespace-nowrap">资产名称</th>
<th class="text-left whitespace-nowrap">资产型号</th>
<th class="text-left whitespace-nowrap">资产类型</th>
<th class="text-left whitespace-nowrap">使用部门</th>
<th class="text-left whitespace-nowrap">地址</th>
<th class="text-left whitespace-nowrap">单价</th>
<th class="text-left whitespace-nowrap">状态</th>
<th class="text-left whitespace-nowrap">库存</th>
<th class="text-left whitespace-nowrap">操作</th>
</tr>
</thead>
<tbody>
<template v-for="equipment in displayList" :key="equipment.id">
<!-- 父资产行 -->
<tr
class="border-b border-system-gray-6 hover:bg-system-gray-6 transition-colors"
:class="{ 'bg-apple-blue/5': isParentAsset(equipment) }"
>
<td class="py-3 px-4 whitespace-nowrap">
<td class="whitespace-nowrap">
<input type="checkbox" :checked="selectedIds.includes(equipment.id)" @change="toggleSelect(equipment.id)" class="rounded" />
</td>
<td class="py-3 px-4 text-apple-body whitespace-nowrap">
<td class="text-apple-body whitespace-nowrap">
<div class="flex items-center gap-2">
<!-- 展开/收起按钮 -->
<button
@ -99,8 +127,8 @@
{{ equipment.id }}
</div>
</td>
<td class="py-3 px-4 text-apple-body font-mono whitespace-nowrap">{{ equipment.equipment_code }}</td>
<td class="py-3 px-4 text-apple-body">
<td class="text-apple-body font-mono whitespace-nowrap">{{ equipment.equipment_code }}</td>
<td class="text-apple-body">
<div class="flex items-center gap-2">
<span class="max-w-[220px] break-words leading-6" :title="equipment.equipment_name">
{{ equipment.equipment_name }}
@ -111,18 +139,18 @@
<span v-if="hasChildren(equipment)" class="text-xs text-system-label-tertiary">({{ equipment.children?.length }}个配件)</span>
</div>
</td>
<td class="py-3 px-4 text-apple-subhead">
<td class="text-apple-subhead">
<span class="block max-w-[180px] break-words leading-6" :title="equipment.equipment_model || '-'">
{{ equipment.equipment_model || '-' }}
</span>
</td>
<td class="py-3 px-4 text-apple-subhead whitespace-nowrap">{{ equipment.equipment_type }}</td>
<td class="py-3 px-4 text-apple-subhead">
<td class="text-apple-subhead whitespace-nowrap">{{ equipment.equipment_type }}</td>
<td class="text-apple-subhead">
<span class="block max-w-[160px] break-words leading-6" :title="equipment.department?.name || '-'">
{{ equipment.department?.name || '-' }}
</span>
</td>
<td class="py-3 px-4 text-apple-subhead">
<td class="text-apple-subhead">
<span
class="block max-w-[220px] break-words leading-6"
:title="equipment.storageLocation ? `${equipment.storageLocation.name}${equipment.storageLocation.code ? `(${equipment.storageLocation.code})` : ''}` : '-'"
@ -131,12 +159,12 @@
<template v-else>-</template>
</span>
</td>
<td class="py-3 px-4 text-apple-body whitespace-nowrap">{{ formatCurrencyNoFraction(Number(equipment.unit_price || 0)) }}</td>
<td class="py-3 px-4 whitespace-nowrap">
<td class="text-apple-body whitespace-nowrap">{{ formatCurrencyNoFraction(Number(equipment.unit_price || 0)) }}</td>
<td class="whitespace-nowrap">
<span class="px-3 py-1 rounded-apple text-apple-footnote font-medium whitespace-nowrap" :class="getStatusClass(equipment.status)">{{ equipment.status }}</span>
</td>
<td class="py-3 px-4 text-apple-body whitespace-nowrap">{{ equipment.remaining_quantity }} / {{ equipment.total_quantity }}</td>
<td class="py-3 px-4 whitespace-nowrap">
<td class="text-apple-body whitespace-nowrap">{{ equipment.remaining_quantity }} / {{ equipment.total_quantity }}</td>
<td class="whitespace-nowrap">
<div class="flex items-center space-x-3 whitespace-nowrap">
<button @click="handleEdit(equipment.id)" class="text-apple-blue hover:underline text-apple-footnote">编辑</button>
<button @click="handleDelete(equipment.id)" class="text-apple-red hover:underline text-apple-footnote">删除</button>
@ -149,14 +177,14 @@
<tr
v-for="child in equipment.children"
:key="'child-' + child.id"
class="border-b border-system-gray-6 bg-apple-purple/5 hover:bg-apple-purple/10 transition-colors"
class="bg-apple-purple/5 hover:bg-apple-purple/10 transition-colors"
>
<td class="py-2 px-4"></td>
<td class="py-2 px-4 text-apple-footnote whitespace-nowrap pl-10">
<td></td>
<td class="text-apple-footnote whitespace-nowrap pl-10">
<span class="text-system-label-tertiary">└</span>
</td>
<td class="py-2 px-4 text-apple-footnote font-mono whitespace-nowrap text-system-label-secondary">{{ child.equipment_code }}</td>
<td class="py-2 px-4 text-apple-footnote">
<td class="text-apple-footnote font-mono whitespace-nowrap text-system-label-secondary">{{ child.equipment_code }}</td>
<td class="text-apple-footnote">
<div class="flex items-center gap-2">
<span class="max-w-[220px] break-words leading-5 text-system-label-secondary" :title="child.equipment_name">
{{ child.equipment_name }}
@ -164,18 +192,18 @@
<span class="px-1.5 py-0.5 rounded text-xs bg-apple-purple/15 text-apple-purple">配件</span>
</div>
</td>
<td class="py-2 px-4 text-apple-footnote text-system-label-tertiary">
<td class="text-apple-footnote text-system-label-tertiary">
<span class="block max-w-[180px] break-words leading-5" :title="child.equipment_model || '-'">
{{ child.equipment_model || '-' }}
</span>
</td>
<td class="py-2 px-4 text-apple-footnote whitespace-nowrap text-system-label-tertiary">{{ child.equipment_type }}</td>
<td class="py-2 px-4 text-apple-footnote whitespace-nowrap text-system-label-tertiary">-</td>
<td class="py-2 px-4 text-apple-footnote whitespace-nowrap text-system-label-tertiary">-</td>
<td class="py-2 px-4 text-apple-footnote whitespace-nowrap text-system-label-secondary">{{ formatCurrencyNoFraction(Number(child.unit_price || 0)) }}</td>
<td class="py-2 px-4 whitespace-nowrap">-</td>
<td class="py-2 px-4 text-apple-footnote whitespace-nowrap text-system-label-tertiary">-</td>
<td class="py-2 px-4 whitespace-nowrap">
<td class="text-apple-footnote whitespace-nowrap text-system-label-tertiary">{{ child.equipment_type }}</td>
<td class="text-apple-footnote whitespace-nowrap text-system-label-tertiary">-</td>
<td class="text-apple-footnote whitespace-nowrap text-system-label-tertiary">-</td>
<td class="text-apple-footnote whitespace-nowrap text-system-label-secondary">{{ formatCurrencyNoFraction(Number(child.unit_price || 0)) }}</td>
<td class="whitespace-nowrap">-</td>
<td class="text-apple-footnote whitespace-nowrap text-system-label-tertiary">-</td>
<td class="whitespace-nowrap">
<button @click="handleEdit(child.id)" class="text-apple-blue hover:underline text-apple-footnote">编辑</button>
</td>
</tr>
@ -183,6 +211,7 @@
</template>
</tbody>
</table>
</div>
</div>
<div class="flex items-center justify-between mt-4 pt-4 border-t border-system-gray-5">
@ -296,7 +325,7 @@ import { batchImportEquipments } from '@/api/batchImport'
import { getPersonOptions } from '@/api/person'
import { batchBorrow, batchReturn, batchTransfer } from '@/api/batch'
import type { Equipment, StorageLocation, AssetType, Department, Person } from '@/types'
import { formatCurrencyNoFraction } from '@/utils/format'
import { formatCurrencyNoFraction, formatNumber } from '@/utils/format'
import BarcodePrint from '@/components/BarcodePrint.vue'
const router = useRouter()
@ -427,6 +456,32 @@ const displayList = computed(() => {
return equipmentList.value.filter(e => !e.parent_id)
})
const pageSummary = computed(() => {
const summary = {
available: 0,
borrowed: 0,
parentAssets: 0,
childAssets: 0
}
for (const equipment of equipmentList.value) {
if (equipment.parent_id) {
summary.childAssets += 1
}
if (equipment.children?.length) {
summary.parentAssets += 1
}
if (equipment.remaining_quantity > 0) {
summary.available += 1
}
if (equipment.total_quantity > equipment.remaining_quantity) {
summary.borrowed += 1
}
}
return summary
})
const fetchEquipmentList = async () => {
loading.value = true
try {

View File

@ -1,126 +1,330 @@
<template>
<div class="space-y-6">
<!-- Value Statistics -->
<div class="card-apple">
<h2 class="text-apple-title mb-4">资产价值统计</h2>
<div ref="valueChartRef" class="h-96"></div>
<section class="page-header">
<div>
<p class="page-kicker">Value Analytics</p>
<h2 class="page-title">资产统计分析</h2>
<p class="page-subtitle">
这里重点看价值结构和高价值资产,适合做盘点准备、采购复盘和对外汇报。
</p>
</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>
</div>
<div class="page-meta">
<p class="page-meta__label">可用价值</p>
<p class="page-meta__value">{{ formatCurrencyNoFraction(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">{{ formatCurrencyNoFraction(summary.borrowed_value) }}</p>
<p class="page-meta__subvalue">当前在外资产</p>
</div>
</div>
</section>
<section class="toolbar-panel">
<div class="grid flex-1 grid-cols-1 gap-4 md:grid-cols-3">
<div>
<label class="mb-2 block text-sm font-medium text-system-label-primary">资产类型</label>
<select v-model="filters.equipment_type" class="input-apple" @change="handleSearch">
<option value="">全部类型</option>
<option v-for="assetType in assetTypes" :key="assetType.id" :value="assetType.name">
{{ assetType.name }}
</option>
</select>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-system-label-primary">开始日期</label>
<input v-model="filters.start_date" type="date" class="input-apple" />
</div>
<div>
<label class="mb-2 block text-sm font-medium text-system-label-primary">结束日期</label>
<input v-model="filters.end_date" type="date" class="input-apple" />
</div>
</div>
<div class="flex flex-wrap gap-3">
<button class="btn-apple-secondary" @click="handleReset">重置</button>
<button class="btn-apple-primary" :disabled="loading" @click="handleSearch">
{{ loading ? '统计中...' : '应用筛选' }}
</button>
</div>
</section>
<div class="metric-grid">
<div class="card-apple">
<p class="text-[12px] font-semibold uppercase tracking-[0.14em] text-system-label-tertiary">总资产价值</p>
<p class="mt-3 text-[2rem] font-semibold tracking-[-0.03em] text-system-label-primary">
{{ formatCurrencyNoFraction(summary.total_value) }}
</p>
<p class="mt-2 text-sm text-system-label-secondary">当前筛选条件下的全部资产价值。</p>
</div>
<div class="card-apple">
<p class="text-[12px] font-semibold uppercase tracking-[0.14em] text-system-label-tertiary">可用价值</p>
<p class="mt-3 text-[2rem] font-semibold tracking-[-0.03em] text-apple-green">
{{ formatCurrencyNoFraction(summary.available_value) }}
</p>
<p class="mt-2 text-sm text-system-label-secondary">随时可调拨、借出的资产价值。</p>
</div>
<div class="card-apple">
<p class="text-[12px] font-semibold uppercase tracking-[0.14em] text-system-label-tertiary">借出价值</p>
<p class="mt-3 text-[2rem] font-semibold tracking-[-0.03em] text-apple-orange">
{{ formatCurrencyNoFraction(summary.borrowed_value) }}
</p>
<p class="mt-2 text-sm text-system-label-secondary">当前在外流转资产的价值规模。</p>
</div>
<div class="card-apple">
<p class="text-[12px] font-semibold uppercase tracking-[0.14em] text-system-label-tertiary">维护价值</p>
<p class="mt-3 text-[2rem] font-semibold tracking-[-0.03em] text-apple-yellow">
{{ formatCurrencyNoFraction(summary.maintenance_value) }}
</p>
<p class="mt-2 text-sm text-system-label-secondary">维修状态资产对应的价值体量。</p>
</div>
</div>
<!-- Value by Type -->
<div class="card-apple">
<h2 class="text-apple-title mb-4">资产类型价值分布</h2>
<div ref="typeChartRef" class="h-96"></div>
<div class="grid grid-cols-1 gap-6 xl:grid-cols-[0.92fr,1.08fr]">
<section class="card-apple">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 class="section-title">价值结构占比</h3>
<p class="section-subtitle">先看可用、借出和维护中的价值分布。</p>
</div>
<span class="chip-muted">价值口径</span>
</div>
<div ref="valueChartRef" class="mt-5 h-80"></div>
</section>
<section class="card-apple">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 class="section-title">按类型的价值结构</h3>
<p class="section-subtitle">哪些类型价值高、占比重,一眼能看出来。</p>
</div>
<span class="chip-muted">总值 / 借出 / 可用</span>
</div>
<div ref="typeChartRef" class="mt-5 h-80"></div>
</section>
</div>
<section class="card-apple">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 class="section-title">高价值资产 Top 10</h3>
<p class="section-subtitle">适合盘点时优先关注,也适合对外汇报重点资产构成。</p>
</div>
<span class="chip-muted">按总价值排序</span>
</div>
<div class="table-shell mt-5">
<div class="overflow-x-auto">
<table class="data-table min-w-[760px]">
<thead>
<tr>
<th class="text-left">排名</th>
<th class="text-left">资产名称</th>
<th class="text-left">单价</th>
<th class="text-left">总价值</th>
<th class="text-left">借出价值</th>
<th class="text-left">可用价值</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in statistics?.top_equipment || []" :key="`${item.equipment_name}-${index}`">
<td>
<span class="chip-muted">{{ String(index + 1).padStart(2, '0') }}</span>
</td>
<td class="font-medium text-system-label-primary">{{ item.equipment_name }}</td>
<td class="text-system-label-secondary">{{ formatCurrencyNoFraction(item.unit_price) }}</td>
<td class="font-medium text-system-label-primary">{{ formatCurrencyNoFraction(item.total_value) }}</td>
<td class="text-apple-orange">{{ formatCurrencyNoFraction(item.borrowed_value) }}</td>
<td class="text-apple-green">{{ formatCurrencyNoFraction(item.available_value) }}</td>
</tr>
<tr v-if="!statistics?.top_equipment?.length">
<td colspan="6" class="text-center text-sm text-system-label-secondary">当前筛选条件下暂无高价值资产数据。</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import * as echarts from 'echarts'
import { getValueStatistics } from '@/api/statistics'
import { getAssetTypeOptions } from '@/api/assetType'
import type { AssetType, ValueStatistics } from '@/types'
import { formatCurrencyNoFraction } from '@/utils/format'
const valueChartRef = ref<HTMLElement>()
const typeChartRef = ref<HTMLElement>()
const loading = ref(false)
const assetTypes = ref<AssetType[]>([])
const statistics = ref<ValueStatistics | null>(null)
const filters = reactive({
equipment_type: '',
start_date: '',
end_date: ''
})
let valueChart: echarts.ECharts | null = null
let typeChart: echarts.ECharts | null = null
const fetchStatistics = async () => {
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
}))
const fetchAssetTypes = async () => {
try {
const response = await getValueStatistics()
const response = await getAssetTypeOptions()
if (response.data) {
assetTypes.value = response.data
}
} catch (error) {
console.error('获取资产类型失败:', error)
}
}
const fetchStatistics = async () => {
loading.value = true
try {
const response = await getValueStatistics({
equipment_type: filters.equipment_type || undefined,
start_date: filters.start_date || undefined,
end_date: filters.end_date || undefined
})
if (response.data) {
statistics.value = response.data
initCharts(response.data)
}
} catch (error) {
console.error('获取统计数据失败:', error)
} finally {
loading.value = false
}
}
const initCharts = (data: any) => {
if (valueChartRef.value) {
const initCharts = (data: ValueStatistics) => {
if (valueChartRef.value && !valueChart) {
valueChart = echarts.init(valueChartRef.value)
valueChart.setOption({
tooltip: {
trigger: 'item'
},
legend: {
orient: 'vertical',
left: 'left'
},
series: [{
type: 'pie',
radius: '50%',
data: [
{ value: data.available_value, name: '可用价值', itemStyle: { color: '#34C759' } },
{ value: data.borrowed_value, name: '已借出价值', itemStyle: { color: '#FF9500' } },
{ value: data.maintenance_value, name: '维护中价值', itemStyle: { color: '#FFCC00' } }
],
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)'
}
}
}]
})
}
if (typeChartRef.value && !typeChart) {
typeChart = echarts.init(typeChartRef.value)
}
if (typeChartRef.value && data.value_by_type) {
typeChart = echarts.init(typeChartRef.value)
typeChart.setOption({
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
}
},
legend: {
data: ['总价值', '已借出价值', '可用价值']
},
xAxis: {
type: 'category',
data: data.value_by_type.map((item: any) => item.equipment_type)
},
yAxis: {
type: 'value'
},
series: [
{
name: '总价值',
type: 'bar',
data: data.value_by_type.map((item: any) => item.total_value),
itemStyle: { color: '#007AFF' }
valueChart?.setOption({
tooltip: { trigger: 'item' },
legend: {
bottom: 0,
icon: 'circle',
textStyle: {
color: '#6b7280'
}
},
series: [
{
type: 'pie',
radius: ['48%', '76%'],
center: ['50%', '43%'],
label: {
formatter: '{b}\n{d}%',
color: '#5f6675'
},
{
name: '已借出价值',
type: 'bar',
data: data.value_by_type.map((item: any) => item.borrowed_value),
itemStyle: { color: '#FF9500' }
},
{
name: '可用价值',
type: 'bar',
data: data.value_by_type.map((item: any) => item.available_value),
itemStyle: { color: '#34C759' }
}
]
})
}
data: [
{ value: data.available_value, name: '可用价值', itemStyle: { color: '#34C759' } },
{ value: data.borrowed_value, name: '借出价值', itemStyle: { color: '#FF9500' } },
{ value: data.maintenance_value, name: '维护价值', itemStyle: { color: '#FFCC00' } }
]
}
]
})
typeChart?.setOption({
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
}
},
legend: {
bottom: 0,
data: ['总价值', '借出价值', '可用价值']
},
grid: {
left: 0,
right: 10,
top: 16,
bottom: 50,
containLabel: true
},
xAxis: {
type: 'category',
axisTick: { show: false },
data: data.value_by_type.map((item) => item.equipment_type)
},
yAxis: {
type: 'value',
splitLine: { lineStyle: { color: 'rgba(99, 102, 112, 0.08)' } }
},
series: [
{
name: '总价值',
type: 'bar',
barWidth: 18,
itemStyle: { color: '#355C7D', borderRadius: [6, 6, 0, 0] },
data: data.value_by_type.map((item) => item.total_value)
},
{
name: '借出价值',
type: 'bar',
itemStyle: { color: '#F08A5D', borderRadius: [6, 6, 0, 0] },
data: data.value_by_type.map((item) => item.borrowed_value)
},
{
name: '可用价值',
type: 'bar',
itemStyle: { color: '#4D8076', borderRadius: [6, 6, 0, 0] },
data: data.value_by_type.map((item) => item.available_value)
}
]
})
}
const handleSearch = () => {
fetchStatistics()
}
const handleReset = () => {
filters.equipment_type = ''
filters.start_date = ''
filters.end_date = ''
fetchStatistics()
}
const resizeHandler = () => {
valueChart?.resize()
typeChart?.resize()
}
onMounted(async () => {
await fetchStatistics()
window.addEventListener('resize', () => {
valueChart?.resize()
typeChart?.resize()
})
await Promise.all([fetchAssetTypes(), fetchStatistics()])
window.addEventListener('resize', resizeHandler)
})
onUnmounted(() => {
valueChart?.dispose()
typeChart?.dispose()
window.removeEventListener('resize', () => {})
window.removeEventListener('resize', resizeHandler)
})
</script>

View File

@ -1,5 +1,5 @@
param(
[string]$HostName = "1.13.22.133",
[string]$HostName = "",
[string]$User = "root",
[string]$RemoteDir = "/opt/equipment-management",
[string]$RemoteArchive = "/tmp/equipment-management-release.tar.gz",
@ -9,6 +9,14 @@ param(
$ErrorActionPreference = "Stop"
if (-not $HostName -and $env:DEPLOY_HOST) {
$HostName = $env:DEPLOY_HOST
}
if (-not $HostName) {
throw "请通过 -HostName 或 DEPLOY_HOST 提供部署服务器地址。"
}
$projectRoot = Split-Path -Parent $PSScriptRoot
$password = if ($env:DEPLOY_PASSWORD) { $env:DEPLOY_PASSWORD } else { "" }
$archiveName = "equipment-management-release.tar.gz"