备份当前功能修复与界面改版基线
This commit is contained in:
parent
b8132cce4c
commit
6d371f347b
20
.env.example
Normal file
20
.env.example
Normal 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
3
.gitignore
vendored
@ -1,4 +1,7 @@
|
||||
hr_sync_cookie.txt
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
backend/node_modules/
|
||||
backend/dist/
|
||||
frontend/node_modules/
|
||||
|
||||
23
README.md
23
README.md
@ -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`
|
||||
|
||||
## 注意事项
|
||||
|
||||
|
||||
@ -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[] = []
|
||||
|
||||
@ -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: '已报废' }
|
||||
})
|
||||
})
|
||||
|
||||
// 记录操作日志
|
||||
|
||||
@ -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: '可用' }
|
||||
})
|
||||
})
|
||||
|
||||
// 记录操作日志
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
97
backend/src/utils/authIdentity.ts
Normal file
97
backend/src/utils/authIdentity.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
20
backend/src/utils/equipmentGuards.ts
Normal file
20
backend/src/utils/equipmentGuards.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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 "=========================================="
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user