first commit
This commit is contained in:
commit
dee0ca70f6
34
Dockerfile
Normal file
34
Dockerfile
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
FROM node:20-alpine as builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 构建参数
|
||||||
|
ARG VITE_API_BASE_URL=http://localhost:3000/api
|
||||||
|
ARG VITE_AUTH_MODE=local
|
||||||
|
|
||||||
|
# 设置环境变量
|
||||||
|
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
|
||||||
|
ENV VITE_AUTH_MODE=$VITE_AUTH_MODE
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install --registry=https://registry.npmmirror.com
|
||||||
|
|
||||||
|
# 复制源代码
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# 构建前端
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# 生产环境使用nginx
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# 复制构建产物
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# 复制nginx配置
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
27
backend/Dockerfile
Normal file
27
backend/Dockerfile
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
FROM node:18-alpine
|
||||||
|
|
||||||
|
# 安装OpenSSL
|
||||||
|
RUN apk add --no-cache openssl openssl-dev
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install --registry=https://registry.npmmirror.com
|
||||||
|
|
||||||
|
# 复制源代码
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# 生成Prisma客户端
|
||||||
|
RUN npx prisma generate
|
||||||
|
|
||||||
|
# 构建TypeScript
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# 创建uploads目录
|
||||||
|
RUN mkdir -p uploads
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# 启动脚本
|
||||||
|
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/index.js"]
|
||||||
2868
backend/package-lock.json
generated
Normal file
2868
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
backend/package.json
Normal file
41
backend/package.json
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "摄影设备借还系统后端",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "nodemon --exec ts-node src/index.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"prisma:generate": "prisma generate",
|
||||||
|
"prisma:migrate": "prisma migrate dev",
|
||||||
|
"prisma:studio": "prisma studio"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/client": "^5.0.0",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.0.0",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
|
"express": "^4.18.0",
|
||||||
|
"express-session": "^1.17.3",
|
||||||
|
"keycloak-connect": "^23.0.7",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"mysql2": "^3.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.2",
|
||||||
|
"@types/cors": "^2.8.0",
|
||||||
|
"@types/express": "^4.17.0",
|
||||||
|
"@types/express-session": "^1.17.7",
|
||||||
|
"@types/multer": "^1.4.7",
|
||||||
|
"@types/node": "^20.0.0",
|
||||||
|
"nodemon": "^3.0.0",
|
||||||
|
"prisma": "^5.0.0",
|
||||||
|
"ts-node": "^10.9.0",
|
||||||
|
"typescript": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
12
backend/prisma.config.ts
Normal file
12
backend/prisma.config.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { defineConfig, env } from "prisma/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "prisma/schema.prisma",
|
||||||
|
migrations: {
|
||||||
|
path: "prisma/migrations",
|
||||||
|
},
|
||||||
|
engine: "classic",
|
||||||
|
datasource: {
|
||||||
|
url: env("DATABASE_URL"),
|
||||||
|
},
|
||||||
|
});
|
||||||
302
backend/prisma/schema.prisma
Normal file
302
backend/prisma/schema.prisma
Normal file
@ -0,0 +1,302 @@
|
|||||||
|
// This is your Prisma schema file,
|
||||||
|
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "mysql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model AssetType {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
name String @unique @db.VarChar(50)
|
||||||
|
status String @default("active") @db.VarChar(20)
|
||||||
|
can_be_parent Boolean @default(false) // 可作为父资产
|
||||||
|
can_be_child Boolean @default(false) // 可作为子资产
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime @updatedAt
|
||||||
|
|
||||||
|
equipments Equipment[]
|
||||||
|
|
||||||
|
@@index([status])
|
||||||
|
@@map("asset_type")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Department {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
name String @unique @db.VarChar(100)
|
||||||
|
status String @default("active") @db.VarChar(20)
|
||||||
|
parent_id Int? // 父部门ID,null表示根部门
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime @updatedAt
|
||||||
|
|
||||||
|
// 自关联 - 部门层级
|
||||||
|
parent Department? @relation("DepartmentHierarchy", fields: [parent_id], references: [id])
|
||||||
|
children Department[] @relation("DepartmentHierarchy")
|
||||||
|
|
||||||
|
equipments Equipment[]
|
||||||
|
|
||||||
|
@@index([status])
|
||||||
|
@@index([parent_id])
|
||||||
|
@@map("department")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Equipment {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
equipment_code String @unique @db.VarChar(50)
|
||||||
|
equipment_name String @db.VarChar(100)
|
||||||
|
equipment_model String? @db.VarChar(100)
|
||||||
|
equipment_type String @db.VarChar(50)
|
||||||
|
code_generation_mode String @default("auto") @db.VarChar(20)
|
||||||
|
status String @default("可用") @db.VarChar(20)
|
||||||
|
total_quantity Int @default(0)
|
||||||
|
remaining_quantity Int @default(0)
|
||||||
|
unit_price Decimal @default(0.00) @db.Decimal(10, 2)
|
||||||
|
total_value Decimal @default(0.00) @db.Decimal(12, 2)
|
||||||
|
borrowed_value Decimal @default(0.00) @db.Decimal(12, 2)
|
||||||
|
available_value Decimal @default(0.00) @db.Decimal(12, 2)
|
||||||
|
description String? @db.Text
|
||||||
|
purchase_date DateTime? @db.Date
|
||||||
|
supplier String? @db.VarChar(100)
|
||||||
|
storage_location_id Int?
|
||||||
|
asset_type_id Int?
|
||||||
|
department_id Int?
|
||||||
|
// 父子资产关联字段
|
||||||
|
parent_id Int? // 父资产ID(如果已挂载)
|
||||||
|
mount_status String @default("none") @db.VarChar(20) // none/mounted/unmounted
|
||||||
|
total_value_with_children Decimal @default(0.00) @db.Decimal(12, 2) // 含配件总价值
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime @updatedAt
|
||||||
|
|
||||||
|
borrow_records BorrowRecord[]
|
||||||
|
storageLocation StorageLocation? @relation(fields: [storage_location_id], references: [id])
|
||||||
|
assetType AssetType? @relation(fields: [asset_type_id], references: [id])
|
||||||
|
department Department? @relation(fields: [department_id], references: [id])
|
||||||
|
// 父子资产自关联
|
||||||
|
parent Equipment? @relation("ParentChild", fields: [parent_id], references: [id])
|
||||||
|
children Equipment[] @relation("ParentChild")
|
||||||
|
// 挂载历史关联
|
||||||
|
mountHistories MountHistory[]
|
||||||
|
// 报废记录关联
|
||||||
|
disposalRecords DisposalRecord[]
|
||||||
|
// 维修记录关联
|
||||||
|
maintenanceRecords MaintenanceRecord[]
|
||||||
|
|
||||||
|
@@index([equipment_code])
|
||||||
|
@@index([equipment_type])
|
||||||
|
@@index([status])
|
||||||
|
@@index([code_generation_mode])
|
||||||
|
@@index([unit_price])
|
||||||
|
@@index([storage_location_id])
|
||||||
|
@@index([asset_type_id])
|
||||||
|
@@index([department_id])
|
||||||
|
@@index([parent_id])
|
||||||
|
@@index([mount_status])
|
||||||
|
@@map("equipment")
|
||||||
|
}
|
||||||
|
|
||||||
|
model BorrowRecord {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
equipment_id Int
|
||||||
|
equipment_code String @db.VarChar(50)
|
||||||
|
person_id Int
|
||||||
|
person_name String @db.VarChar(50)
|
||||||
|
person_nickname String @db.VarChar(50)
|
||||||
|
person_identifier String? @db.VarChar(50)
|
||||||
|
borrow_time DateTime @default(now())
|
||||||
|
expected_return_time DateTime?
|
||||||
|
is_long_term Boolean @default(false)
|
||||||
|
return_time DateTime?
|
||||||
|
status String @default("已借出") @db.VarChar(20)
|
||||||
|
return_condition String @default("正常") @db.VarChar(20)
|
||||||
|
notes String? @db.Text
|
||||||
|
children_snapshot String? @db.Text // JSON格式的配件快照
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime @updatedAt
|
||||||
|
|
||||||
|
equipment Equipment @relation(fields: [equipment_id], references: [id])
|
||||||
|
person Person @relation(fields: [person_id], references: [id])
|
||||||
|
|
||||||
|
@@index([equipment_id])
|
||||||
|
@@index([person_id])
|
||||||
|
@@index([status])
|
||||||
|
@@index([borrow_time])
|
||||||
|
@@map("borrow_record")
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
username String @unique @db.VarChar(50)
|
||||||
|
password String @db.VarChar(255)
|
||||||
|
name String @db.VarChar(50)
|
||||||
|
email String? @db.VarChar(100)
|
||||||
|
phone String? @db.VarChar(20)
|
||||||
|
role String @default("user") @db.VarChar(20)
|
||||||
|
status String @default("active") @db.VarChar(20)
|
||||||
|
last_login_at DateTime? @db.DateTime
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime @updatedAt
|
||||||
|
|
||||||
|
operation_logs OperationLog[]
|
||||||
|
site_configs SiteConfig[] @relation("SiteConfigUpdatedBy")
|
||||||
|
mount_histories MountHistory[]
|
||||||
|
// 报废记录关联
|
||||||
|
disposal_applied DisposalRecord[] @relation("DisposalApplier")
|
||||||
|
disposal_approved DisposalRecord[] @relation("DisposalApprover")
|
||||||
|
// 维修记录关联
|
||||||
|
maintenance_records MaintenanceRecord[]
|
||||||
|
|
||||||
|
@@index([role])
|
||||||
|
@@index([status])
|
||||||
|
@@map("user")
|
||||||
|
}
|
||||||
|
|
||||||
|
model SiteConfig {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
site_name String @db.VarChar(100)
|
||||||
|
logo_url String? @db.VarChar(255)
|
||||||
|
favicon_url String? @db.VarChar(255)
|
||||||
|
theme_color String? @db.VarChar(20)
|
||||||
|
updated_by Int?
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime @default(now())
|
||||||
|
|
||||||
|
updatedBy User? @relation("SiteConfigUpdatedBy", fields: [updated_by], references: [id])
|
||||||
|
|
||||||
|
@@map("site_config")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Person {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
nickname String @db.VarChar(50)
|
||||||
|
real_name String @db.VarChar(50)
|
||||||
|
id_number String? @unique @db.VarChar(50)
|
||||||
|
department String? @db.VarChar(100)
|
||||||
|
position String? @db.VarChar(100)
|
||||||
|
phone String? @db.VarChar(20)
|
||||||
|
email String? @db.VarChar(100)
|
||||||
|
status String @default("active") @db.VarChar(20)
|
||||||
|
remarks String? @db.Text
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime @updatedAt
|
||||||
|
|
||||||
|
borrow_records BorrowRecord[]
|
||||||
|
|
||||||
|
@@index([nickname])
|
||||||
|
@@index([real_name])
|
||||||
|
@@index([status])
|
||||||
|
@@map("person")
|
||||||
|
}
|
||||||
|
|
||||||
|
model StorageLocation {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
name String @db.VarChar(100)
|
||||||
|
code String? @db.VarChar(50)
|
||||||
|
status String @default("active") @db.VarChar(20)
|
||||||
|
description String? @db.Text
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime @updatedAt
|
||||||
|
|
||||||
|
equipments Equipment[]
|
||||||
|
|
||||||
|
@@index([status])
|
||||||
|
@@map("storage_location")
|
||||||
|
}
|
||||||
|
|
||||||
|
model OperationLog {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
user_id Int?
|
||||||
|
username String? @db.VarChar(50)
|
||||||
|
operation_type String @db.VarChar(50)
|
||||||
|
equipment_id Int?
|
||||||
|
equipment_code String? @db.VarChar(50)
|
||||||
|
operation_details String? @db.Text
|
||||||
|
ip_address String? @db.VarChar(50)
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
|
||||||
|
user User? @relation(fields: [user_id], references: [id])
|
||||||
|
|
||||||
|
@@index([user_id])
|
||||||
|
@@index([operation_type])
|
||||||
|
@@index([created_at])
|
||||||
|
@@map("operation_log")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
model MountHistory {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
child_id Int // 子资产ID
|
||||||
|
parent_id Int? // 父资产ID
|
||||||
|
operation_type String @db.VarChar(20) // mount/unmount/replace
|
||||||
|
operated_at DateTime @default(now())
|
||||||
|
operated_by Int // 操作人ID
|
||||||
|
notes String? @db.Text
|
||||||
|
old_parent_id Int? // 更换时的旧父资产ID
|
||||||
|
new_parent_id Int? // 更换时的新父资产ID
|
||||||
|
|
||||||
|
child Equipment @relation(fields: [child_id], references: [id])
|
||||||
|
operator User @relation(fields: [operated_by], references: [id])
|
||||||
|
|
||||||
|
@@index([child_id])
|
||||||
|
@@index([parent_id])
|
||||||
|
@@index([operation_type])
|
||||||
|
@@index([operated_at])
|
||||||
|
@@map("mount_history")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 报废记录
|
||||||
|
model DisposalRecord {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
equipment_id Int
|
||||||
|
equipment_code String @db.VarChar(50)
|
||||||
|
disposal_reason String @db.Text // 报废原因
|
||||||
|
disposal_method String @db.VarChar(50) // 处置方式:报废/转让/捐赠
|
||||||
|
estimated_value Decimal @db.Decimal(12,2) // 残值估价
|
||||||
|
actual_value Decimal? @db.Decimal(12,2) // 实际处置价值
|
||||||
|
status String @default("pending") @db.VarChar(20) // pending/approved/rejected/completed
|
||||||
|
applied_by Int // 申请人
|
||||||
|
applied_at DateTime @default(now())
|
||||||
|
approved_by Int? // 审批人
|
||||||
|
approved_at DateTime?
|
||||||
|
completed_at DateTime? // 完成时间
|
||||||
|
notes String? @db.Text
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime @updatedAt
|
||||||
|
|
||||||
|
equipment Equipment @relation(fields: [equipment_id], references: [id])
|
||||||
|
applier User @relation("DisposalApplier", fields: [applied_by], references: [id])
|
||||||
|
approver User? @relation("DisposalApprover", fields: [approved_by], references: [id])
|
||||||
|
|
||||||
|
@@index([equipment_id])
|
||||||
|
@@index([status])
|
||||||
|
@@map("disposal_record")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 维修记录
|
||||||
|
model MaintenanceRecord {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
equipment_id Int
|
||||||
|
equipment_code String @db.VarChar(50)
|
||||||
|
issue_description String @db.Text // 故障描述
|
||||||
|
repair_vendor String? @db.VarChar(100) // 维修商
|
||||||
|
estimated_cost Decimal @db.Decimal(10,2) // 预估费用
|
||||||
|
actual_cost Decimal? @db.Decimal(10,2) // 实际费用
|
||||||
|
start_date DateTime @default(now()) // 送修日期
|
||||||
|
expected_end_date DateTime? // 预计完成日期
|
||||||
|
actual_end_date DateTime? // 实际完成日期
|
||||||
|
status String @default("in_progress") @db.VarChar(20) // in_progress/completed/cancelled
|
||||||
|
repair_result String? @db.Text // 维修结果
|
||||||
|
created_by Int
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime @updatedAt
|
||||||
|
|
||||||
|
equipment Equipment @relation(fields: [equipment_id], references: [id])
|
||||||
|
creator User @relation(fields: [created_by], references: [id])
|
||||||
|
|
||||||
|
@@index([equipment_id])
|
||||||
|
@@index([status])
|
||||||
|
@@map("maintenance_record")
|
||||||
|
}
|
||||||
180
backend/src/controllers/assetTypeController.ts
Normal file
180
backend/src/controllers/assetTypeController.ts
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
import { Request, Response } from 'express'
|
||||||
|
import prisma from '../utils/prisma'
|
||||||
|
import { successResponse, errorResponse } from '../utils/response'
|
||||||
|
|
||||||
|
const baseSelect = {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
status: true,
|
||||||
|
can_be_parent: true,
|
||||||
|
can_be_child: true,
|
||||||
|
created_at: true,
|
||||||
|
updated_at: true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取启用的资产类型列表(下拉选项)
|
||||||
|
export const getAssetTypeOptions = async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const types = await prisma.assetType.findMany({
|
||||||
|
where: { status: 'active' },
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
select: baseSelect
|
||||||
|
})
|
||||||
|
|
||||||
|
successResponse(res, types, '获取成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '获取资产类型列表失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取资产类型列表(分页)
|
||||||
|
export const getAssetTypeList = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { page = 1, pageSize = 10, keyword, status } = req.query
|
||||||
|
|
||||||
|
const where: any = {}
|
||||||
|
if (status) {
|
||||||
|
where.status = status
|
||||||
|
}
|
||||||
|
if (keyword) {
|
||||||
|
where.name = { contains: keyword as string }
|
||||||
|
}
|
||||||
|
|
||||||
|
const skip = (Number(page) - 1) * Number(pageSize)
|
||||||
|
|
||||||
|
const [list, total] = await Promise.all([
|
||||||
|
prisma.assetType.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: Number(pageSize),
|
||||||
|
orderBy: { updated_at: 'desc' },
|
||||||
|
select: {
|
||||||
|
...baseSelect,
|
||||||
|
_count: {
|
||||||
|
select: { equipments: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
prisma.assetType.count({ where })
|
||||||
|
])
|
||||||
|
|
||||||
|
successResponse(
|
||||||
|
res,
|
||||||
|
{
|
||||||
|
list,
|
||||||
|
total,
|
||||||
|
page: Number(page),
|
||||||
|
pageSize: Number(pageSize)
|
||||||
|
},
|
||||||
|
'获取成功'
|
||||||
|
)
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '获取资产类型列表失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建资产类型
|
||||||
|
export const createAssetType = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { name, can_be_parent, can_be_child } = req.body as {
|
||||||
|
name?: string
|
||||||
|
can_be_parent?: boolean
|
||||||
|
can_be_child?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!name || !name.trim()) {
|
||||||
|
return errorResponse(res, '类型名称不能为空', 422)
|
||||||
|
}
|
||||||
|
|
||||||
|
const assetType = await prisma.assetType.create({
|
||||||
|
data: {
|
||||||
|
name: name.trim(),
|
||||||
|
can_be_parent: can_be_parent ?? false,
|
||||||
|
can_be_child: can_be_child ?? false
|
||||||
|
},
|
||||||
|
select: baseSelect
|
||||||
|
})
|
||||||
|
|
||||||
|
successResponse(res, assetType, '新增资产类型成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.code === 'P2002') {
|
||||||
|
return errorResponse(res, '类型名称已存在')
|
||||||
|
}
|
||||||
|
errorResponse(res, error.message || '新增资产类型失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新资产类型
|
||||||
|
export const updateAssetType = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
const { name, status, can_be_parent, can_be_child } = req.body as {
|
||||||
|
name?: string
|
||||||
|
status?: string
|
||||||
|
can_be_parent?: boolean
|
||||||
|
can_be_child?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.assetType.findUnique({
|
||||||
|
where: { id: Number(id) }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return errorResponse(res, '资产类型不存在', 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name !== undefined && !name.trim()) {
|
||||||
|
return errorResponse(res, '类型名称不能为空', 422)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.assetType.update({
|
||||||
|
where: { id: Number(id) },
|
||||||
|
data: {
|
||||||
|
name: name?.trim() ?? existing.name,
|
||||||
|
status: status ?? existing.status,
|
||||||
|
can_be_parent: can_be_parent ?? existing.can_be_parent,
|
||||||
|
can_be_child: can_be_child ?? existing.can_be_child
|
||||||
|
},
|
||||||
|
select: baseSelect
|
||||||
|
})
|
||||||
|
|
||||||
|
successResponse(res, updated, '更新资产类型成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.code === 'P2002') {
|
||||||
|
return errorResponse(res, '类型名称已存在')
|
||||||
|
}
|
||||||
|
errorResponse(res, error.message || '更新资产类型失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除资产类型
|
||||||
|
export const deleteAssetType = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
|
||||||
|
const existing = await prisma.assetType.findUnique({
|
||||||
|
where: { id: Number(id) },
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: { equipments: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return errorResponse(res, '资产类型不存在', 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing._count.equipments > 0) {
|
||||||
|
return errorResponse(res, '该类型已被资产使用,无法删除')
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.assetType.delete({
|
||||||
|
where: { id: Number(id) }
|
||||||
|
})
|
||||||
|
|
||||||
|
successResponse(res, null, '删除资产类型成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '删除资产类型失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
103
backend/src/controllers/barcodeController.ts
Normal file
103
backend/src/controllers/barcodeController.ts
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import { Request, Response } from 'express'
|
||||||
|
import prisma from '../utils/prisma'
|
||||||
|
import { successResponse, errorResponse } from '../utils/response'
|
||||||
|
|
||||||
|
// 获取资产信息用于生成条码(条码在前端生成)
|
||||||
|
export const getEquipmentForBarcode = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { equipment_code } = req.params
|
||||||
|
|
||||||
|
const equipment = await prisma.equipment.findUnique({
|
||||||
|
where: { equipment_code },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
equipment_code: true,
|
||||||
|
equipment_name: true,
|
||||||
|
equipment_model: true,
|
||||||
|
equipment_type: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!equipment) {
|
||||||
|
return errorResponse(res, '资产不存在', 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(res, {
|
||||||
|
equipment_code: equipment.equipment_code,
|
||||||
|
equipment_name: equipment.equipment_name,
|
||||||
|
equipment_model: equipment.equipment_model,
|
||||||
|
equipment_type: equipment.equipment_type
|
||||||
|
}, '获取成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '获取资产信息失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量获取资产信息用于生成条码
|
||||||
|
export const getEquipmentBatchForBarcode = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { equipment_ids } = req.body
|
||||||
|
|
||||||
|
// 使用 select 明确指定所有需要的字段,包括关联数据
|
||||||
|
const equipmentList = await prisma.equipment.findMany({
|
||||||
|
where: {
|
||||||
|
id: {
|
||||||
|
in: equipment_ids.map((id: number) => Number(id))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
equipment_code: true,
|
||||||
|
equipment_name: true,
|
||||||
|
equipment_model: true,
|
||||||
|
equipment_type: true,
|
||||||
|
storage_location_id: true,
|
||||||
|
storageLocation: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
code: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 调试:打印原始查询结果
|
||||||
|
console.log('条码打印 - 原始查询结果:', JSON.stringify(equipmentList, null, 2))
|
||||||
|
equipmentList.forEach((eq, index) => {
|
||||||
|
console.log(`原始资产 ${index + 1} (ID: ${eq.id}):`, {
|
||||||
|
name: eq.equipment_name,
|
||||||
|
storage_location_id: eq.storage_location_id,
|
||||||
|
storageLocation: eq.storageLocation,
|
||||||
|
allKeys: Object.keys(eq)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 直接返回查询结果,不需要再次映射
|
||||||
|
const result = equipmentList.map(eq => ({
|
||||||
|
id: eq.id,
|
||||||
|
equipment_code: eq.equipment_code,
|
||||||
|
equipment_name: eq.equipment_name,
|
||||||
|
equipment_model: eq.equipment_model,
|
||||||
|
equipment_type: eq.equipment_type,
|
||||||
|
storage_location_id: eq.storage_location_id,
|
||||||
|
storageLocation: eq.storageLocation
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 调试:打印返回的数据
|
||||||
|
console.log('条码打印 - 最终返回结果:', JSON.stringify(result, null, 2))
|
||||||
|
result.forEach((eq, index) => {
|
||||||
|
console.log(`资产 ${index + 1} (ID: ${eq.id}):`, {
|
||||||
|
name: eq.equipment_name,
|
||||||
|
storage_location_id: eq.storage_location_id,
|
||||||
|
storageLocation: eq.storageLocation
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
successResponse(res, result, '获取成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('批量获取资产信息失败:', error)
|
||||||
|
errorResponse(res, error.message || '批量获取资产信息失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
238
backend/src/controllers/batchController.ts
Normal file
238
backend/src/controllers/batchController.ts
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
import { Request, Response } from 'express'
|
||||||
|
import prisma from '../utils/prisma'
|
||||||
|
import { successResponse, errorResponse } from '../utils/response'
|
||||||
|
import { logOperation } from './operationLogController'
|
||||||
|
|
||||||
|
// 批量借出
|
||||||
|
export const batchBorrow = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { equipment_ids, person_id, expected_return_time, is_long_term, notes } = req.body
|
||||||
|
|
||||||
|
if (!Array.isArray(equipment_ids) || equipment_ids.length === 0) {
|
||||||
|
return errorResponse(res, '请选择要借出的资产')
|
||||||
|
}
|
||||||
|
|
||||||
|
const person = await prisma.person.findUnique({ where: { id: Number(person_id) } })
|
||||||
|
if (!person || person.status !== 'active') {
|
||||||
|
return errorResponse(res, '使用者不存在或已禁用')
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: any[] = []
|
||||||
|
let successCount = 0
|
||||||
|
let failedCount = 0
|
||||||
|
|
||||||
|
for (const equipmentId of equipment_ids) {
|
||||||
|
try {
|
||||||
|
const equipment = await prisma.equipment.findUnique({ where: { id: Number(equipmentId) } })
|
||||||
|
|
||||||
|
if (!equipment) {
|
||||||
|
results.push({ equipment_id: equipmentId, status: 'failed', message: '资产不存在' })
|
||||||
|
failedCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (equipment.status !== '可用' || equipment.remaining_quantity <= 0) {
|
||||||
|
results.push({ equipment_id: equipmentId, equipment_code: equipment.equipment_code, status: 'failed', message: '资产不可借用' })
|
||||||
|
failedCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const longTerm = Boolean(is_long_term)
|
||||||
|
await prisma.borrowRecord.create({
|
||||||
|
data: {
|
||||||
|
equipment_id: equipment.id,
|
||||||
|
equipment_code: equipment.equipment_code,
|
||||||
|
person_id: person.id,
|
||||||
|
person_name: person.real_name,
|
||||||
|
person_nickname: person.nickname,
|
||||||
|
person_identifier: person.id_number,
|
||||||
|
expected_return_time: longTerm ? null : (expected_return_time ? new Date(expected_return_time) : null),
|
||||||
|
is_long_term: longTerm,
|
||||||
|
notes: longTerm ? `${notes || ''};在职长期` : notes,
|
||||||
|
status: '已借出'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const newRemaining = equipment.remaining_quantity - 1
|
||||||
|
await prisma.equipment.update({
|
||||||
|
where: { id: equipment.id },
|
||||||
|
data: {
|
||||||
|
remaining_quantity: newRemaining,
|
||||||
|
status: newRemaining === 0 ? '已借出' : '可用',
|
||||||
|
borrowed_value: Number(equipment.unit_price) * (equipment.total_quantity - newRemaining),
|
||||||
|
available_value: Number(equipment.unit_price) * newRemaining
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
results.push({ equipment_id: equipmentId, equipment_code: equipment.equipment_code, status: 'success', message: '借出成功' })
|
||||||
|
successCount++
|
||||||
|
} catch (error: any) {
|
||||||
|
results.push({ equipment_id: equipmentId, status: 'failed', message: error.message })
|
||||||
|
failedCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录操作日志
|
||||||
|
const userId = res.locals.user?.id
|
||||||
|
const username = res.locals.user?.username || res.locals.user?.name || 'unknown'
|
||||||
|
const successCodes = results.filter(r => r.status === 'success').map(r => r.equipment_code).join(', ')
|
||||||
|
if (successCount > 0) {
|
||||||
|
await logOperation(
|
||||||
|
userId,
|
||||||
|
username,
|
||||||
|
'批量借出',
|
||||||
|
`批量借出 ${successCount} 件资产给 ${person.real_name}(${person.nickname}),资产编码:${successCodes}`,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
req.ip
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(res, { success_count: successCount, failed_count: failedCount, results }, '批量借出完成')
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '批量借出失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量归还
|
||||||
|
export const batchReturn = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { equipment_ids, return_condition = '正常', notes } = req.body
|
||||||
|
|
||||||
|
if (!Array.isArray(equipment_ids) || equipment_ids.length === 0) {
|
||||||
|
return errorResponse(res, '请选择要归还的资产')
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: any[] = []
|
||||||
|
let successCount = 0
|
||||||
|
let failedCount = 0
|
||||||
|
|
||||||
|
for (const equipmentId of equipment_ids) {
|
||||||
|
try {
|
||||||
|
const equipment = await prisma.equipment.findUnique({ where: { id: Number(equipmentId) } })
|
||||||
|
|
||||||
|
if (!equipment) {
|
||||||
|
results.push({ equipment_id: equipmentId, status: 'failed', message: '资产不存在' })
|
||||||
|
failedCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const borrowRecord = await prisma.borrowRecord.findFirst({
|
||||||
|
where: { equipment_id: equipment.id, status: '已借出' },
|
||||||
|
orderBy: { borrow_time: 'desc' }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!borrowRecord) {
|
||||||
|
results.push({ equipment_id: equipmentId, equipment_code: equipment.equipment_code, status: 'failed', message: '未找到借用记录' })
|
||||||
|
failedCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.borrowRecord.update({
|
||||||
|
where: { id: borrowRecord.id },
|
||||||
|
data: { return_time: new Date(), status: '已归还', return_condition, notes }
|
||||||
|
})
|
||||||
|
|
||||||
|
const newRemaining = equipment.remaining_quantity + 1
|
||||||
|
await prisma.equipment.update({
|
||||||
|
where: { id: equipment.id },
|
||||||
|
data: {
|
||||||
|
remaining_quantity: newRemaining,
|
||||||
|
status: '可用',
|
||||||
|
borrowed_value: Number(equipment.unit_price) * (equipment.total_quantity - newRemaining),
|
||||||
|
available_value: Number(equipment.unit_price) * newRemaining
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
results.push({ equipment_id: equipmentId, equipment_code: equipment.equipment_code, status: 'success', message: '归还成功' })
|
||||||
|
successCount++
|
||||||
|
} catch (error: any) {
|
||||||
|
results.push({ equipment_id: equipmentId, status: 'failed', message: error.message })
|
||||||
|
failedCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录操作日志
|
||||||
|
const userId = res.locals.user?.id
|
||||||
|
const username = res.locals.user?.username || res.locals.user?.name || 'unknown'
|
||||||
|
const successCodes = results.filter(r => r.status === 'success').map(r => r.equipment_code).join(', ')
|
||||||
|
if (successCount > 0) {
|
||||||
|
await logOperation(
|
||||||
|
userId,
|
||||||
|
username,
|
||||||
|
'批量归还',
|
||||||
|
`批量归还 ${successCount} 件资产,归还状态:${return_condition},资产编码:${successCodes}`,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
req.ip
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(res, { success_count: successCount, failed_count: failedCount, results }, '批量归还完成')
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '批量归还失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量转移部门
|
||||||
|
export const batchTransfer = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { equipment_ids, department_id } = req.body
|
||||||
|
|
||||||
|
if (!Array.isArray(equipment_ids) || equipment_ids.length === 0) {
|
||||||
|
return errorResponse(res, '请选择要转移的资产')
|
||||||
|
}
|
||||||
|
|
||||||
|
const department = await prisma.department.findUnique({ where: { id: Number(department_id) } })
|
||||||
|
if (!department) {
|
||||||
|
return errorResponse(res, '目标部门不存在')
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: any[] = []
|
||||||
|
let successCount = 0
|
||||||
|
let failedCount = 0
|
||||||
|
|
||||||
|
for (const equipmentId of equipment_ids) {
|
||||||
|
try {
|
||||||
|
const equipment = await prisma.equipment.findUnique({ where: { id: Number(equipmentId) } })
|
||||||
|
|
||||||
|
if (!equipment) {
|
||||||
|
results.push({ equipment_id: equipmentId, status: 'failed', message: '资产不存在' })
|
||||||
|
failedCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.equipment.update({
|
||||||
|
where: { id: equipment.id },
|
||||||
|
data: { department_id: department.id }
|
||||||
|
})
|
||||||
|
|
||||||
|
results.push({ equipment_id: equipmentId, equipment_code: equipment.equipment_code, status: 'success', message: '转移成功' })
|
||||||
|
successCount++
|
||||||
|
} catch (error: any) {
|
||||||
|
results.push({ equipment_id: equipmentId, status: 'failed', message: error.message })
|
||||||
|
failedCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录操作日志
|
||||||
|
const userId = res.locals.user?.id
|
||||||
|
const username = res.locals.user?.username || res.locals.user?.name || 'unknown'
|
||||||
|
const successCodes = results.filter(r => r.status === 'success').map(r => r.equipment_code).join(', ')
|
||||||
|
if (successCount > 0) {
|
||||||
|
await logOperation(
|
||||||
|
userId,
|
||||||
|
username,
|
||||||
|
'批量转移',
|
||||||
|
`批量转移 ${successCount} 件资产到部门 ${department.name},资产编码:${successCodes}`,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
req.ip
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(res, { success_count: successCount, failed_count: failedCount, results }, '批量转移完成')
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '批量转移失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
219
backend/src/controllers/batchImportController.ts
Normal file
219
backend/src/controllers/batchImportController.ts
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
import { Request, Response } from 'express'
|
||||||
|
import prisma from '../utils/prisma'
|
||||||
|
import { successResponse, errorResponse } from '../utils/response'
|
||||||
|
import { generateEquipmentCode } from '../utils/equipmentCode'
|
||||||
|
|
||||||
|
interface PersonImportData {
|
||||||
|
nickname: string
|
||||||
|
real_name: string
|
||||||
|
id_number?: string
|
||||||
|
department?: string
|
||||||
|
position?: string
|
||||||
|
phone?: string
|
||||||
|
email?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EquipmentImportData {
|
||||||
|
equipment_name: string
|
||||||
|
equipment_type: string
|
||||||
|
equipment_model?: string
|
||||||
|
department?: string
|
||||||
|
storage_location?: string
|
||||||
|
unit_price?: number
|
||||||
|
total_quantity: number
|
||||||
|
supplier?: string
|
||||||
|
purchase_date?: string
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量导入使用者
|
||||||
|
export const batchImportPersons = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { persons } = req.body as { persons: PersonImportData[] }
|
||||||
|
|
||||||
|
if (!persons || !Array.isArray(persons) || persons.length === 0) {
|
||||||
|
return errorResponse(res, '导入数据不能为空')
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = { success: 0, failed: 0, errors: [] as { row: number; message: string }[] }
|
||||||
|
|
||||||
|
for (let i = 0; i < persons.length; i++) {
|
||||||
|
const person = persons[i]
|
||||||
|
const rowNum = i + 2 // Excel行号从2开始(第1行是表头)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 验证必填字段
|
||||||
|
if (!person.nickname || !person.real_name) {
|
||||||
|
results.errors.push({ row: rowNum, message: '昵称和真实姓名为必填项' })
|
||||||
|
results.failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查工号是否重复
|
||||||
|
if (person.id_number) {
|
||||||
|
const existing = await prisma.person.findUnique({
|
||||||
|
where: { id_number: person.id_number }
|
||||||
|
})
|
||||||
|
if (existing) {
|
||||||
|
results.errors.push({ row: rowNum, message: `工号 ${person.id_number} 已存在` })
|
||||||
|
results.failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.person.create({
|
||||||
|
data: {
|
||||||
|
nickname: person.nickname,
|
||||||
|
real_name: person.real_name,
|
||||||
|
id_number: person.id_number || null,
|
||||||
|
department: person.department || null,
|
||||||
|
position: person.position || null,
|
||||||
|
phone: person.phone || null,
|
||||||
|
email: person.email || null,
|
||||||
|
status: 'active'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
results.success++
|
||||||
|
} catch (error: any) {
|
||||||
|
results.errors.push({ row: rowNum, message: error.message || '创建失败' })
|
||||||
|
results.failed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录操作日志
|
||||||
|
const user = (req as any).user
|
||||||
|
await prisma.operationLog.create({
|
||||||
|
data: {
|
||||||
|
user_id: user?.id || null,
|
||||||
|
username: user?.username || 'system',
|
||||||
|
operation_type: '批量导入使用者',
|
||||||
|
operation_details: `成功: ${results.success}, 失败: ${results.failed}`,
|
||||||
|
ip_address: req.ip || req.socket.remoteAddress
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
successResponse(res, results, '导入完成')
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '批量导入失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 批量导入资产
|
||||||
|
export const batchImportEquipments = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { equipments } = req.body as { equipments: EquipmentImportData[] }
|
||||||
|
|
||||||
|
if (!equipments || !Array.isArray(equipments) || equipments.length === 0) {
|
||||||
|
return errorResponse(res, '导入数据不能为空')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预加载资产类型、部门、地址映射
|
||||||
|
const [assetTypes, departments, locations] = await Promise.all([
|
||||||
|
prisma.assetType.findMany({ where: { status: 'active' } }),
|
||||||
|
prisma.department.findMany({ where: { status: 'active' } }),
|
||||||
|
prisma.storageLocation.findMany({ where: { status: 'active' } })
|
||||||
|
])
|
||||||
|
|
||||||
|
const assetTypeMap = new Map(assetTypes.map(t => [t.name, t.id]))
|
||||||
|
const departmentMap = new Map(departments.map(d => [d.name, d.id]))
|
||||||
|
const locationMap = new Map(locations.map(l => [l.name, l.id]))
|
||||||
|
|
||||||
|
const results = { success: 0, failed: 0, errors: [] as { row: number; message: string }[] }
|
||||||
|
|
||||||
|
for (let i = 0; i < equipments.length; i++) {
|
||||||
|
const eq = equipments[i]
|
||||||
|
const rowNum = i + 2
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 验证必填字段
|
||||||
|
if (!eq.equipment_name || !eq.equipment_type || !eq.total_quantity) {
|
||||||
|
results.errors.push({ row: rowNum, message: '资产名称、资产类型、数量为必填项' })
|
||||||
|
results.failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找资产类型
|
||||||
|
const assetTypeId = assetTypeMap.get(eq.equipment_type)
|
||||||
|
if (!assetTypeId) {
|
||||||
|
results.errors.push({ row: rowNum, message: `资产类型 "${eq.equipment_type}" 不存在` })
|
||||||
|
results.failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找部门(可选)
|
||||||
|
let departmentId: number | null = null
|
||||||
|
if (eq.department) {
|
||||||
|
departmentId = departmentMap.get(eq.department) || null
|
||||||
|
if (!departmentId) {
|
||||||
|
results.errors.push({ row: rowNum, message: `部门 "${eq.department}" 不存在` })
|
||||||
|
results.failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找地址(可选)
|
||||||
|
let locationId: number | null = null
|
||||||
|
if (eq.storage_location) {
|
||||||
|
locationId = locationMap.get(eq.storage_location) || null
|
||||||
|
if (!locationId) {
|
||||||
|
results.errors.push({ row: rowNum, message: `地址 "${eq.storage_location}" 不存在` })
|
||||||
|
results.failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成资产编码
|
||||||
|
const equipmentCode = await generateEquipmentCode(eq.equipment_type, prisma, locationId)
|
||||||
|
|
||||||
|
// 计算价值
|
||||||
|
const unitPrice = Number(eq.unit_price) || 0
|
||||||
|
const totalQuantity = Number(eq.total_quantity) || 1
|
||||||
|
const totalValue = unitPrice * totalQuantity
|
||||||
|
|
||||||
|
await prisma.equipment.create({
|
||||||
|
data: {
|
||||||
|
equipment_code: equipmentCode,
|
||||||
|
equipment_name: eq.equipment_name,
|
||||||
|
equipment_model: eq.equipment_model || null,
|
||||||
|
equipment_type: eq.equipment_type,
|
||||||
|
code_generation_mode: 'auto',
|
||||||
|
unit_price: unitPrice,
|
||||||
|
total_quantity: totalQuantity,
|
||||||
|
remaining_quantity: totalQuantity,
|
||||||
|
total_value: totalValue,
|
||||||
|
borrowed_value: 0,
|
||||||
|
available_value: totalValue,
|
||||||
|
supplier: eq.supplier || null,
|
||||||
|
purchase_date: eq.purchase_date ? new Date(eq.purchase_date) : null,
|
||||||
|
description: eq.description || null,
|
||||||
|
status: '可用',
|
||||||
|
storage_location_id: locationId,
|
||||||
|
department_id: departmentId,
|
||||||
|
asset_type_id: assetTypeId
|
||||||
|
}
|
||||||
|
})
|
||||||
|
results.success++
|
||||||
|
} catch (error: any) {
|
||||||
|
results.errors.push({ row: rowNum, message: error.message || '创建失败' })
|
||||||
|
results.failed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录操作日志
|
||||||
|
const user = (req as any).user
|
||||||
|
await prisma.operationLog.create({
|
||||||
|
data: {
|
||||||
|
user_id: user?.id || null,
|
||||||
|
username: user?.username || 'system',
|
||||||
|
operation_type: '批量导入资产',
|
||||||
|
operation_details: `成功: ${results.success}, 失败: ${results.failed}`,
|
||||||
|
ip_address: req.ip || req.socket.remoteAddress
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
successResponse(res, results, '导入完成')
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '批量导入失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
830
backend/src/controllers/borrowController.ts
Normal file
830
backend/src/controllers/borrowController.ts
Normal file
@ -0,0 +1,830 @@
|
|||||||
|
import { Request, Response } from 'express'
|
||||||
|
import prisma from '../utils/prisma'
|
||||||
|
import { successResponse, errorResponse } from '../utils/response'
|
||||||
|
|
||||||
|
const parseNumber = (value: any): number | undefined => {
|
||||||
|
const num = Number(value)
|
||||||
|
return Number.isNaN(num) ? undefined : num
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseDate = (value?: any): Date | null => {
|
||||||
|
if (!value) return null
|
||||||
|
const date = new Date(value)
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date
|
||||||
|
}
|
||||||
|
|
||||||
|
const equipmentInclude = {
|
||||||
|
storageLocation: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
code: true,
|
||||||
|
status: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getEquipmentByIdentifier = async (equipmentId?: number, equipmentCode?: string) => {
|
||||||
|
if (equipmentId) {
|
||||||
|
return prisma.equipment.findUnique({ where: { id: equipmentId }, include: equipmentInclude })
|
||||||
|
}
|
||||||
|
if (equipmentCode) {
|
||||||
|
return prisma.equipment.findUnique({ where: { equipment_code: equipmentCode }, include: equipmentInclude })
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensurePersonAvailable = async (personId?: number) => {
|
||||||
|
if (!personId) {
|
||||||
|
throw new Error('使用者ID不能为空')
|
||||||
|
}
|
||||||
|
const person = await prisma.person.findUnique({ where: { id: personId } })
|
||||||
|
if (!person || person.status !== 'active') {
|
||||||
|
throw new Error('使用者不存在或已禁用')
|
||||||
|
}
|
||||||
|
return person
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureEquipmentBorrowable = (equipment: any) => {
|
||||||
|
// 检查报废状态
|
||||||
|
if (equipment.status === '待报废' || equipment.status === '已报废') {
|
||||||
|
throw new Error('资产已报废或待报废,不可借用')
|
||||||
|
}
|
||||||
|
// 检查维护状态
|
||||||
|
if (equipment.status === '维护中') {
|
||||||
|
throw new Error('资产正在维护中,不可借用')
|
||||||
|
}
|
||||||
|
if (equipment.status !== '可用') {
|
||||||
|
throw new Error('资产状态不可借用')
|
||||||
|
}
|
||||||
|
if (equipment.remaining_quantity <= 0) {
|
||||||
|
throw new Error('资产库存不足')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateEquipmentAfterBorrow = async (equipment: any) => {
|
||||||
|
const newRemainingQuantity = equipment.remaining_quantity - 1
|
||||||
|
if (newRemainingQuantity < 0) {
|
||||||
|
throw new Error('资产库存不足')
|
||||||
|
}
|
||||||
|
const borrowedQuantity = equipment.total_quantity - newRemainingQuantity
|
||||||
|
await prisma.equipment.update({
|
||||||
|
where: { id: equipment.id },
|
||||||
|
data: {
|
||||||
|
remaining_quantity: newRemainingQuantity,
|
||||||
|
status: newRemainingQuantity === 0 ? '已借出' : '可用',
|
||||||
|
borrowed_value: Number(equipment.unit_price) * borrowedQuantity,
|
||||||
|
available_value: Number(equipment.unit_price) * newRemainingQuantity
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateEquipmentAfterReturn = async (equipment: any) => {
|
||||||
|
const newRemainingQuantity = equipment.remaining_quantity + 1
|
||||||
|
const borrowedQuantity = equipment.total_quantity - newRemainingQuantity
|
||||||
|
await prisma.equipment.update({
|
||||||
|
where: { id: equipment.id },
|
||||||
|
data: {
|
||||||
|
remaining_quantity: newRemainingQuantity,
|
||||||
|
status: '可用',
|
||||||
|
borrowed_value: Number(equipment.unit_price) * borrowedQuantity,
|
||||||
|
available_value: Number(equipment.unit_price) * newRemainingQuantity
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapBorrowRecord = (record: any) => ({
|
||||||
|
id: record.id,
|
||||||
|
equipment_id: record.equipment_id,
|
||||||
|
equipment_code: record.equipment_code,
|
||||||
|
equipment_name: record.equipment?.equipment_name,
|
||||||
|
storage_location_id: record.equipment?.storage_location_id ?? null,
|
||||||
|
storageLocation: record.equipment?.storageLocation ?? null,
|
||||||
|
person: record.person
|
||||||
|
? {
|
||||||
|
id: record.person.id,
|
||||||
|
nickname: record.person.nickname,
|
||||||
|
real_name: record.person.real_name,
|
||||||
|
department: record.person.department,
|
||||||
|
phone: record.person.phone
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
id: record.person_id,
|
||||||
|
nickname: record.person_nickname,
|
||||||
|
real_name: record.person_name
|
||||||
|
},
|
||||||
|
person_id: record.person_id,
|
||||||
|
person_name: record.person_name,
|
||||||
|
person_nickname: record.person_nickname,
|
||||||
|
person_identifier: record.person_identifier,
|
||||||
|
borrow_time: record.borrow_time,
|
||||||
|
expected_return_time: record.expected_return_time,
|
||||||
|
is_long_term: record.is_long_term,
|
||||||
|
return_time: record.return_time,
|
||||||
|
status: record.status,
|
||||||
|
return_condition: record.return_condition,
|
||||||
|
notes: record.notes
|
||||||
|
})
|
||||||
|
|
||||||
|
// 借用资产
|
||||||
|
export const borrowEquipment = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { equipment_id, equipment_code, person_id, expected_return_time, notes, is_long_term } =
|
||||||
|
req.body
|
||||||
|
|
||||||
|
const equipmentIdNum = parseNumber(equipment_id)
|
||||||
|
const equipment = await getEquipmentByIdentifier(equipmentIdNum, equipment_code)
|
||||||
|
|
||||||
|
if (!equipment) {
|
||||||
|
return errorResponse(res, '资产不存在', 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (equipmentIdNum && equipment_code && equipment.equipment_code !== equipment_code) {
|
||||||
|
return errorResponse(res, '资产编号与ID不匹配', 422)
|
||||||
|
}
|
||||||
|
|
||||||
|
let person
|
||||||
|
try {
|
||||||
|
person = await ensurePersonAvailable(parseNumber(person_id))
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message || '使用者验证失败', 422)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
ensureEquipmentBorrowable(equipment)
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
const longTerm = Boolean(is_long_term)
|
||||||
|
const expectedReturn = longTerm ? null : parseDate(expected_return_time)
|
||||||
|
|
||||||
|
const combinedNotes =
|
||||||
|
longTerm && notes
|
||||||
|
? `${notes};在职长期`
|
||||||
|
: longTerm
|
||||||
|
? '在职长期'
|
||||||
|
: notes || undefined
|
||||||
|
|
||||||
|
// 获取子资产快照(如果是父资产)
|
||||||
|
let childrenSnapshot: string | undefined
|
||||||
|
const children = await prisma.equipment.findMany({
|
||||||
|
where: { parent_id: equipment.id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
equipment_code: true,
|
||||||
|
equipment_name: true,
|
||||||
|
equipment_model: true,
|
||||||
|
equipment_type: true,
|
||||||
|
unit_price: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (children.length > 0) {
|
||||||
|
childrenSnapshot = JSON.stringify(children)
|
||||||
|
}
|
||||||
|
|
||||||
|
const borrowRecord = await prisma.borrowRecord.create({
|
||||||
|
data: {
|
||||||
|
equipment_id: equipment.id,
|
||||||
|
equipment_code: equipment.equipment_code,
|
||||||
|
person_id: person.id,
|
||||||
|
person_name: person.real_name,
|
||||||
|
person_nickname: person.nickname,
|
||||||
|
person_identifier: person.id_number,
|
||||||
|
expected_return_time: expectedReturn,
|
||||||
|
is_long_term: longTerm,
|
||||||
|
notes: combinedNotes,
|
||||||
|
status: '已借出',
|
||||||
|
children_snapshot: childrenSnapshot
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await updateEquipmentAfterBorrow(equipment)
|
||||||
|
|
||||||
|
successResponse(
|
||||||
|
res,
|
||||||
|
{
|
||||||
|
id: borrowRecord.id,
|
||||||
|
borrow_time: borrowRecord.borrow_time,
|
||||||
|
children_count: children.length
|
||||||
|
},
|
||||||
|
'借用成功'
|
||||||
|
)
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[借用资产失败]', {
|
||||||
|
error: error?.message || error,
|
||||||
|
stack: error?.stack,
|
||||||
|
body: req.body,
|
||||||
|
user: res.locals.user
|
||||||
|
})
|
||||||
|
errorResponse(res, error.message || '借用资产失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 扫码出库
|
||||||
|
export const scanBorrow = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { equipment_code, person_id, expected_return_time, notes, is_long_term } = req.body
|
||||||
|
|
||||||
|
const equipment = await getEquipmentByIdentifier(undefined, equipment_code)
|
||||||
|
if (!equipment) {
|
||||||
|
return errorResponse(res, '资产不存在', 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
let person
|
||||||
|
try {
|
||||||
|
person = await ensurePersonAvailable(parseNumber(person_id))
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message || '使用者验证失败', 422)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
ensureEquipmentBorrowable(equipment)
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
const longTerm = Boolean(is_long_term)
|
||||||
|
const expectedReturn = longTerm ? null : parseDate(expected_return_time)
|
||||||
|
const combinedNotes =
|
||||||
|
longTerm && notes
|
||||||
|
? `${notes};在职长期`
|
||||||
|
: longTerm
|
||||||
|
? '在职长期'
|
||||||
|
: notes || undefined
|
||||||
|
|
||||||
|
// 获取子资产快照(如果是父资产)
|
||||||
|
let childrenSnapshot: string | undefined
|
||||||
|
const children = await prisma.equipment.findMany({
|
||||||
|
where: { parent_id: equipment.id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
equipment_code: true,
|
||||||
|
equipment_name: true,
|
||||||
|
equipment_model: true,
|
||||||
|
equipment_type: true,
|
||||||
|
unit_price: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (children.length > 0) {
|
||||||
|
childrenSnapshot = JSON.stringify(children)
|
||||||
|
}
|
||||||
|
|
||||||
|
const borrowRecord = await prisma.borrowRecord.create({
|
||||||
|
data: {
|
||||||
|
equipment_id: equipment.id,
|
||||||
|
equipment_code: equipment.equipment_code,
|
||||||
|
person_id: person.id,
|
||||||
|
person_name: person.real_name,
|
||||||
|
person_nickname: person.nickname,
|
||||||
|
person_identifier: person.id_number,
|
||||||
|
expected_return_time: expectedReturn,
|
||||||
|
is_long_term: longTerm,
|
||||||
|
notes: combinedNotes,
|
||||||
|
status: '已借出',
|
||||||
|
children_snapshot: childrenSnapshot
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await updateEquipmentAfterBorrow(equipment)
|
||||||
|
const updatedEquipment = await prisma.equipment.findUnique({
|
||||||
|
where: { id: equipment.id },
|
||||||
|
include: equipmentInclude
|
||||||
|
})
|
||||||
|
|
||||||
|
successResponse(
|
||||||
|
res,
|
||||||
|
{
|
||||||
|
id: borrowRecord.id,
|
||||||
|
equipment_code: equipment.equipment_code,
|
||||||
|
equipment_name: equipment.equipment_name,
|
||||||
|
borrow_time: borrowRecord.borrow_time,
|
||||||
|
status: '已借出',
|
||||||
|
remaining_quantity: updatedEquipment?.remaining_quantity ?? 0,
|
||||||
|
equipment_status: updatedEquipment?.status ?? '已借出',
|
||||||
|
storage_location_id: equipment.storage_location_id,
|
||||||
|
storageLocation: equipment.storageLocation,
|
||||||
|
children_count: children.length
|
||||||
|
},
|
||||||
|
'出库成功'
|
||||||
|
)
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[扫码出库失败]', {
|
||||||
|
error: error?.message || error,
|
||||||
|
stack: error?.stack,
|
||||||
|
body: req.body,
|
||||||
|
user: res.locals.user
|
||||||
|
})
|
||||||
|
errorResponse(res, error.message || '出库失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量扫码出库
|
||||||
|
export const scanBorrowBatch = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { equipment_codes, person_id, expected_return_time, notes, is_long_term } = req.body as {
|
||||||
|
equipment_codes: string[]
|
||||||
|
person_id: number
|
||||||
|
expected_return_time?: string
|
||||||
|
notes?: string
|
||||||
|
is_long_term?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(equipment_codes) || equipment_codes.length === 0) {
|
||||||
|
return errorResponse(res, '资产编码列表不能为空', 422)
|
||||||
|
}
|
||||||
|
|
||||||
|
let person
|
||||||
|
try {
|
||||||
|
person = await ensurePersonAvailable(parseNumber(person_id))
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message || '使用者验证失败', 422)
|
||||||
|
}
|
||||||
|
|
||||||
|
const longTerm = Boolean(is_long_term)
|
||||||
|
const expectedReturn = longTerm ? null : parseDate(expected_return_time)
|
||||||
|
const combinedNotes =
|
||||||
|
longTerm && notes
|
||||||
|
? `${notes};在职长期`
|
||||||
|
: longTerm
|
||||||
|
? '在职长期'
|
||||||
|
: notes || undefined
|
||||||
|
const results: any[] = []
|
||||||
|
let successCount = 0
|
||||||
|
let failedCount = 0
|
||||||
|
|
||||||
|
for (const code of equipment_codes) {
|
||||||
|
try {
|
||||||
|
const equipment = await getEquipmentByIdentifier(undefined, code)
|
||||||
|
if (!equipment) {
|
||||||
|
results.push({ equipment_code: code, status: 'failed', message: '资产不存在' })
|
||||||
|
failedCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
ensureEquipmentBorrowable(equipment)
|
||||||
|
} catch (err: any) {
|
||||||
|
results.push({ equipment_code: code, status: 'failed', message: err.message })
|
||||||
|
failedCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.borrowRecord.create({
|
||||||
|
data: {
|
||||||
|
equipment_id: equipment.id,
|
||||||
|
equipment_code: equipment.equipment_code,
|
||||||
|
person_id: person.id,
|
||||||
|
person_name: person.real_name,
|
||||||
|
person_nickname: person.nickname,
|
||||||
|
person_identifier: person.id_number,
|
||||||
|
expected_return_time: expectedReturn,
|
||||||
|
is_long_term: longTerm,
|
||||||
|
notes: combinedNotes,
|
||||||
|
status: '已借出'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await updateEquipmentAfterBorrow(equipment)
|
||||||
|
|
||||||
|
results.push({ equipment_code: code, status: 'success', message: '出库成功' })
|
||||||
|
successCount++
|
||||||
|
} catch (error: any) {
|
||||||
|
results.push({
|
||||||
|
equipment_code: code,
|
||||||
|
status: 'failed',
|
||||||
|
message: error.message || '出库失败'
|
||||||
|
})
|
||||||
|
failedCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(
|
||||||
|
res,
|
||||||
|
{
|
||||||
|
success_count: successCount,
|
||||||
|
failed_count: failedCount,
|
||||||
|
results
|
||||||
|
},
|
||||||
|
'批量出库完成'
|
||||||
|
)
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[批量出库失败]', {
|
||||||
|
error: error?.message || error,
|
||||||
|
stack: error?.stack,
|
||||||
|
body: req.body,
|
||||||
|
user: res.locals.user
|
||||||
|
})
|
||||||
|
errorResponse(res, error.message || '批量出库失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 归还资产
|
||||||
|
export const returnEquipment = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
equipment_id,
|
||||||
|
equipment_code,
|
||||||
|
return_condition = '正常',
|
||||||
|
notes,
|
||||||
|
confirm_children_diff
|
||||||
|
} = req.body as Record<string, any>
|
||||||
|
|
||||||
|
const equipmentIdNum = parseNumber(equipment_id)
|
||||||
|
const equipment = await getEquipmentByIdentifier(equipmentIdNum, equipment_code)
|
||||||
|
|
||||||
|
if (!equipment) {
|
||||||
|
return errorResponse(res, '资产不存在', 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
const borrowRecord = await prisma.borrowRecord.findFirst({
|
||||||
|
where: {
|
||||||
|
equipment_id: equipment.id,
|
||||||
|
status: '已借出'
|
||||||
|
},
|
||||||
|
orderBy: { borrow_time: 'desc' }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!borrowRecord) {
|
||||||
|
return errorResponse(res, '未找到对应的借用记录')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查配件差异
|
||||||
|
let childrenDiff: any = null
|
||||||
|
if (borrowRecord.children_snapshot) {
|
||||||
|
const borrowedChildren = JSON.parse(borrowRecord.children_snapshot) as any[]
|
||||||
|
const currentChildren = await prisma.equipment.findMany({
|
||||||
|
where: { parent_id: equipment.id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
equipment_code: true,
|
||||||
|
equipment_name: true,
|
||||||
|
equipment_model: true,
|
||||||
|
equipment_type: true,
|
||||||
|
unit_price: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const borrowedIds = new Set(borrowedChildren.map(c => c.id))
|
||||||
|
const currentIds = new Set(currentChildren.map(c => c.id))
|
||||||
|
|
||||||
|
const removed = borrowedChildren.filter(c => !currentIds.has(c.id))
|
||||||
|
const added = currentChildren.filter(c => !borrowedIds.has(c.id))
|
||||||
|
|
||||||
|
if (removed.length > 0 || added.length > 0) {
|
||||||
|
childrenDiff = { removed, added }
|
||||||
|
|
||||||
|
// 如果有差异且未确认,返回差异信息要求确认
|
||||||
|
if (!confirm_children_diff) {
|
||||||
|
return successResponse(res, {
|
||||||
|
need_confirm: true,
|
||||||
|
children_diff: childrenDiff,
|
||||||
|
message: '配件与借出时不一致,请确认后继续'
|
||||||
|
}, '配件差异提醒')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.borrowRecord.update({
|
||||||
|
where: { id: borrowRecord.id },
|
||||||
|
data: {
|
||||||
|
return_time: new Date(),
|
||||||
|
status: '已归还',
|
||||||
|
return_condition,
|
||||||
|
notes: childrenDiff
|
||||||
|
? `${notes || ''};配件变更:移除${childrenDiff.removed.length}个,新增${childrenDiff.added.length}个`
|
||||||
|
: notes
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await updateEquipmentAfterReturn(equipment)
|
||||||
|
|
||||||
|
successResponse(
|
||||||
|
res,
|
||||||
|
{
|
||||||
|
id: borrowRecord.id,
|
||||||
|
return_time: new Date(),
|
||||||
|
children_diff: childrenDiff
|
||||||
|
},
|
||||||
|
'归还成功'
|
||||||
|
)
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[归还资产失败]', {
|
||||||
|
error: error?.message || error,
|
||||||
|
stack: error?.stack,
|
||||||
|
body: req.body,
|
||||||
|
user: res.locals.user
|
||||||
|
})
|
||||||
|
errorResponse(res, error.message || '归还资产失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 扫码入库
|
||||||
|
export const scanReturn = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
equipment_code,
|
||||||
|
return_condition = '正常',
|
||||||
|
notes,
|
||||||
|
person_id,
|
||||||
|
borrow_record_id
|
||||||
|
} = req.body as Record<string, any>
|
||||||
|
|
||||||
|
const equipment = await getEquipmentByIdentifier(undefined, equipment_code)
|
||||||
|
|
||||||
|
if (!equipment) {
|
||||||
|
return errorResponse(res, '资产不存在', 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
const personIdNum = parseNumber(person_id)
|
||||||
|
const borrowRecordIdNum = parseNumber(borrow_record_id)
|
||||||
|
|
||||||
|
let borrowRecord = null
|
||||||
|
|
||||||
|
if (borrowRecordIdNum) {
|
||||||
|
borrowRecord = await prisma.borrowRecord.findFirst({
|
||||||
|
where: {
|
||||||
|
id: borrowRecordIdNum,
|
||||||
|
equipment_id: equipment.id,
|
||||||
|
status: '已借出'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!borrowRecord && personIdNum) {
|
||||||
|
borrowRecord = await prisma.borrowRecord.findFirst({
|
||||||
|
where: {
|
||||||
|
equipment_id: equipment.id,
|
||||||
|
person_id: personIdNum,
|
||||||
|
status: '已借出'
|
||||||
|
},
|
||||||
|
orderBy: { borrow_time: 'desc' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!borrowRecord) {
|
||||||
|
borrowRecord = await prisma.borrowRecord.findFirst({
|
||||||
|
where: {
|
||||||
|
equipment_id: equipment.id,
|
||||||
|
status: '已借出'
|
||||||
|
},
|
||||||
|
orderBy: { borrow_time: 'desc' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!borrowRecord) {
|
||||||
|
return errorResponse(res, '未找到对应的借用记录,请选择正确的使用者', 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedRecord = await prisma.borrowRecord.update({
|
||||||
|
where: { id: borrowRecord.id },
|
||||||
|
data: {
|
||||||
|
return_time: new Date(),
|
||||||
|
status: '已归还',
|
||||||
|
return_condition,
|
||||||
|
notes
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await updateEquipmentAfterReturn(equipment)
|
||||||
|
|
||||||
|
successResponse(
|
||||||
|
res,
|
||||||
|
{
|
||||||
|
id: updatedRecord.id,
|
||||||
|
equipment_code: equipment.equipment_code,
|
||||||
|
equipment_name: equipment.equipment_name,
|
||||||
|
return_time: updatedRecord.return_time,
|
||||||
|
status: '已归还',
|
||||||
|
person_id: updatedRecord.person_id
|
||||||
|
},
|
||||||
|
'入库成功'
|
||||||
|
)
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[扫码入库失败]', {
|
||||||
|
error: error?.message || error,
|
||||||
|
stack: error?.stack,
|
||||||
|
body: req.body,
|
||||||
|
user: res.locals.user
|
||||||
|
})
|
||||||
|
errorResponse(res, error.message || '入库失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量扫码入库
|
||||||
|
export const scanReturnBatch = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
equipment_codes,
|
||||||
|
return_condition = '正常',
|
||||||
|
notes
|
||||||
|
} = req.body as { equipment_codes: string[]; return_condition?: string; notes?: string }
|
||||||
|
|
||||||
|
if (!Array.isArray(equipment_codes) || equipment_codes.length === 0) {
|
||||||
|
return errorResponse(res, '资产编码列表不能为空', 422)
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: any[] = []
|
||||||
|
let successCount = 0
|
||||||
|
let failedCount = 0
|
||||||
|
|
||||||
|
for (const code of equipment_codes) {
|
||||||
|
try {
|
||||||
|
const equipment = await getEquipmentByIdentifier(undefined, code)
|
||||||
|
|
||||||
|
if (!equipment) {
|
||||||
|
results.push({ equipment_code: code, status: 'failed', message: '资产不存在' })
|
||||||
|
failedCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const borrowRecord = await prisma.borrowRecord.findFirst({
|
||||||
|
where: {
|
||||||
|
equipment_id: equipment.id,
|
||||||
|
status: '已借出'
|
||||||
|
},
|
||||||
|
orderBy: { borrow_time: 'desc' }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!borrowRecord) {
|
||||||
|
results.push({ equipment_code: code, status: 'failed', message: '未找到对应的借用记录' })
|
||||||
|
failedCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.borrowRecord.update({
|
||||||
|
where: { id: borrowRecord.id },
|
||||||
|
data: {
|
||||||
|
return_time: new Date(),
|
||||||
|
status: '已归还',
|
||||||
|
return_condition,
|
||||||
|
notes
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await updateEquipmentAfterReturn(equipment)
|
||||||
|
|
||||||
|
results.push({ equipment_code: code, status: 'success', message: '入库成功' })
|
||||||
|
successCount++
|
||||||
|
} catch (error: any) {
|
||||||
|
results.push({
|
||||||
|
equipment_code: code,
|
||||||
|
status: 'failed',
|
||||||
|
message: error.message || '入库失败'
|
||||||
|
})
|
||||||
|
failedCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(
|
||||||
|
res,
|
||||||
|
{
|
||||||
|
success_count: successCount,
|
||||||
|
failed_count: failedCount,
|
||||||
|
results
|
||||||
|
},
|
||||||
|
'批量入库完成'
|
||||||
|
)
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[批量入库失败]', {
|
||||||
|
error: error?.message || error,
|
||||||
|
stack: error?.stack,
|
||||||
|
body: req.body,
|
||||||
|
user: res.locals.user
|
||||||
|
})
|
||||||
|
errorResponse(res, error.message || '批量入库失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据资产编码查询借用记录
|
||||||
|
export const getBorrowRecordByCode = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { equipment_code } = req.params
|
||||||
|
|
||||||
|
const equipment = await getEquipmentByIdentifier(undefined, equipment_code)
|
||||||
|
|
||||||
|
if (!equipment) {
|
||||||
|
return errorResponse(res, '资产不存在', 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
const borrowRecords = await prisma.borrowRecord.findMany({
|
||||||
|
where: {
|
||||||
|
equipment_id: equipment.id,
|
||||||
|
status: '已借出'
|
||||||
|
},
|
||||||
|
orderBy: { borrow_time: 'desc' },
|
||||||
|
include: {
|
||||||
|
person: true,
|
||||||
|
equipment: {
|
||||||
|
select: {
|
||||||
|
equipment_name: true,
|
||||||
|
storage_location_id: true,
|
||||||
|
storageLocation: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
code: true,
|
||||||
|
status: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (borrowRecords.length === 0) {
|
||||||
|
return errorResponse(res, '未找到对应的借用记录', 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(
|
||||||
|
res,
|
||||||
|
{
|
||||||
|
equipment_code: equipment.equipment_code,
|
||||||
|
equipment_name: equipment.equipment_name,
|
||||||
|
unit_price: Number(equipment.unit_price),
|
||||||
|
equipment_value: Number(equipment.unit_price),
|
||||||
|
storage_location_id: equipment.storage_location_id,
|
||||||
|
storageLocation: equipment.storageLocation,
|
||||||
|
borrow_records: borrowRecords.map(mapBorrowRecord),
|
||||||
|
can_return: true,
|
||||||
|
message: '资产已借出,可以选择对应使用者归还'
|
||||||
|
},
|
||||||
|
'查询成功'
|
||||||
|
)
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '查询借用记录失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取借用记录列表
|
||||||
|
export const getBorrowRecordList = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { page = 1, pageSize = 10, status, person_name, person_id } = req.query
|
||||||
|
|
||||||
|
const filters: any[] = []
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
filters.push({ status })
|
||||||
|
}
|
||||||
|
|
||||||
|
const personIdNum = parseNumber(person_id)
|
||||||
|
if (personIdNum) {
|
||||||
|
filters.push({ person_id: personIdNum })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (person_name) {
|
||||||
|
const kw = person_name as string
|
||||||
|
filters.push({
|
||||||
|
OR: [
|
||||||
|
{ person_name: { contains: kw } },
|
||||||
|
{ person_nickname: { contains: kw } },
|
||||||
|
{ person_identifier: { contains: kw } }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = filters.length ? { AND: filters } : undefined
|
||||||
|
const skip = (Number(page) - 1) * Number(pageSize)
|
||||||
|
|
||||||
|
const [list, total] = await Promise.all([
|
||||||
|
prisma.borrowRecord.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: Number(pageSize),
|
||||||
|
orderBy: { borrow_time: 'desc' },
|
||||||
|
include: {
|
||||||
|
person: true,
|
||||||
|
equipment: {
|
||||||
|
select: {
|
||||||
|
equipment_name: true,
|
||||||
|
storage_location_id: true,
|
||||||
|
storageLocation: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
code: true,
|
||||||
|
status: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
prisma.borrowRecord.count({ where })
|
||||||
|
])
|
||||||
|
|
||||||
|
successResponse(
|
||||||
|
res,
|
||||||
|
{
|
||||||
|
list: list.map(mapBorrowRecord),
|
||||||
|
total,
|
||||||
|
page: Number(page),
|
||||||
|
pageSize: Number(pageSize)
|
||||||
|
},
|
||||||
|
'获取成功'
|
||||||
|
)
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '获取借用记录列表失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
303
backend/src/controllers/departmentController.ts
Normal file
303
backend/src/controllers/departmentController.ts
Normal file
@ -0,0 +1,303 @@
|
|||||||
|
import { Request, Response } from 'express'
|
||||||
|
import prisma from '../utils/prisma'
|
||||||
|
import { successResponse, errorResponse } from '../utils/response'
|
||||||
|
|
||||||
|
const baseSelect = {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
status: true,
|
||||||
|
parent_id: true,
|
||||||
|
created_at: true,
|
||||||
|
updated_at: true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取启用的部门列表(下拉选项,含层级信息)
|
||||||
|
export const getDepartmentOptions = async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const departments = await prisma.department.findMany({
|
||||||
|
where: { status: 'active' },
|
||||||
|
orderBy: [{ parent_id: 'asc' }, { name: 'asc' }],
|
||||||
|
select: {
|
||||||
|
...baseSelect,
|
||||||
|
parent: {
|
||||||
|
select: { id: true, name: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 添加层级信息
|
||||||
|
const options = departments.map(dept => ({
|
||||||
|
...dept,
|
||||||
|
parent_name: dept.parent?.name || null,
|
||||||
|
level: dept.parent_id ? 1 : 0
|
||||||
|
}))
|
||||||
|
|
||||||
|
successResponse(res, options, '获取成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '获取部门列表失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取部门树形结构
|
||||||
|
export const getDepartmentTree = async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const departments = await prisma.department.findMany({
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
select: {
|
||||||
|
...baseSelect,
|
||||||
|
_count: {
|
||||||
|
select: { equipments: true, children: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 构建树形结构
|
||||||
|
const rootDepts = departments.filter(d => !d.parent_id)
|
||||||
|
const childDepts = departments.filter(d => d.parent_id)
|
||||||
|
|
||||||
|
const tree = rootDepts.map(root => ({
|
||||||
|
...root,
|
||||||
|
equipment_count: root._count.equipments,
|
||||||
|
children_count: root._count.children,
|
||||||
|
children: childDepts
|
||||||
|
.filter(child => child.parent_id === root.id)
|
||||||
|
.map(child => ({
|
||||||
|
...child,
|
||||||
|
equipment_count: child._count.equipments,
|
||||||
|
children_count: child._count.children,
|
||||||
|
children: []
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
|
||||||
|
successResponse(res, tree, '获取成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '获取部门树失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取部门列表(分页)
|
||||||
|
export const getDepartmentList = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { page = 1, pageSize = 10, keyword, status } = req.query
|
||||||
|
|
||||||
|
const where: any = {}
|
||||||
|
if (status) {
|
||||||
|
where.status = status
|
||||||
|
}
|
||||||
|
if (keyword) {
|
||||||
|
where.name = { contains: keyword as string }
|
||||||
|
}
|
||||||
|
|
||||||
|
const skip = (Number(page) - 1) * Number(pageSize)
|
||||||
|
|
||||||
|
const [list, total] = await Promise.all([
|
||||||
|
prisma.department.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: Number(pageSize),
|
||||||
|
orderBy: [{ parent_id: 'asc' }, { updated_at: 'desc' }],
|
||||||
|
select: {
|
||||||
|
...baseSelect,
|
||||||
|
parent: {
|
||||||
|
select: { id: true, name: true }
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: { equipments: true, children: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
prisma.department.count({ where })
|
||||||
|
])
|
||||||
|
|
||||||
|
const formattedList = list.map(dept => ({
|
||||||
|
...dept,
|
||||||
|
parent_name: dept.parent?.name || null,
|
||||||
|
level: dept.parent_id ? 1 : 0,
|
||||||
|
equipment_count: dept._count.equipments,
|
||||||
|
children_count: dept._count.children
|
||||||
|
}))
|
||||||
|
|
||||||
|
successResponse(
|
||||||
|
res,
|
||||||
|
{
|
||||||
|
list: formattedList,
|
||||||
|
total,
|
||||||
|
page: Number(page),
|
||||||
|
pageSize: Number(pageSize)
|
||||||
|
},
|
||||||
|
'获取成功'
|
||||||
|
)
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '获取部门列表失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证父部门有效性
|
||||||
|
const validateParentDepartment = async (parentId: number | null | undefined, currentId?: number): Promise<string | null> => {
|
||||||
|
if (!parentId) return null
|
||||||
|
|
||||||
|
const parent = await prisma.department.findUnique({
|
||||||
|
where: { id: parentId },
|
||||||
|
select: { id: true, parent_id: true, status: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!parent) {
|
||||||
|
return '指定的父部门不存在'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parent.status !== 'active') {
|
||||||
|
return '父部门已禁用,无法设置'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查父部门是否已经是子部门(限制两级)
|
||||||
|
if (parent.parent_id) {
|
||||||
|
return '不能将子部门设为父部门(最多支持两级层级)'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否设置自己为父部门
|
||||||
|
if (currentId && parentId === currentId) {
|
||||||
|
return '不能将部门设为自己的子部门'
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建部门
|
||||||
|
export const createDepartment = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { name, parent_id } = req.body as { name?: string; parent_id?: number | null }
|
||||||
|
|
||||||
|
if (!name || !name.trim()) {
|
||||||
|
return errorResponse(res, '部门名称不能为空', 422)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证父部门
|
||||||
|
const parentError = await validateParentDepartment(parent_id)
|
||||||
|
if (parentError) {
|
||||||
|
return errorResponse(res, parentError, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const department = await prisma.department.create({
|
||||||
|
data: {
|
||||||
|
name: name.trim(),
|
||||||
|
parent_id: parent_id || null
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
...baseSelect,
|
||||||
|
parent: {
|
||||||
|
select: { id: true, name: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
successResponse(res, {
|
||||||
|
...department,
|
||||||
|
parent_name: department.parent?.name || null,
|
||||||
|
level: department.parent_id ? 1 : 0
|
||||||
|
}, '新增部门成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.code === 'P2002') {
|
||||||
|
return errorResponse(res, '部门名称已存在')
|
||||||
|
}
|
||||||
|
errorResponse(res, error.message || '新增部门失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新部门
|
||||||
|
export const updateDepartment = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
const { name, status, parent_id } = req.body as { name?: string; status?: string; parent_id?: number | null }
|
||||||
|
|
||||||
|
const existing = await prisma.department.findUnique({
|
||||||
|
where: { id: Number(id) },
|
||||||
|
include: {
|
||||||
|
_count: { select: { children: true } }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return errorResponse(res, '部门不存在', 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name !== undefined && !name.trim()) {
|
||||||
|
return errorResponse(res, '部门名称不能为空', 422)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果要设置父部门,验证有效性
|
||||||
|
if (parent_id !== undefined) {
|
||||||
|
// 如果该部门有子部门,不能将其变为子部门
|
||||||
|
if (parent_id && existing._count.children > 0) {
|
||||||
|
return errorResponse(res, '该部门下存在子部门,不能将其设为子部门', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentError = await validateParentDepartment(parent_id, Number(id))
|
||||||
|
if (parentError) {
|
||||||
|
return errorResponse(res, parentError, 400)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.department.update({
|
||||||
|
where: { id: Number(id) },
|
||||||
|
data: {
|
||||||
|
name: name?.trim() || existing.name,
|
||||||
|
status: status || existing.status,
|
||||||
|
parent_id: parent_id !== undefined ? (parent_id || null) : existing.parent_id
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
...baseSelect,
|
||||||
|
parent: {
|
||||||
|
select: { id: true, name: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
successResponse(res, {
|
||||||
|
...updated,
|
||||||
|
parent_name: updated.parent?.name || null,
|
||||||
|
level: updated.parent_id ? 1 : 0
|
||||||
|
}, '更新部门成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.code === 'P2002') {
|
||||||
|
return errorResponse(res, '部门名称已存在')
|
||||||
|
}
|
||||||
|
errorResponse(res, error.message || '更新部门失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除部门
|
||||||
|
export const deleteDepartment = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
|
||||||
|
const existing = await prisma.department.findUnique({
|
||||||
|
where: { id: Number(id) },
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: { equipments: true, children: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return errorResponse(res, '部门不存在', 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否有子部门
|
||||||
|
if (existing._count.children > 0) {
|
||||||
|
return errorResponse(res, '该部门下存在子部门,请先删除或转移子部门', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing._count.equipments > 0) {
|
||||||
|
return errorResponse(res, '该部门已被资产使用,无法删除')
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.department.delete({
|
||||||
|
where: { id: Number(id) }
|
||||||
|
})
|
||||||
|
|
||||||
|
successResponse(res, null, '删除部门成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '删除部门失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
254
backend/src/controllers/disposalController.ts
Normal file
254
backend/src/controllers/disposalController.ts
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
import { Request, Response } from 'express'
|
||||||
|
import prisma from '../utils/prisma'
|
||||||
|
import { successResponse, errorResponse } from '../utils/response'
|
||||||
|
import { logOperation } from './operationLogController'
|
||||||
|
|
||||||
|
// 创建报废申请
|
||||||
|
export const createDisposal = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { equipment_id, disposal_reason, disposal_method, estimated_value, notes } = req.body
|
||||||
|
const userId = res.locals.user?.id
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return errorResponse(res, '用户未登录', 401)
|
||||||
|
}
|
||||||
|
|
||||||
|
const equipment = await prisma.equipment.findUnique({
|
||||||
|
where: { id: Number(equipment_id) }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!equipment) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await prisma.equipment.update({
|
||||||
|
where: { id: equipment.id },
|
||||||
|
data: { status: '待报废' }
|
||||||
|
})
|
||||||
|
|
||||||
|
// 记录操作日志
|
||||||
|
const username = res.locals.user?.username || res.locals.user?.name || 'unknown'
|
||||||
|
await logOperation(
|
||||||
|
userId,
|
||||||
|
username,
|
||||||
|
'报废申请',
|
||||||
|
`申请报废资产 ${equipment.equipment_name}(${equipment.equipment_code}),原因:${disposal_reason},处置方式:${disposal_method}`,
|
||||||
|
equipment.id,
|
||||||
|
equipment.equipment_code,
|
||||||
|
req.ip
|
||||||
|
)
|
||||||
|
|
||||||
|
successResponse(res, disposal, '报废申请创建成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[创建报废申请失败]', error)
|
||||||
|
errorResponse(res, error.message || '创建报废申请失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取报废记录列表
|
||||||
|
export const getDisposalList = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { page = 1, pageSize = 10, status, keyword, equipment_id } = req.query
|
||||||
|
const skip = (Number(page) - 1) * Number(pageSize)
|
||||||
|
|
||||||
|
const where: any = {}
|
||||||
|
if (status) where.status = status
|
||||||
|
if (equipment_id) where.equipment_id = Number(equipment_id)
|
||||||
|
if (keyword) {
|
||||||
|
where.OR = [
|
||||||
|
{ equipment_code: { contains: String(keyword) } },
|
||||||
|
{ disposal_reason: { contains: String(keyword) } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const [list, total] = await Promise.all([
|
||||||
|
prisma.disposalRecord.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: Number(pageSize),
|
||||||
|
orderBy: { created_at: 'desc' },
|
||||||
|
include: {
|
||||||
|
equipment: { select: { equipment_name: true, equipment_type: true, unit_price: true } },
|
||||||
|
applier: { select: { id: true, name: true, username: true } },
|
||||||
|
approver: { select: { id: true, name: true, username: true } }
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
prisma.disposalRecord.count({ where })
|
||||||
|
])
|
||||||
|
|
||||||
|
successResponse(res, { list, total, page: Number(page), pageSize: Number(pageSize) }, '获取成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '获取报废记录列表失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取报废记录详情
|
||||||
|
export const getDisposalDetail = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
const disposal = await prisma.disposalRecord.findUnique({
|
||||||
|
where: { id: Number(id) },
|
||||||
|
include: {
|
||||||
|
equipment: true,
|
||||||
|
applier: { select: { id: true, name: true, username: true } },
|
||||||
|
approver: { select: { id: true, name: true, username: true } }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!disposal) {
|
||||||
|
return errorResponse(res, '报废记录不存在', 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(res, disposal, '获取成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '获取报废记录详情失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 审批报废申请
|
||||||
|
export const approveDisposal = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
const { approved, reject_reason } = req.body
|
||||||
|
const userId = res.locals.user?.id
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return errorResponse(res, '用户未登录', 401)
|
||||||
|
}
|
||||||
|
|
||||||
|
const disposal = await prisma.disposalRecord.findUnique({
|
||||||
|
where: { id: Number(id) },
|
||||||
|
include: { equipment: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!disposal) {
|
||||||
|
return errorResponse(res, '报废记录不存在', 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (disposal.status !== 'pending') {
|
||||||
|
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({
|
||||||
|
where: { id: Number(id) },
|
||||||
|
data: { status: 'rejected', approved_by: userId, approved_at: new Date(), notes: reject_reason }
|
||||||
|
})
|
||||||
|
await prisma.equipment.update({
|
||||||
|
where: { id: disposal.equipment_id },
|
||||||
|
data: { status: '可用' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录操作日志
|
||||||
|
const username = res.locals.user?.username || res.locals.user?.name || 'unknown'
|
||||||
|
await logOperation(
|
||||||
|
userId,
|
||||||
|
username,
|
||||||
|
approved ? '报废审批通过' : '报废审批驳回',
|
||||||
|
`${approved ? '批准' : '驳回'}资产 ${disposal.equipment?.equipment_name || ''}(${disposal.equipment_code}) 的报废申请${!approved && reject_reason ? `,原因:${reject_reason}` : ''}`,
|
||||||
|
disposal.equipment_id,
|
||||||
|
disposal.equipment_code,
|
||||||
|
req.ip
|
||||||
|
)
|
||||||
|
|
||||||
|
successResponse(res, null, approved ? '审批通过' : '已驳回')
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '审批失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 完成报废处置
|
||||||
|
export const completeDisposal = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
const { actual_value, notes } = req.body
|
||||||
|
|
||||||
|
const disposal = await prisma.disposalRecord.findUnique({
|
||||||
|
where: { id: Number(id) }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!disposal) {
|
||||||
|
return errorResponse(res, '报废记录不存在', 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (disposal.status !== 'approved') {
|
||||||
|
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.equipment.update({
|
||||||
|
where: { id: disposal.equipment_id },
|
||||||
|
data: { status: '已报废' }
|
||||||
|
})
|
||||||
|
|
||||||
|
// 记录操作日志
|
||||||
|
const userId = res.locals.user?.id
|
||||||
|
const username = res.locals.user?.username || res.locals.user?.name || 'unknown'
|
||||||
|
await logOperation(
|
||||||
|
userId,
|
||||||
|
username,
|
||||||
|
'报废完成',
|
||||||
|
`完成资产 (${disposal.equipment_code}) 的报废处置,实际残值:¥${actual_value !== undefined ? actual_value : disposal.estimated_value}`,
|
||||||
|
disposal.equipment_id,
|
||||||
|
disposal.equipment_code,
|
||||||
|
req.ip
|
||||||
|
)
|
||||||
|
|
||||||
|
successResponse(res, null, '报废处置完成')
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '完成报废处置失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取资产的报废历史
|
||||||
|
export const getEquipmentDisposalHistory = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { equipment_id } = req.params
|
||||||
|
const history = await prisma.disposalRecord.findMany({
|
||||||
|
where: { equipment_id: Number(equipment_id) },
|
||||||
|
orderBy: { created_at: 'desc' },
|
||||||
|
include: {
|
||||||
|
applier: { select: { id: true, name: true } },
|
||||||
|
approver: { select: { id: true, name: true } }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
successResponse(res, history, '获取成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '获取报废历史失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
568
backend/src/controllers/equipmentController.ts
Normal file
568
backend/src/controllers/equipmentController.ts
Normal file
@ -0,0 +1,568 @@
|
|||||||
|
import { Request, Response } from 'express'
|
||||||
|
import prisma from '../utils/prisma'
|
||||||
|
import { successResponse, errorResponse } from '../utils/response'
|
||||||
|
import { generateEquipmentCode, validateEquipmentCode } from '../utils/equipmentCode'
|
||||||
|
|
||||||
|
// 计算资产价值
|
||||||
|
const calculateEquipmentValues = (unitPrice: number, totalQuantity: number, remainingQuantity: number) => {
|
||||||
|
const totalValue = Number(unitPrice) * totalQuantity
|
||||||
|
const borrowedQuantity = totalQuantity - remainingQuantity
|
||||||
|
const borrowedValue = Number(unitPrice) * borrowedQuantity
|
||||||
|
const availableValue = Number(unitPrice) * remainingQuantity
|
||||||
|
|
||||||
|
return {
|
||||||
|
total_value: totalValue,
|
||||||
|
borrowed_value: borrowedValue,
|
||||||
|
available_value: availableValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const includeStorageLocation = {
|
||||||
|
storageLocation: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
code: true,
|
||||||
|
status: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
department: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
status: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
assetType: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
status: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureLocationExists = async (locationId?: number | null) => {
|
||||||
|
if (!locationId) {
|
||||||
|
throw new Error('地址信息不能为空')
|
||||||
|
}
|
||||||
|
|
||||||
|
const location = await prisma.storageLocation.findUnique({
|
||||||
|
where: { id: locationId }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!location || location.status !== 'active') {
|
||||||
|
throw new Error('所选地址不存在或已停用')
|
||||||
|
}
|
||||||
|
|
||||||
|
return location
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取设备列表
|
||||||
|
export const getEquipmentList = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { page = 1, pageSize = 10, equipmentType, status, keyword, storage_location_id, department_id, asset_type_id, mount_status, parent_id } = req.query
|
||||||
|
|
||||||
|
const where: any = {}
|
||||||
|
|
||||||
|
if (equipmentType) {
|
||||||
|
where.equipment_type = equipmentType
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
where.status = status
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyword) {
|
||||||
|
where.OR = [
|
||||||
|
{ equipment_name: { contains: keyword as string } },
|
||||||
|
{ equipment_code: { contains: keyword as string } },
|
||||||
|
{ equipment_model: { contains: keyword as string } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const locationId = Number(storage_location_id)
|
||||||
|
if (!Number.isNaN(locationId) && storage_location_id) {
|
||||||
|
where.storage_location_id = locationId
|
||||||
|
}
|
||||||
|
|
||||||
|
const deptId = Number(department_id)
|
||||||
|
if (!Number.isNaN(deptId) && department_id) {
|
||||||
|
where.department_id = deptId
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeId = Number(asset_type_id)
|
||||||
|
if (!Number.isNaN(typeId) && asset_type_id) {
|
||||||
|
where.asset_type_id = typeId
|
||||||
|
}
|
||||||
|
|
||||||
|
// 挂载状态筛选
|
||||||
|
if (mount_status && mount_status !== 'all') {
|
||||||
|
if (mount_status === 'mounted') {
|
||||||
|
where.mount_status = 'mounted'
|
||||||
|
} else if (mount_status === 'unmounted') {
|
||||||
|
where.mount_status = { in: ['none', 'unmounted'] }
|
||||||
|
where.parent_id = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 父资产筛选(显示某主机的所有配件)
|
||||||
|
const parentIdNum = Number(parent_id)
|
||||||
|
if (!Number.isNaN(parentIdNum) && parent_id) {
|
||||||
|
where.parent_id = parentIdNum
|
||||||
|
}
|
||||||
|
|
||||||
|
const skip = (Number(page) - 1) * Number(pageSize)
|
||||||
|
|
||||||
|
const [list, total] = await Promise.all([
|
||||||
|
prisma.equipment.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: Number(pageSize),
|
||||||
|
orderBy: { created_at: 'desc' },
|
||||||
|
include: {
|
||||||
|
...includeStorageLocation,
|
||||||
|
parent: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
equipment_code: true,
|
||||||
|
equipment_name: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
children: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
equipment_code: true,
|
||||||
|
equipment_name: true,
|
||||||
|
equipment_model: true,
|
||||||
|
unit_price: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
prisma.equipment.count({ where })
|
||||||
|
])
|
||||||
|
|
||||||
|
successResponse(
|
||||||
|
res,
|
||||||
|
{
|
||||||
|
list,
|
||||||
|
total,
|
||||||
|
page: Number(page),
|
||||||
|
pageSize: Number(pageSize)
|
||||||
|
},
|
||||||
|
'获取成功'
|
||||||
|
)
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '获取资产列表失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取资产详情
|
||||||
|
export const getEquipmentDetail = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
|
||||||
|
const equipment = await prisma.equipment.findUnique({
|
||||||
|
where: { id: Number(id) },
|
||||||
|
include: {
|
||||||
|
...includeStorageLocation,
|
||||||
|
parent: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
equipment_code: true,
|
||||||
|
equipment_name: true,
|
||||||
|
equipment_model: true,
|
||||||
|
unit_price: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
children: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
equipment_code: true,
|
||||||
|
equipment_name: true,
|
||||||
|
equipment_model: true,
|
||||||
|
equipment_type: true,
|
||||||
|
unit_price: true,
|
||||||
|
status: true,
|
||||||
|
mount_status: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!equipment) {
|
||||||
|
return errorResponse(res, '资产不存在', 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(res, equipment, '获取成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '获取资产详情失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增资产
|
||||||
|
export const createEquipment = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
equipment_code,
|
||||||
|
equipment_name,
|
||||||
|
equipment_model,
|
||||||
|
equipment_type,
|
||||||
|
code_generation_mode = 'auto',
|
||||||
|
unit_price,
|
||||||
|
total_quantity,
|
||||||
|
purchase_date,
|
||||||
|
supplier,
|
||||||
|
description,
|
||||||
|
storage_location_id,
|
||||||
|
department_id,
|
||||||
|
asset_type_id
|
||||||
|
} = req.body
|
||||||
|
|
||||||
|
// 生成或验证设备编码
|
||||||
|
let finalCode = equipment_code
|
||||||
|
|
||||||
|
const locationId = storage_location_id ? Number(storage_location_id) : null
|
||||||
|
const deptId = department_id ? Number(department_id) : null
|
||||||
|
const typeId = asset_type_id ? Number(asset_type_id) : null
|
||||||
|
|
||||||
|
if (code_generation_mode === 'auto') {
|
||||||
|
// 自动生成编码,确保唯一性(generateEquipmentCode内部已处理唯一性检查)
|
||||||
|
finalCode = await generateEquipmentCode(equipment_type, prisma, locationId)
|
||||||
|
|
||||||
|
// 再次检查编码唯一性(双重保险)
|
||||||
|
const existingCheck = await prisma.equipment.findUnique({
|
||||||
|
where: { equipment_code: finalCode }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existingCheck) {
|
||||||
|
// 如果编码已存在,重新生成(这种情况理论上不应该发生,但作为安全措施)
|
||||||
|
finalCode = await generateEquipmentCode(equipment_type, prisma, locationId)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!equipment_code) {
|
||||||
|
return errorResponse(res, '自定义录入模式下资产编码不能为空')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validateEquipmentCode(equipment_code)) {
|
||||||
|
return errorResponse(res, '资产编码格式不正确')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查编码是否已存在
|
||||||
|
const existing = await prisma.equipment.findUnique({
|
||||||
|
where: { equipment_code }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return errorResponse(res, '资产编码已存在')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureLocationExists(locationId)
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message || '地址无效', 422)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算价值
|
||||||
|
const remaining_quantity = total_quantity || 1
|
||||||
|
const values = calculateEquipmentValues(
|
||||||
|
Number(unit_price) || 0,
|
||||||
|
Number(total_quantity) || 1,
|
||||||
|
remaining_quantity
|
||||||
|
)
|
||||||
|
|
||||||
|
const created = await prisma.equipment.create({
|
||||||
|
data: {
|
||||||
|
equipment_code: finalCode,
|
||||||
|
equipment_name,
|
||||||
|
equipment_model,
|
||||||
|
equipment_type,
|
||||||
|
code_generation_mode,
|
||||||
|
unit_price: Number(unit_price) || 0,
|
||||||
|
total_quantity: Number(total_quantity) || 1,
|
||||||
|
remaining_quantity,
|
||||||
|
total_value: values.total_value,
|
||||||
|
borrowed_value: values.borrowed_value,
|
||||||
|
available_value: values.available_value,
|
||||||
|
purchase_date: purchase_date ? new Date(purchase_date) : null,
|
||||||
|
supplier,
|
||||||
|
description,
|
||||||
|
status: '可用',
|
||||||
|
storage_location_id: locationId || undefined,
|
||||||
|
department_id: deptId || undefined,
|
||||||
|
asset_type_id: typeId || undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const equipment = await prisma.equipment.findUnique({
|
||||||
|
where: { id: created.id },
|
||||||
|
include: includeStorageLocation
|
||||||
|
})
|
||||||
|
|
||||||
|
successResponse(res, equipment, '新增成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.code === 'P2002') {
|
||||||
|
return errorResponse(res, '资产编码已存在')
|
||||||
|
}
|
||||||
|
errorResponse(res, error.message || '新增资产失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新资产
|
||||||
|
export const updateEquipment = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
const {
|
||||||
|
equipment_name,
|
||||||
|
equipment_model,
|
||||||
|
equipment_type,
|
||||||
|
unit_price,
|
||||||
|
total_quantity,
|
||||||
|
status,
|
||||||
|
purchase_date,
|
||||||
|
supplier,
|
||||||
|
description,
|
||||||
|
storage_location_id,
|
||||||
|
department_id,
|
||||||
|
asset_type_id
|
||||||
|
} = req.body
|
||||||
|
|
||||||
|
// 获取当前设备信息
|
||||||
|
const currentEquipment = await prisma.equipment.findUnique({
|
||||||
|
where: { id: Number(id) }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!currentEquipment) {
|
||||||
|
return errorResponse(res, '资产不存在', 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetLocationId = currentEquipment.storage_location_id
|
||||||
|
if (storage_location_id !== undefined) {
|
||||||
|
const normalizedLocationId = Number(storage_location_id)
|
||||||
|
try {
|
||||||
|
await ensureLocationExists(normalizedLocationId)
|
||||||
|
} catch (error: any) {
|
||||||
|
return errorResponse(res, error.message || '地址无效', 422)
|
||||||
|
}
|
||||||
|
targetLocationId = normalizedLocationId
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理部门ID
|
||||||
|
let targetDeptId = currentEquipment.department_id
|
||||||
|
if (department_id !== undefined) {
|
||||||
|
targetDeptId = department_id ? Number(department_id) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理资产类型ID
|
||||||
|
let targetTypeId = currentEquipment.asset_type_id
|
||||||
|
if (asset_type_id !== undefined) {
|
||||||
|
targetTypeId = asset_type_id ? Number(asset_type_id) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算价值与剩余库存同步逻辑
|
||||||
|
const finalTotalQuantity = total_quantity !== undefined ? Number(total_quantity) : currentEquipment.total_quantity
|
||||||
|
const finalUnitPrice = unit_price !== undefined ? Number(unit_price) : Number(currentEquipment.unit_price)
|
||||||
|
// 已借数量 = 原总数 - 原剩余数(保证不为负)
|
||||||
|
const borrowedQuantity = Math.max(currentEquipment.total_quantity - currentEquipment.remaining_quantity, 0)
|
||||||
|
// 若修改了总库存,则根据已借数量同步计算新的剩余库存;否则保持原剩余库存
|
||||||
|
const newRemainingQuantity =
|
||||||
|
total_quantity !== undefined
|
||||||
|
? Math.max(finalTotalQuantity - borrowedQuantity, 0)
|
||||||
|
: currentEquipment.remaining_quantity
|
||||||
|
const values = calculateEquipmentValues(finalUnitPrice, finalTotalQuantity, newRemainingQuantity)
|
||||||
|
|
||||||
|
// 调试日志:观察请求与计算过程
|
||||||
|
console.log('[更新资产] 输入参数:', {
|
||||||
|
id,
|
||||||
|
body: {
|
||||||
|
equipment_name,
|
||||||
|
equipment_model,
|
||||||
|
equipment_type,
|
||||||
|
unit_price,
|
||||||
|
total_quantity,
|
||||||
|
status,
|
||||||
|
purchase_date,
|
||||||
|
supplier,
|
||||||
|
description,
|
||||||
|
storage_location_id,
|
||||||
|
department_id,
|
||||||
|
asset_type_id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
console.log('[更新资产] 当前资产:', {
|
||||||
|
current_total: currentEquipment.total_quantity,
|
||||||
|
current_remaining: currentEquipment.remaining_quantity,
|
||||||
|
current_status: currentEquipment.status
|
||||||
|
})
|
||||||
|
console.log('[更新资产] 计算结果:', {
|
||||||
|
borrowedQuantity,
|
||||||
|
finalTotalQuantity,
|
||||||
|
newRemainingQuantity,
|
||||||
|
finalUnitPrice,
|
||||||
|
total_value: values.total_value,
|
||||||
|
borrowed_value: values.borrowed_value,
|
||||||
|
available_value: values.available_value
|
||||||
|
})
|
||||||
|
|
||||||
|
await prisma.equipment.update({
|
||||||
|
where: { id: Number(id) },
|
||||||
|
data: {
|
||||||
|
equipment_name,
|
||||||
|
equipment_model,
|
||||||
|
equipment_type,
|
||||||
|
unit_price: finalUnitPrice,
|
||||||
|
total_quantity: finalTotalQuantity,
|
||||||
|
total_value: values.total_value,
|
||||||
|
borrowed_value: values.borrowed_value,
|
||||||
|
available_value: values.available_value,
|
||||||
|
// 若未显式传入状态,则根据剩余库存自动设定
|
||||||
|
status: status ?? (newRemainingQuantity > 0 ? '可用' : '已借出'),
|
||||||
|
purchase_date: purchase_date ? new Date(purchase_date) : currentEquipment.purchase_date,
|
||||||
|
supplier,
|
||||||
|
description,
|
||||||
|
storage_location_id: targetLocationId,
|
||||||
|
department_id: targetDeptId,
|
||||||
|
asset_type_id: targetTypeId,
|
||||||
|
remaining_quantity: newRemainingQuantity
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('[更新资产] 已写入:', {
|
||||||
|
id,
|
||||||
|
total_quantity: finalTotalQuantity,
|
||||||
|
remaining_quantity: newRemainingQuantity
|
||||||
|
})
|
||||||
|
|
||||||
|
const equipment = await prisma.equipment.findUnique({
|
||||||
|
where: { id: Number(id) },
|
||||||
|
include: includeStorageLocation
|
||||||
|
})
|
||||||
|
|
||||||
|
successResponse(res, equipment, '更新成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '更新资产失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除资产
|
||||||
|
export const deleteEquipment = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
|
||||||
|
const equipmentId = Number(id)
|
||||||
|
|
||||||
|
// 检查是否有未归还的借用记录
|
||||||
|
const activeBorrowCount = await prisma.borrowRecord.count({
|
||||||
|
where: {
|
||||||
|
equipment_id: equipmentId,
|
||||||
|
status: '已借出'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (activeBorrowCount > 0) {
|
||||||
|
return errorResponse(res, '该资产存在未归还的借用记录,无法删除')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理历史借用记录,避免外键约束阻止删除
|
||||||
|
await prisma.borrowRecord.deleteMany({
|
||||||
|
where: {
|
||||||
|
equipment_id: equipmentId
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await prisma.equipment.delete({
|
||||||
|
where: { id: equipmentId }
|
||||||
|
})
|
||||||
|
|
||||||
|
successResponse(res, null, '删除成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.code === 'P2025') {
|
||||||
|
return errorResponse(res, '资产不存在', 404)
|
||||||
|
}
|
||||||
|
errorResponse(res, error.message || '删除资产失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成资产编码
|
||||||
|
export const generateCode = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { equipment_type, code_generation_mode = 'auto', storage_location_id } = req.body
|
||||||
|
|
||||||
|
if (code_generation_mode === 'auto') {
|
||||||
|
const locationId = storage_location_id ? Number(storage_location_id) : null
|
||||||
|
const code = await generateEquipmentCode(equipment_type, prisma, locationId)
|
||||||
|
successResponse(res, {
|
||||||
|
equipment_code: code,
|
||||||
|
code_format: '{类型数字}{地址ID(4位)}{序号(5位)}'
|
||||||
|
}, '生成成功')
|
||||||
|
} else {
|
||||||
|
errorResponse(res, '自定义录入模式不需要生成编码')
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '生成编码失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证资产编码
|
||||||
|
export const verifyCode = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { equipment_code } = req.body
|
||||||
|
|
||||||
|
if (!validateEquipmentCode(equipment_code)) {
|
||||||
|
return successResponse(res, {
|
||||||
|
is_valid: false,
|
||||||
|
is_unique: false,
|
||||||
|
message: '编码格式不正确'
|
||||||
|
}, '验证成功')
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.equipment.findUnique({
|
||||||
|
where: { equipment_code }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return successResponse(res, {
|
||||||
|
is_valid: true,
|
||||||
|
is_unique: false,
|
||||||
|
message: '编码已存在'
|
||||||
|
}, '验证成功')
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(res, {
|
||||||
|
is_valid: true,
|
||||||
|
is_unique: true,
|
||||||
|
message: '编码可用'
|
||||||
|
}, '验证成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '验证编码失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据资产编码查询资产信息(扫码)
|
||||||
|
export const scanEquipment = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { equipment_code } = req.params
|
||||||
|
|
||||||
|
const equipment = await prisma.equipment.findUnique({
|
||||||
|
where: { equipment_code },
|
||||||
|
include: includeStorageLocation
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!equipment) {
|
||||||
|
return errorResponse(res, '资产不存在', 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
const is_available = equipment.status === '可用' && equipment.remaining_quantity > 0
|
||||||
|
|
||||||
|
successResponse(res, {
|
||||||
|
...equipment,
|
||||||
|
is_available,
|
||||||
|
message: is_available ? '资产可用' : '资产不可用'
|
||||||
|
}, '查询成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '查询资产失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
457
backend/src/controllers/exportController.ts
Normal file
457
backend/src/controllers/exportController.ts
Normal file
@ -0,0 +1,457 @@
|
|||||||
|
import { Request, Response } from 'express'
|
||||||
|
import prisma from '../utils/prisma'
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
const ExcelJS = require('exceljs')
|
||||||
|
|
||||||
|
// 生成带下拉选项的导入模板
|
||||||
|
export const generateImportTemplate = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
// 从数据库获取所有启用的选项数据
|
||||||
|
const [departments, assetTypes, storageLocations] = await Promise.all([
|
||||||
|
prisma.department.findMany({
|
||||||
|
where: { status: 'active' },
|
||||||
|
select: { name: true },
|
||||||
|
orderBy: { name: 'asc' }
|
||||||
|
}),
|
||||||
|
prisma.assetType.findMany({
|
||||||
|
where: { status: 'active' },
|
||||||
|
select: { name: true },
|
||||||
|
orderBy: { name: 'asc' }
|
||||||
|
}),
|
||||||
|
prisma.storageLocation.findMany({
|
||||||
|
where: { status: 'active' },
|
||||||
|
select: { name: true },
|
||||||
|
orderBy: { name: 'asc' }
|
||||||
|
})
|
||||||
|
])
|
||||||
|
|
||||||
|
const departmentNames = departments.map(d => d.name)
|
||||||
|
const assetTypeNames = assetTypes.map(t => t.name)
|
||||||
|
const locationNames = storageLocations.map(l => l.name)
|
||||||
|
|
||||||
|
const workbook = new ExcelJS.Workbook()
|
||||||
|
|
||||||
|
// 创建主数据表
|
||||||
|
const mainSheet = workbook.addWorksheet('资产导入')
|
||||||
|
|
||||||
|
// 设置列
|
||||||
|
mainSheet.columns = [
|
||||||
|
{ header: '资产名称 *', key: 'equipment_name', width: 25 },
|
||||||
|
{ header: '资产类型 *', key: 'equipment_type', width: 18 },
|
||||||
|
{ header: '型号', key: 'equipment_model', width: 20 },
|
||||||
|
{ header: '使用部门', key: 'department', width: 18 },
|
||||||
|
{ header: '地址', key: 'storage_location', width: 20 },
|
||||||
|
{ header: '单价', key: 'unit_price', width: 12 },
|
||||||
|
{ header: '数量 *', key: 'total_quantity', width: 10 },
|
||||||
|
{ header: '供应商', key: 'supplier', width: 20 },
|
||||||
|
{ header: '采购日期', key: 'purchase_date', width: 15 },
|
||||||
|
{ header: '描述', key: 'description', width: 30 }
|
||||||
|
]
|
||||||
|
|
||||||
|
// 设置表头样式
|
||||||
|
const headerRow = mainSheet.getRow(1)
|
||||||
|
headerRow.font = { bold: true, color: { argb: 'FFFFFFFF' } }
|
||||||
|
headerRow.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF4A90D9' } }
|
||||||
|
headerRow.alignment = { horizontal: 'center', vertical: 'middle' }
|
||||||
|
headerRow.height = 25
|
||||||
|
|
||||||
|
// 添加示例数据行
|
||||||
|
mainSheet.addRow({
|
||||||
|
equipment_name: '示例:笔记本电脑',
|
||||||
|
equipment_type: assetTypeNames[0] || '电子设备',
|
||||||
|
equipment_model: 'ThinkPad X1 Carbon',
|
||||||
|
department: departmentNames[0] || '技术部',
|
||||||
|
storage_location: locationNames[0] || '总部仓库',
|
||||||
|
unit_price: 8000,
|
||||||
|
total_quantity: 1,
|
||||||
|
supplier: 'Lenovo',
|
||||||
|
purchase_date: '2024-01-01',
|
||||||
|
description: '办公用笔记本电脑'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 设置示例行样式(灰色斜体,提示用户删除)
|
||||||
|
const exampleRow = mainSheet.getRow(2)
|
||||||
|
exampleRow.font = { italic: true, color: { argb: 'FF888888' } }
|
||||||
|
|
||||||
|
// 创建隐藏的数据源表
|
||||||
|
const dataSheet = workbook.addWorksheet('数据源')
|
||||||
|
dataSheet.state = 'hidden' // 隐藏此表
|
||||||
|
|
||||||
|
// 写入资产类型列表
|
||||||
|
dataSheet.getCell('A1').value = '资产类型'
|
||||||
|
assetTypeNames.forEach((name, index) => {
|
||||||
|
dataSheet.getCell(`A${index + 2}`).value = name
|
||||||
|
})
|
||||||
|
|
||||||
|
// 写入部门列表
|
||||||
|
dataSheet.getCell('B1').value = '使用部门'
|
||||||
|
departmentNames.forEach((name, index) => {
|
||||||
|
dataSheet.getCell(`B${index + 2}`).value = name
|
||||||
|
})
|
||||||
|
|
||||||
|
// 写入地址列表
|
||||||
|
dataSheet.getCell('C1').value = '地址'
|
||||||
|
locationNames.forEach((name, index) => {
|
||||||
|
dataSheet.getCell(`C${index + 2}`).value = name
|
||||||
|
})
|
||||||
|
|
||||||
|
// 为主表设置数据验证(下拉列表)
|
||||||
|
// 预设100行的下拉选项
|
||||||
|
const maxRows = 100
|
||||||
|
|
||||||
|
// 资产类型下拉(B列)
|
||||||
|
if (assetTypeNames.length > 0) {
|
||||||
|
for (let row = 2; row <= maxRows; row++) {
|
||||||
|
mainSheet.getCell(`B${row}`).dataValidation = {
|
||||||
|
type: 'list',
|
||||||
|
allowBlank: true,
|
||||||
|
formulae: [`数据源!$A$2:$A$${assetTypeNames.length + 1}`],
|
||||||
|
showErrorMessage: true,
|
||||||
|
errorTitle: '无效输入',
|
||||||
|
error: '请从下拉列表中选择资产类型'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用部门下拉(D列)
|
||||||
|
if (departmentNames.length > 0) {
|
||||||
|
for (let row = 2; row <= maxRows; row++) {
|
||||||
|
mainSheet.getCell(`D${row}`).dataValidation = {
|
||||||
|
type: 'list',
|
||||||
|
allowBlank: true,
|
||||||
|
formulae: [`数据源!$B$2:$B$${departmentNames.length + 1}`],
|
||||||
|
showErrorMessage: true,
|
||||||
|
errorTitle: '无效输入',
|
||||||
|
error: '请从下拉列表中选择使用部门'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 地址下拉(E列)
|
||||||
|
if (locationNames.length > 0) {
|
||||||
|
for (let row = 2; row <= maxRows; row++) {
|
||||||
|
mainSheet.getCell(`E${row}`).dataValidation = {
|
||||||
|
type: 'list',
|
||||||
|
allowBlank: true,
|
||||||
|
formulae: [`数据源!$C$2:$C$${locationNames.length + 1}`],
|
||||||
|
showErrorMessage: true,
|
||||||
|
errorTitle: '无效输入',
|
||||||
|
error: '请从下拉列表中选择地址'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加说明表
|
||||||
|
const helpSheet = workbook.addWorksheet('填写说明')
|
||||||
|
helpSheet.columns = [
|
||||||
|
{ header: '字段', key: 'field', width: 15 },
|
||||||
|
{ header: '是否必填', key: 'required', width: 12 },
|
||||||
|
{ header: '说明', key: 'description', width: 50 }
|
||||||
|
]
|
||||||
|
|
||||||
|
helpSheet.getRow(1).font = { bold: true }
|
||||||
|
helpSheet.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } }
|
||||||
|
|
||||||
|
const helpData = [
|
||||||
|
{ field: '资产名称', required: '是', description: '资产的名称,如"笔记本电脑"、"投影仪"等' },
|
||||||
|
{ field: '资产类型', required: '是', description: '从下拉列表选择,当前可选:' + (assetTypeNames.join('、') || '暂无') },
|
||||||
|
{ field: '型号', required: '否', description: '资产的具体型号,如"ThinkPad X1 Carbon"' },
|
||||||
|
{ field: '使用部门', required: '否', description: '从下拉列表选择,当前可选:' + (departmentNames.join('、') || '暂无') },
|
||||||
|
{ field: '地址', required: '否', description: '从下拉列表选择,当前可选:' + (locationNames.join('、') || '暂无') },
|
||||||
|
{ field: '单价', required: '否', description: '资产单价(元),只填数字' },
|
||||||
|
{ field: '数量', required: '是', description: '资产数量,默认为1' },
|
||||||
|
{ field: '供应商', required: '否', description: '供应商名称' },
|
||||||
|
{ field: '采购日期', required: '否', description: '格式:YYYY-MM-DD,如2024-01-01' },
|
||||||
|
{ field: '描述', required: '否', description: '资产的详细描述信息' }
|
||||||
|
]
|
||||||
|
|
||||||
|
helpData.forEach(item => helpSheet.addRow(item))
|
||||||
|
|
||||||
|
// 设置响应头
|
||||||
|
const timestamp = new Date().toISOString().slice(0, 10).replace(/-/g, '')
|
||||||
|
const filename = `资产导入模板_${timestamp}.xlsx`
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`)
|
||||||
|
|
||||||
|
await workbook.xlsx.write(res)
|
||||||
|
res.end()
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[生成导入模板失败]', error)
|
||||||
|
res.status(500).json({ code: 500, message: error.message || '生成模板失败', data: null })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出资产列表
|
||||||
|
export const exportEquipment = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { status, equipment_type, department_id, keyword } = req.query
|
||||||
|
|
||||||
|
const where: any = {}
|
||||||
|
if (status) where.status = status
|
||||||
|
if (equipment_type) where.equipment_type = equipment_type
|
||||||
|
if (department_id) where.department_id = Number(department_id)
|
||||||
|
if (keyword) {
|
||||||
|
where.OR = [
|
||||||
|
{ equipment_code: { contains: String(keyword) } },
|
||||||
|
{ equipment_name: { contains: String(keyword) } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const equipments = await prisma.equipment.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
department: { select: { name: true } },
|
||||||
|
storageLocation: { select: { name: true } },
|
||||||
|
assetType: { select: { name: true } }
|
||||||
|
},
|
||||||
|
orderBy: { created_at: 'desc' }
|
||||||
|
})
|
||||||
|
|
||||||
|
const workbook = new ExcelJS.Workbook()
|
||||||
|
const worksheet = workbook.addWorksheet('资产列表')
|
||||||
|
|
||||||
|
worksheet.columns = [
|
||||||
|
{ header: '资产编码', key: 'equipment_code', width: 20 },
|
||||||
|
{ header: '资产名称', key: 'equipment_name', width: 25 },
|
||||||
|
{ header: '资产类型', key: 'equipment_type', width: 15 },
|
||||||
|
{ header: '型号', key: 'equipment_model', width: 20 },
|
||||||
|
{ header: '使用部门', key: 'department', width: 15 },
|
||||||
|
{ header: '存放地址', key: 'storage_location', width: 20 },
|
||||||
|
{ header: '单价', key: 'unit_price', width: 12 },
|
||||||
|
{ header: '总数量', key: 'total_quantity', width: 10 },
|
||||||
|
{ header: '剩余数量', key: 'remaining_quantity', width: 10 },
|
||||||
|
{ header: '状态', key: 'status', width: 10 },
|
||||||
|
{ header: '采购日期', key: 'purchase_date', width: 15 },
|
||||||
|
{ header: '供应商', key: 'supplier', width: 20 }
|
||||||
|
]
|
||||||
|
|
||||||
|
// 设置表头样式
|
||||||
|
worksheet.getRow(1).font = { bold: true }
|
||||||
|
worksheet.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } }
|
||||||
|
|
||||||
|
equipments.forEach(eq => {
|
||||||
|
worksheet.addRow({
|
||||||
|
equipment_code: eq.equipment_code,
|
||||||
|
equipment_name: eq.equipment_name,
|
||||||
|
equipment_type: eq.assetType?.name || eq.equipment_type,
|
||||||
|
equipment_model: eq.equipment_model,
|
||||||
|
department: eq.department?.name || '',
|
||||||
|
storage_location: eq.storageLocation?.name || '',
|
||||||
|
unit_price: Number(eq.unit_price),
|
||||||
|
total_quantity: eq.total_quantity,
|
||||||
|
remaining_quantity: eq.remaining_quantity,
|
||||||
|
status: eq.status,
|
||||||
|
purchase_date: eq.purchase_date ? new Date(eq.purchase_date).toLocaleDateString('zh-CN') : '',
|
||||||
|
supplier: eq.supplier || ''
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const timestamp = new Date().toISOString().slice(0, 10).replace(/-/g, '')
|
||||||
|
const filename = `资产列表_${timestamp}.xlsx`
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`)
|
||||||
|
|
||||||
|
await workbook.xlsx.write(res)
|
||||||
|
res.end()
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[导出资产列表失败]', error)
|
||||||
|
res.status(500).json({ code: 500, message: error.message || '导出失败', data: null })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出借用记录
|
||||||
|
export const exportBorrowRecords = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { status, person_name, start_date, end_date } = req.query
|
||||||
|
|
||||||
|
const where: any = {}
|
||||||
|
if (status) where.status = status
|
||||||
|
if (person_name) {
|
||||||
|
where.OR = [
|
||||||
|
{ person_name: { contains: String(person_name) } },
|
||||||
|
{ person_nickname: { contains: String(person_name) } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if (start_date || end_date) {
|
||||||
|
where.borrow_time = {}
|
||||||
|
if (start_date) where.borrow_time.gte = new Date(String(start_date))
|
||||||
|
if (end_date) where.borrow_time.lte = new Date(String(end_date))
|
||||||
|
}
|
||||||
|
|
||||||
|
const records = await prisma.borrowRecord.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
equipment: { select: { equipment_name: true, unit_price: true } },
|
||||||
|
person: { select: { nickname: true, real_name: true, department: true } }
|
||||||
|
},
|
||||||
|
orderBy: { borrow_time: 'desc' }
|
||||||
|
})
|
||||||
|
|
||||||
|
const workbook = new ExcelJS.Workbook()
|
||||||
|
const worksheet = workbook.addWorksheet('借用记录')
|
||||||
|
|
||||||
|
worksheet.columns = [
|
||||||
|
{ header: '资产编码', key: 'equipment_code', width: 20 },
|
||||||
|
{ header: '资产名称', key: 'equipment_name', width: 25 },
|
||||||
|
{ header: '使用者', key: 'person_name', width: 15 },
|
||||||
|
{ header: '使用者昵称', key: 'person_nickname', width: 15 },
|
||||||
|
{ header: '部门', key: 'department', width: 15 },
|
||||||
|
{ header: '借用时间', key: 'borrow_time', width: 20 },
|
||||||
|
{ header: '预计归还', key: 'expected_return_time', width: 20 },
|
||||||
|
{ header: '实际归还', key: 'return_time', width: 20 },
|
||||||
|
{ header: '状态', key: 'status', width: 10 },
|
||||||
|
{ header: '归还状况', key: 'return_condition', width: 10 },
|
||||||
|
{ header: '备注', key: 'notes', width: 30 }
|
||||||
|
]
|
||||||
|
|
||||||
|
worksheet.getRow(1).font = { bold: true }
|
||||||
|
worksheet.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } }
|
||||||
|
|
||||||
|
records.forEach(record => {
|
||||||
|
worksheet.addRow({
|
||||||
|
equipment_code: record.equipment_code,
|
||||||
|
equipment_name: record.equipment?.equipment_name || '',
|
||||||
|
person_name: record.person_name,
|
||||||
|
person_nickname: record.person_nickname,
|
||||||
|
department: record.person?.department || '',
|
||||||
|
borrow_time: record.borrow_time ? new Date(record.borrow_time).toLocaleString('zh-CN') : '',
|
||||||
|
expected_return_time: record.is_long_term ? '长期' : (record.expected_return_time ? new Date(record.expected_return_time).toLocaleDateString('zh-CN') : ''),
|
||||||
|
return_time: record.return_time ? new Date(record.return_time).toLocaleString('zh-CN') : '',
|
||||||
|
status: record.status,
|
||||||
|
return_condition: record.return_condition,
|
||||||
|
notes: record.notes || ''
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const timestamp = new Date().toISOString().slice(0, 10).replace(/-/g, '')
|
||||||
|
const filename = `借用记录_${timestamp}.xlsx`
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`)
|
||||||
|
|
||||||
|
await workbook.xlsx.write(res)
|
||||||
|
res.end()
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[导出借用记录失败]', error)
|
||||||
|
res.status(500).json({ code: 500, message: error.message || '导出失败', data: null })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 生成带下拉选项的使用者导入模板
|
||||||
|
export const generatePersonTemplate = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
// 从数据库获取所有启用的部门
|
||||||
|
const departments = await prisma.department.findMany({
|
||||||
|
where: { status: 'active' },
|
||||||
|
select: { name: true },
|
||||||
|
orderBy: { name: 'asc' }
|
||||||
|
})
|
||||||
|
|
||||||
|
const departmentNames = departments.map(d => d.name)
|
||||||
|
|
||||||
|
const workbook = new ExcelJS.Workbook()
|
||||||
|
|
||||||
|
// 创建主数据表
|
||||||
|
const mainSheet = workbook.addWorksheet('使用者导入')
|
||||||
|
|
||||||
|
// 设置列
|
||||||
|
mainSheet.columns = [
|
||||||
|
{ header: '昵称 *', key: 'nickname', width: 15 },
|
||||||
|
{ header: '真实姓名 *', key: 'real_name', width: 15 },
|
||||||
|
{ header: '工号', key: 'employee_id', width: 15 },
|
||||||
|
{ header: '部门', key: 'department', width: 18 },
|
||||||
|
{ header: '职位', key: 'position', width: 15 },
|
||||||
|
{ header: '电话', key: 'phone', width: 15 },
|
||||||
|
{ header: '邮箱', key: 'email', width: 25 }
|
||||||
|
]
|
||||||
|
|
||||||
|
// 设置表头样式
|
||||||
|
const headerRow = mainSheet.getRow(1)
|
||||||
|
headerRow.font = { bold: true, color: { argb: 'FFFFFFFF' } }
|
||||||
|
headerRow.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF4A90D9' } }
|
||||||
|
headerRow.alignment = { horizontal: 'center', vertical: 'middle' }
|
||||||
|
headerRow.height = 25
|
||||||
|
|
||||||
|
// 添加示例数据行
|
||||||
|
mainSheet.addRow({
|
||||||
|
nickname: '示例:张三',
|
||||||
|
real_name: '张三',
|
||||||
|
employee_id: 'EMP001',
|
||||||
|
department: departmentNames[0] || '技术部',
|
||||||
|
position: '工程师',
|
||||||
|
phone: '13800138000',
|
||||||
|
email: 'zhangsan@example.com'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 设置示例行样式(灰色斜体,提示用户删除)
|
||||||
|
const exampleRow = mainSheet.getRow(2)
|
||||||
|
exampleRow.font = { italic: true, color: { argb: 'FF888888' } }
|
||||||
|
|
||||||
|
// 创建隐藏的数据源表
|
||||||
|
const dataSheet = workbook.addWorksheet('数据源')
|
||||||
|
dataSheet.state = 'hidden' // 隐藏此表
|
||||||
|
|
||||||
|
// 写入部门列表
|
||||||
|
dataSheet.getCell('A1').value = '部门'
|
||||||
|
departmentNames.forEach((name, index) => {
|
||||||
|
dataSheet.getCell(`A${index + 2}`).value = name
|
||||||
|
})
|
||||||
|
|
||||||
|
// 为主表设置数据验证(下拉列表)
|
||||||
|
// 预设100行的下拉选项
|
||||||
|
const maxRows = 100
|
||||||
|
|
||||||
|
// 部门下拉(D列)
|
||||||
|
if (departmentNames.length > 0) {
|
||||||
|
for (let row = 2; row <= maxRows; row++) {
|
||||||
|
mainSheet.getCell(`D${row}`).dataValidation = {
|
||||||
|
type: 'list',
|
||||||
|
allowBlank: true,
|
||||||
|
formulae: [`数据源!$A$2:$A$${departmentNames.length + 1}`],
|
||||||
|
showErrorMessage: true,
|
||||||
|
errorTitle: '无效输入',
|
||||||
|
error: '请从下拉列表中选择部门'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加说明表
|
||||||
|
const helpSheet = workbook.addWorksheet('填写说明')
|
||||||
|
helpSheet.columns = [
|
||||||
|
{ header: '字段', key: 'field', width: 15 },
|
||||||
|
{ header: '是否必填', key: 'required', width: 12 },
|
||||||
|
{ header: '说明', key: 'description', width: 50 }
|
||||||
|
]
|
||||||
|
|
||||||
|
helpSheet.getRow(1).font = { bold: true }
|
||||||
|
helpSheet.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } }
|
||||||
|
|
||||||
|
const helpData = [
|
||||||
|
{ field: '昵称', required: '是', description: '使用者的昵称或常用名' },
|
||||||
|
{ field: '真实姓名', required: '是', description: '使用者的真实姓名' },
|
||||||
|
{ field: '工号', required: '否', description: '员工工号' },
|
||||||
|
{ field: '部门', required: '否', description: '从下拉列表选择,当前可选:' + (departmentNames.join('、') || '暂无') },
|
||||||
|
{ field: '职位', required: '否', description: '职位名称,如"工程师"、"经理"等' },
|
||||||
|
{ field: '电话', required: '否', description: '联系电话' },
|
||||||
|
{ field: '邮箱', required: '否', description: '电子邮箱地址' }
|
||||||
|
]
|
||||||
|
|
||||||
|
helpData.forEach(item => helpSheet.addRow(item))
|
||||||
|
|
||||||
|
// 设置响应头
|
||||||
|
const timestamp = new Date().toISOString().slice(0, 10).replace(/-/g, '')
|
||||||
|
const filename = `使用者导入模板_${timestamp}.xlsx`
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`)
|
||||||
|
|
||||||
|
await workbook.xlsx.write(res)
|
||||||
|
res.end()
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[生成使用者导入模板失败]', error)
|
||||||
|
res.status(500).json({ code: 500, message: error.message || '生成模板失败', data: null })
|
||||||
|
}
|
||||||
|
}
|
||||||
282
backend/src/controllers/maintenanceController.ts
Normal file
282
backend/src/controllers/maintenanceController.ts
Normal file
@ -0,0 +1,282 @@
|
|||||||
|
import { Request, Response } from 'express'
|
||||||
|
import prisma from '../utils/prisma'
|
||||||
|
import { successResponse, errorResponse } from '../utils/response'
|
||||||
|
import { logOperation } from './operationLogController'
|
||||||
|
|
||||||
|
// 创建维修记录
|
||||||
|
export const createMaintenance = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { equipment_id, issue_description, repair_vendor, estimated_cost, expected_end_date, notes } = req.body
|
||||||
|
const userId = res.locals.user?.id
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return errorResponse(res, '用户未登录', 401)
|
||||||
|
}
|
||||||
|
|
||||||
|
const equipment = await prisma.equipment.findUnique({
|
||||||
|
where: { id: Number(equipment_id) }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!equipment) {
|
||||||
|
return errorResponse(res, '资产不存在', 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (equipment.status === '已借出') {
|
||||||
|
return errorResponse(res, '资产已借出,无法送修')
|
||||||
|
}
|
||||||
|
if (equipment.status === '维护中') {
|
||||||
|
return errorResponse(res, '资产已在维修中')
|
||||||
|
}
|
||||||
|
if (equipment.status === '待报废' || equipment.status === '已报废') {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await prisma.equipment.update({
|
||||||
|
where: { id: equipment.id },
|
||||||
|
data: { status: '维护中' }
|
||||||
|
})
|
||||||
|
|
||||||
|
// 记录操作日志
|
||||||
|
const username = res.locals.user?.username || res.locals.user?.name || 'unknown'
|
||||||
|
await logOperation(
|
||||||
|
userId,
|
||||||
|
username,
|
||||||
|
'送修',
|
||||||
|
`资产 ${equipment.equipment_name}(${equipment.equipment_code}) 送修,故障描述:${issue_description}${repair_vendor ? `,维修商:${repair_vendor}` : ''}`,
|
||||||
|
equipment.id,
|
||||||
|
equipment.equipment_code,
|
||||||
|
req.ip
|
||||||
|
)
|
||||||
|
|
||||||
|
successResponse(res, maintenance, '维修记录创建成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[创建维修记录失败]', error)
|
||||||
|
errorResponse(res, error.message || '创建维修记录失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取维修记录列表
|
||||||
|
export const getMaintenanceList = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { page = 1, pageSize = 10, status, keyword, equipment_id } = req.query
|
||||||
|
const skip = (Number(page) - 1) * Number(pageSize)
|
||||||
|
|
||||||
|
const where: any = {}
|
||||||
|
if (status) where.status = status
|
||||||
|
if (equipment_id) where.equipment_id = Number(equipment_id)
|
||||||
|
if (keyword) {
|
||||||
|
where.OR = [
|
||||||
|
{ equipment_code: { contains: String(keyword) } },
|
||||||
|
{ issue_description: { contains: String(keyword) } },
|
||||||
|
{ repair_vendor: { contains: String(keyword) } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const [list, total] = await Promise.all([
|
||||||
|
prisma.maintenanceRecord.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: Number(pageSize),
|
||||||
|
orderBy: { created_at: 'desc' },
|
||||||
|
include: {
|
||||||
|
equipment: { select: { equipment_name: true, equipment_type: true } },
|
||||||
|
creator: { select: { id: true, name: true, username: true } }
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
prisma.maintenanceRecord.count({ where })
|
||||||
|
])
|
||||||
|
|
||||||
|
successResponse(res, { list, total, page: Number(page), pageSize: Number(pageSize) }, '获取成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '获取维修记录列表失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取维修记录详情
|
||||||
|
export const getMaintenanceDetail = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
const maintenance = await prisma.maintenanceRecord.findUnique({
|
||||||
|
where: { id: Number(id) },
|
||||||
|
include: {
|
||||||
|
equipment: true,
|
||||||
|
creator: { select: { id: true, name: true, username: true } }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!maintenance) {
|
||||||
|
return errorResponse(res, '维修记录不存在', 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(res, maintenance, '获取成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '获取维修记录详情失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新维修记录
|
||||||
|
export const updateMaintenance = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
const { repair_vendor, estimated_cost, expected_end_date, notes } = req.body
|
||||||
|
|
||||||
|
const maintenance = await prisma.maintenanceRecord.findUnique({
|
||||||
|
where: { id: Number(id) }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!maintenance) {
|
||||||
|
return errorResponse(res, '维修记录不存在', 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maintenance.status !== 'in_progress') {
|
||||||
|
return errorResponse(res, '只能更新进行中的维修记录')
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.maintenanceRecord.update({
|
||||||
|
where: { id: Number(id) },
|
||||||
|
data: {
|
||||||
|
repair_vendor,
|
||||||
|
estimated_cost: estimated_cost !== undefined ? Number(estimated_cost) : undefined,
|
||||||
|
expected_end_date: expected_end_date ? new Date(expected_end_date) : undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
successResponse(res, updated, '更新成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '更新维修记录失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 完成维修
|
||||||
|
export const completeMaintenance = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
const { actual_cost, repair_result } = req.body
|
||||||
|
|
||||||
|
const maintenance = await prisma.maintenanceRecord.findUnique({
|
||||||
|
where: { id: Number(id) }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!maintenance) {
|
||||||
|
return errorResponse(res, '维修记录不存在', 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maintenance.status !== 'in_progress') {
|
||||||
|
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.equipment.update({
|
||||||
|
where: { id: maintenance.equipment_id },
|
||||||
|
data: { status: '可用' }
|
||||||
|
})
|
||||||
|
|
||||||
|
// 记录操作日志
|
||||||
|
const userId = res.locals.user?.id
|
||||||
|
const username = res.locals.user?.username || res.locals.user?.name || 'unknown'
|
||||||
|
await logOperation(
|
||||||
|
userId,
|
||||||
|
username,
|
||||||
|
'维修完成',
|
||||||
|
`资产 (${maintenance.equipment_code}) 维修完成,实际费用:¥${actual_cost !== undefined ? actual_cost : maintenance.estimated_cost}${repair_result ? `,维修结果:${repair_result}` : ''}`,
|
||||||
|
maintenance.equipment_id,
|
||||||
|
maintenance.equipment_code,
|
||||||
|
req.ip
|
||||||
|
)
|
||||||
|
|
||||||
|
successResponse(res, null, '维修完成')
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '完成维修失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取消维修
|
||||||
|
export const cancelMaintenance = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
const { reason } = req.body
|
||||||
|
|
||||||
|
const maintenance = await prisma.maintenanceRecord.findUnique({
|
||||||
|
where: { id: Number(id) }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!maintenance) {
|
||||||
|
return errorResponse(res, '维修记录不存在', 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maintenance.status !== 'in_progress') {
|
||||||
|
return errorResponse(res, '只能取消进行中的维修记录')
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.maintenanceRecord.update({
|
||||||
|
where: { id: Number(id) },
|
||||||
|
data: { status: 'cancelled', repair_result: reason || '已取消' }
|
||||||
|
})
|
||||||
|
|
||||||
|
await prisma.equipment.update({
|
||||||
|
where: { id: maintenance.equipment_id },
|
||||||
|
data: { status: '可用' }
|
||||||
|
})
|
||||||
|
|
||||||
|
// 记录操作日志
|
||||||
|
const userId = res.locals.user?.id
|
||||||
|
const username = res.locals.user?.username || res.locals.user?.name || 'unknown'
|
||||||
|
await logOperation(
|
||||||
|
userId,
|
||||||
|
username,
|
||||||
|
'维修取消',
|
||||||
|
`取消资产 (${maintenance.equipment_code}) 的维修${reason ? `,原因:${reason}` : ''}`,
|
||||||
|
maintenance.equipment_id,
|
||||||
|
maintenance.equipment_code,
|
||||||
|
req.ip
|
||||||
|
)
|
||||||
|
|
||||||
|
successResponse(res, null, '维修已取消')
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '取消维修失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取资产的维修历史
|
||||||
|
export const getEquipmentMaintenanceHistory = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { equipment_id } = req.params
|
||||||
|
const history = await prisma.maintenanceRecord.findMany({
|
||||||
|
where: { equipment_id: Number(equipment_id) },
|
||||||
|
orderBy: { created_at: 'desc' },
|
||||||
|
include: {
|
||||||
|
creator: { select: { id: true, name: true } }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 计算总维修费用
|
||||||
|
const totalCost = history.reduce((sum, record) => {
|
||||||
|
return sum + Number(record.actual_cost || record.estimated_cost || 0)
|
||||||
|
}, 0)
|
||||||
|
|
||||||
|
successResponse(res, { history, totalCost }, '获取成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '获取维修历史失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
413
backend/src/controllers/mountController.ts
Normal file
413
backend/src/controllers/mountController.ts
Normal file
@ -0,0 +1,413 @@
|
|||||||
|
import { Request, Response } from 'express'
|
||||||
|
import prisma from '../utils/prisma'
|
||||||
|
import { successResponse, errorResponse } from '../utils/response'
|
||||||
|
import { Decimal } from '@prisma/client/runtime/library'
|
||||||
|
|
||||||
|
// 计算父资产含配件总价值
|
||||||
|
async function calculateTotalValueWithChildren(parentId: number): Promise<Decimal> {
|
||||||
|
const parent = await prisma.equipment.findUnique({
|
||||||
|
where: { id: parentId },
|
||||||
|
include: {
|
||||||
|
children: {
|
||||||
|
select: { unit_price: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!parent) return new Decimal(0)
|
||||||
|
|
||||||
|
const childrenValue = parent.children.reduce(
|
||||||
|
(sum, child) => sum.add(child.unit_price),
|
||||||
|
new Decimal(0)
|
||||||
|
)
|
||||||
|
|
||||||
|
return parent.unit_price.add(childrenValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新父资产的含配件总价值
|
||||||
|
async function updateParentTotalValue(parentId: number): Promise<void> {
|
||||||
|
const totalValue = await calculateTotalValueWithChildren(parentId)
|
||||||
|
await prisma.equipment.update({
|
||||||
|
where: { id: parentId },
|
||||||
|
data: { total_value_with_children: totalValue }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 挂载子资产到父资产
|
||||||
|
export const mountChild = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { parentId } = req.params
|
||||||
|
const { child_id, notes } = req.body as { child_id: number; notes?: string }
|
||||||
|
const userId = (req as any).user?.id || 1
|
||||||
|
|
||||||
|
// 验证父资产存在
|
||||||
|
const parent = await prisma.equipment.findUnique({
|
||||||
|
where: { id: Number(parentId) },
|
||||||
|
include: { assetType: true }
|
||||||
|
})
|
||||||
|
if (!parent) {
|
||||||
|
return errorResponse(res, '父资产不存在', 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证父资产类型支持挂载
|
||||||
|
if (!parent.assetType?.can_be_parent) {
|
||||||
|
return errorResponse(res, '该资产类型不支持挂载配件', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证子资产存在
|
||||||
|
const child = await prisma.equipment.findUnique({
|
||||||
|
where: { id: child_id },
|
||||||
|
include: { assetType: true }
|
||||||
|
})
|
||||||
|
if (!child) {
|
||||||
|
return errorResponse(res, '子资产不存在', 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证子资产类型支持被挂载
|
||||||
|
if (!child.assetType?.can_be_child) {
|
||||||
|
return errorResponse(res, '该资产类型不能作为配件挂载', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证子资产未被挂载
|
||||||
|
if (child.parent_id) {
|
||||||
|
const currentParent = await prisma.equipment.findUnique({
|
||||||
|
where: { id: child.parent_id }
|
||||||
|
})
|
||||||
|
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),
|
||||||
|
mount_status: 'mounted'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 记录挂载历史
|
||||||
|
await tx.mountHistory.create({
|
||||||
|
data: {
|
||||||
|
child_id,
|
||||||
|
parent_id: Number(parentId),
|
||||||
|
operation_type: 'mount',
|
||||||
|
operated_by: userId,
|
||||||
|
notes
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 更新父资产含配件总价值
|
||||||
|
await updateParentTotalValue(Number(parentId))
|
||||||
|
|
||||||
|
// 获取更新后的数据
|
||||||
|
const updatedParent = await prisma.equipment.findUnique({
|
||||||
|
where: { id: Number(parentId) },
|
||||||
|
include: {
|
||||||
|
children: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
equipment_code: true,
|
||||||
|
equipment_name: true,
|
||||||
|
equipment_model: true,
|
||||||
|
equipment_type: true,
|
||||||
|
unit_price: true,
|
||||||
|
status: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
successResponse(res, updatedParent, '挂载成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '挂载失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从父资产卸载子资产
|
||||||
|
export const unmountChild = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { parentId } = req.params
|
||||||
|
const { child_id, notes } = req.body as { child_id: number; notes?: string }
|
||||||
|
const userId = (req as any).user?.id || 1
|
||||||
|
|
||||||
|
// 验证子资产存在且挂载在该父资产上
|
||||||
|
const child = await prisma.equipment.findUnique({
|
||||||
|
where: { id: child_id }
|
||||||
|
})
|
||||||
|
if (!child) {
|
||||||
|
return errorResponse(res, '子资产不存在', 404)
|
||||||
|
}
|
||||||
|
if (child.parent_id !== Number(parentId)) {
|
||||||
|
return errorResponse(res, '该配件未挂载到此资产', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行卸载操作
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
// 更新子资产
|
||||||
|
await tx.equipment.update({
|
||||||
|
where: { id: child_id },
|
||||||
|
data: {
|
||||||
|
parent_id: null,
|
||||||
|
mount_status: 'unmounted'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 记录卸载历史
|
||||||
|
await tx.mountHistory.create({
|
||||||
|
data: {
|
||||||
|
child_id,
|
||||||
|
parent_id: Number(parentId),
|
||||||
|
operation_type: 'unmount',
|
||||||
|
operated_by: userId,
|
||||||
|
notes
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 更新父资产含配件总价值
|
||||||
|
await updateParentTotalValue(Number(parentId))
|
||||||
|
|
||||||
|
successResponse(res, null, '卸载成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '卸载失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更换配件(卸载旧配件 + 挂载新配件)
|
||||||
|
export const replaceChild = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { parentId } = req.params
|
||||||
|
const { old_child_id, new_child_id, notes } = req.body as {
|
||||||
|
old_child_id: number
|
||||||
|
new_child_id: number
|
||||||
|
notes?: string
|
||||||
|
}
|
||||||
|
const userId = (req as any).user?.id || 1
|
||||||
|
|
||||||
|
// 验证旧配件挂载在该父资产上
|
||||||
|
const oldChild = await prisma.equipment.findUnique({
|
||||||
|
where: { id: old_child_id }
|
||||||
|
})
|
||||||
|
if (!oldChild || oldChild.parent_id !== Number(parentId)) {
|
||||||
|
return errorResponse(res, '旧配件未挂载到此资产', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证新配件存在且未被挂载
|
||||||
|
const newChild = await prisma.equipment.findUnique({
|
||||||
|
where: { id: new_child_id },
|
||||||
|
include: { assetType: true }
|
||||||
|
})
|
||||||
|
if (!newChild) {
|
||||||
|
return errorResponse(res, '新配件不存在', 404)
|
||||||
|
}
|
||||||
|
if (!newChild.assetType?.can_be_child) {
|
||||||
|
return errorResponse(res, '新配件类型不能作为配件挂载', 400)
|
||||||
|
}
|
||||||
|
if (newChild.parent_id) {
|
||||||
|
return errorResponse(res, '新配件已挂载到其他资产', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在事务中执行更换操作
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
// 卸载旧配件
|
||||||
|
await tx.equipment.update({
|
||||||
|
where: { id: old_child_id },
|
||||||
|
data: {
|
||||||
|
parent_id: null,
|
||||||
|
mount_status: 'unmounted'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 挂载新配件
|
||||||
|
await tx.equipment.update({
|
||||||
|
where: { id: new_child_id },
|
||||||
|
data: {
|
||||||
|
parent_id: Number(parentId),
|
||||||
|
mount_status: 'mounted'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 记录更换历史
|
||||||
|
await tx.mountHistory.create({
|
||||||
|
data: {
|
||||||
|
child_id: old_child_id,
|
||||||
|
parent_id: Number(parentId),
|
||||||
|
operation_type: 'replace',
|
||||||
|
operated_by: userId,
|
||||||
|
notes,
|
||||||
|
old_parent_id: Number(parentId),
|
||||||
|
new_parent_id: null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await tx.mountHistory.create({
|
||||||
|
data: {
|
||||||
|
child_id: new_child_id,
|
||||||
|
parent_id: Number(parentId),
|
||||||
|
operation_type: 'replace',
|
||||||
|
operated_by: userId,
|
||||||
|
notes,
|
||||||
|
old_parent_id: null,
|
||||||
|
new_parent_id: Number(parentId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 更新父资产含配件总价值
|
||||||
|
await updateParentTotalValue(Number(parentId))
|
||||||
|
|
||||||
|
successResponse(res, null, '更换成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '更换失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取父资产的子资产列表
|
||||||
|
export const getChildren = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { parentId } = req.params
|
||||||
|
|
||||||
|
const parent = await prisma.equipment.findUnique({
|
||||||
|
where: { id: Number(parentId) },
|
||||||
|
include: {
|
||||||
|
children: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
equipment_code: true,
|
||||||
|
equipment_name: true,
|
||||||
|
equipment_model: true,
|
||||||
|
equipment_type: true,
|
||||||
|
unit_price: true,
|
||||||
|
status: true,
|
||||||
|
mount_status: true,
|
||||||
|
created_at: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!parent) {
|
||||||
|
return errorResponse(res, '资产不存在', 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalChildrenValue = parent.children.reduce(
|
||||||
|
(sum, child) => sum.add(child.unit_price),
|
||||||
|
new Decimal(0)
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
const history = await prisma.mountHistory.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ child_id: Number(id) },
|
||||||
|
{ parent_id: Number(id) }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
child: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
equipment_code: true,
|
||||||
|
equipment_name: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
operator: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
username: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: { operated_at: 'desc' }
|
||||||
|
})
|
||||||
|
|
||||||
|
// 补充父资产信息
|
||||||
|
const historyWithParent = await Promise.all(
|
||||||
|
history.map(async (h) => {
|
||||||
|
let parentInfo = null
|
||||||
|
if (h.parent_id) {
|
||||||
|
const parent = await prisma.equipment.findUnique({
|
||||||
|
where: { id: h.parent_id },
|
||||||
|
select: { id: true, equipment_code: true, equipment_name: true }
|
||||||
|
})
|
||||||
|
parentInfo = parent
|
||||||
|
}
|
||||||
|
return { ...h, parent: parentInfo }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
successResponse(res, historyWithParent, '获取成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '获取挂载历史失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取可挂载的子资产列表(未挂载的配件类型资产)
|
||||||
|
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 where: any = {
|
||||||
|
parent_id: null,
|
||||||
|
mount_status: { in: ['none', 'unmounted'] },
|
||||||
|
equipment_type: { in: childTypeNames },
|
||||||
|
status: '可用'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyword) {
|
||||||
|
where.OR = [
|
||||||
|
{ equipment_name: { contains: keyword as string } },
|
||||||
|
{ equipment_code: { contains: keyword as string } },
|
||||||
|
{ equipment_model: { contains: keyword as string } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (equipment_type) {
|
||||||
|
where.equipment_type = equipment_type
|
||||||
|
}
|
||||||
|
|
||||||
|
const children = await prisma.equipment.findMany({
|
||||||
|
where,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
equipment_code: true,
|
||||||
|
equipment_name: true,
|
||||||
|
equipment_model: true,
|
||||||
|
equipment_type: true,
|
||||||
|
unit_price: true,
|
||||||
|
status: true
|
||||||
|
},
|
||||||
|
orderBy: { equipment_name: 'asc' },
|
||||||
|
take: 50
|
||||||
|
})
|
||||||
|
|
||||||
|
successResponse(res, children, '获取成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '获取可挂载配件列表失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
111
backend/src/controllers/operationLogController.ts
Normal file
111
backend/src/controllers/operationLogController.ts
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import { Request, Response } from 'express'
|
||||||
|
import prisma from '../utils/prisma'
|
||||||
|
import { successResponse, errorResponse } from '../utils/response'
|
||||||
|
|
||||||
|
// 获取操作日志列表
|
||||||
|
export const getOperationLogList = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
page = 1,
|
||||||
|
pageSize = 20,
|
||||||
|
operationType,
|
||||||
|
username,
|
||||||
|
startDate,
|
||||||
|
endDate
|
||||||
|
} = req.query
|
||||||
|
|
||||||
|
const where: any = {}
|
||||||
|
|
||||||
|
if (operationType) {
|
||||||
|
where.operation_type = operationType
|
||||||
|
}
|
||||||
|
|
||||||
|
if (username) {
|
||||||
|
where.username = { contains: username as string }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startDate || endDate) {
|
||||||
|
where.created_at = {}
|
||||||
|
if (startDate) {
|
||||||
|
where.created_at.gte = new Date(startDate as string)
|
||||||
|
}
|
||||||
|
if (endDate) {
|
||||||
|
const end = new Date(endDate as string)
|
||||||
|
end.setHours(23, 59, 59, 999)
|
||||||
|
where.created_at.lte = end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const skip = (Number(page) - 1) * Number(pageSize)
|
||||||
|
|
||||||
|
const [list, total] = await Promise.all([
|
||||||
|
prisma.operationLog.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: Number(pageSize),
|
||||||
|
orderBy: { created_at: 'desc' },
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
name: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
prisma.operationLog.count({ where })
|
||||||
|
])
|
||||||
|
|
||||||
|
successResponse(res, {
|
||||||
|
list,
|
||||||
|
total,
|
||||||
|
page: Number(page),
|
||||||
|
pageSize: Number(pageSize)
|
||||||
|
}, '获取成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '获取操作日志失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取操作类型列表
|
||||||
|
export const getOperationTypes = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const types = await prisma.operationLog.findMany({
|
||||||
|
select: { operation_type: true },
|
||||||
|
distinct: ['operation_type']
|
||||||
|
})
|
||||||
|
|
||||||
|
const typeList = types.map(t => t.operation_type)
|
||||||
|
successResponse(res, typeList, '获取成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '获取操作类型失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录操作日志(工具函数)
|
||||||
|
export const logOperation = async (
|
||||||
|
userId: number | null,
|
||||||
|
username: string,
|
||||||
|
operationType: string,
|
||||||
|
details: string,
|
||||||
|
equipmentId?: number,
|
||||||
|
equipmentCode?: string,
|
||||||
|
ipAddress?: string
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
await prisma.operationLog.create({
|
||||||
|
data: {
|
||||||
|
user_id: userId,
|
||||||
|
username,
|
||||||
|
operation_type: operationType,
|
||||||
|
equipment_id: equipmentId || null,
|
||||||
|
equipment_code: equipmentCode || null,
|
||||||
|
operation_details: details,
|
||||||
|
ip_address: ipAddress || null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('记录操作日志失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
255
backend/src/controllers/personController.ts
Normal file
255
backend/src/controllers/personController.ts
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
import { Request, Response } from 'express'
|
||||||
|
import prisma from '../utils/prisma'
|
||||||
|
import { successResponse, errorResponse } from '../utils/response'
|
||||||
|
|
||||||
|
const sanitizePerson = (person: any) => ({
|
||||||
|
id: person.id,
|
||||||
|
nickname: person.nickname,
|
||||||
|
real_name: person.real_name,
|
||||||
|
id_number: person.id_number,
|
||||||
|
department: person.department,
|
||||||
|
position: person.position,
|
||||||
|
phone: person.phone,
|
||||||
|
email: person.email,
|
||||||
|
status: person.status,
|
||||||
|
remarks: person.remarks,
|
||||||
|
created_at: person.created_at,
|
||||||
|
updated_at: person.updated_at
|
||||||
|
})
|
||||||
|
|
||||||
|
export const getPersonList = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { page = 1, pageSize = 10, keyword, status } = req.query
|
||||||
|
|
||||||
|
const where: any = {}
|
||||||
|
|
||||||
|
if (keyword) {
|
||||||
|
const kw = keyword as string
|
||||||
|
where.OR = [
|
||||||
|
{ nickname: { contains: kw } },
|
||||||
|
{ real_name: { contains: kw } },
|
||||||
|
{ id_number: { contains: kw } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
where.status = status
|
||||||
|
}
|
||||||
|
|
||||||
|
const skip = (Number(page) - 1) * Number(pageSize)
|
||||||
|
|
||||||
|
const [list, total] = await Promise.all([
|
||||||
|
prisma.person.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: Number(pageSize),
|
||||||
|
orderBy: { created_at: 'desc' }
|
||||||
|
}),
|
||||||
|
prisma.person.count({ where })
|
||||||
|
])
|
||||||
|
|
||||||
|
successResponse(
|
||||||
|
res,
|
||||||
|
{
|
||||||
|
list: list.map(sanitizePerson),
|
||||||
|
total,
|
||||||
|
page: Number(page),
|
||||||
|
pageSize: Number(pageSize)
|
||||||
|
},
|
||||||
|
'获取成功'
|
||||||
|
)
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '获取使用者列表失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getPersonDetail = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
const person = await prisma.person.findUnique({ where: { id: Number(id) } })
|
||||||
|
if (!person) {
|
||||||
|
return errorResponse(res, '使用者不存在', 404)
|
||||||
|
}
|
||||||
|
successResponse(res, sanitizePerson(person), '获取成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '获取使用者详情失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createPerson = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
nickname,
|
||||||
|
real_name,
|
||||||
|
id_number,
|
||||||
|
department,
|
||||||
|
position,
|
||||||
|
phone,
|
||||||
|
email,
|
||||||
|
status = 'active',
|
||||||
|
remarks
|
||||||
|
} = req.body as Record<string, any>
|
||||||
|
|
||||||
|
if (!nickname || !real_name) {
|
||||||
|
return errorResponse(res, '花名与真实姓名为必填项', 422)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id_number) {
|
||||||
|
const exists = await prisma.person.findUnique({ where: { id_number } })
|
||||||
|
if (exists) {
|
||||||
|
return errorResponse(res, '证件编号已存在', 409)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await prisma.person.create({
|
||||||
|
data: {
|
||||||
|
nickname,
|
||||||
|
real_name,
|
||||||
|
id_number,
|
||||||
|
department,
|
||||||
|
position,
|
||||||
|
phone,
|
||||||
|
email,
|
||||||
|
status,
|
||||||
|
remarks
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
successResponse(res, sanitizePerson(created), '创建成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '创建使用者失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updatePerson = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
const {
|
||||||
|
nickname,
|
||||||
|
real_name,
|
||||||
|
id_number,
|
||||||
|
department,
|
||||||
|
position,
|
||||||
|
phone,
|
||||||
|
email,
|
||||||
|
status,
|
||||||
|
remarks
|
||||||
|
} = req.body as Record<string, any>
|
||||||
|
|
||||||
|
const person = await prisma.person.findUnique({ where: { id: Number(id) } })
|
||||||
|
if (!person) {
|
||||||
|
return errorResponse(res, '使用者不存在', 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id_number && id_number !== person.id_number) {
|
||||||
|
const exists = await prisma.person.findUnique({ where: { id_number } })
|
||||||
|
if (exists) {
|
||||||
|
return errorResponse(res, '证件编号已存在', 409)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.person.update({
|
||||||
|
where: { id: Number(id) },
|
||||||
|
data: {
|
||||||
|
nickname: nickname ?? person.nickname,
|
||||||
|
real_name: real_name ?? person.real_name,
|
||||||
|
id_number: id_number ?? person.id_number,
|
||||||
|
department,
|
||||||
|
position,
|
||||||
|
phone,
|
||||||
|
email,
|
||||||
|
status: status ?? person.status,
|
||||||
|
remarks
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
successResponse(res, sanitizePerson(updated), '更新成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '更新使用者失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updatePersonStatus = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
const { status } = req.body as { status?: string }
|
||||||
|
|
||||||
|
if (!status) {
|
||||||
|
return errorResponse(res, '状态不能为空', 422)
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowed = ['active', 'inactive']
|
||||||
|
if (!allowed.includes(status)) {
|
||||||
|
return errorResponse(res, '状态值无效', 422)
|
||||||
|
}
|
||||||
|
|
||||||
|
const person = await prisma.person.findUnique({ where: { id: Number(id) } })
|
||||||
|
if (!person) {
|
||||||
|
return errorResponse(res, '使用者不存在', 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.person.update({
|
||||||
|
where: { id: Number(id) },
|
||||||
|
data: { status }
|
||||||
|
})
|
||||||
|
|
||||||
|
successResponse(res, sanitizePerson(updated), '状态更新成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '更新使用者状态失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const deletePerson = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
const personId = Number(id)
|
||||||
|
|
||||||
|
const person = await prisma.person.findUnique({ where: { id: personId } })
|
||||||
|
if (!person) {
|
||||||
|
return errorResponse(res, '使用者不存在', 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否有未归还的借用记录
|
||||||
|
const activeBorrowCount = await prisma.borrowRecord.count({
|
||||||
|
where: {
|
||||||
|
person_id: personId,
|
||||||
|
status: '已借出'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (activeBorrowCount > 0) {
|
||||||
|
return errorResponse(res, '该使用者存在未归还的借用记录,无法删除')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除历史借用记录
|
||||||
|
await prisma.borrowRecord.deleteMany({
|
||||||
|
where: { person_id: personId }
|
||||||
|
})
|
||||||
|
|
||||||
|
await prisma.person.delete({ where: { id: personId } })
|
||||||
|
|
||||||
|
successResponse(res, null, '删除成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '删除使用者失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取使用者选项列表(用于下拉选择)
|
||||||
|
export const getPersonOptions = async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const persons = await prisma.person.findMany({
|
||||||
|
where: { status: 'active' },
|
||||||
|
orderBy: { nickname: 'asc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
nickname: true,
|
||||||
|
real_name: true,
|
||||||
|
department: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
successResponse(res, persons, '获取成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '获取使用者选项失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
49
backend/src/controllers/searchController.ts
Normal file
49
backend/src/controllers/searchController.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { Request, Response } from 'express'
|
||||||
|
import prisma from '../utils/prisma'
|
||||||
|
import { successResponse, errorResponse } from '../utils/response'
|
||||||
|
|
||||||
|
// 全局搜索
|
||||||
|
export const globalSearch = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { keyword } = req.query
|
||||||
|
|
||||||
|
if (!keyword || String(keyword).trim().length === 0) {
|
||||||
|
return successResponse(res, [], '请输入搜索关键词')
|
||||||
|
}
|
||||||
|
|
||||||
|
const kw = String(keyword).trim()
|
||||||
|
|
||||||
|
const equipments = await prisma.equipment.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ equipment_code: { contains: kw } },
|
||||||
|
{ equipment_name: { contains: kw } },
|
||||||
|
{ equipment_model: { contains: kw } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
equipment_code: true,
|
||||||
|
equipment_name: true,
|
||||||
|
equipment_type: true,
|
||||||
|
status: true,
|
||||||
|
assetType: { select: { name: true } }
|
||||||
|
},
|
||||||
|
take: 10,
|
||||||
|
orderBy: { updated_at: 'desc' }
|
||||||
|
})
|
||||||
|
|
||||||
|
const results = equipments.map(eq => ({
|
||||||
|
id: eq.id,
|
||||||
|
code: eq.equipment_code,
|
||||||
|
name: eq.equipment_name,
|
||||||
|
type: eq.assetType?.name || eq.equipment_type,
|
||||||
|
status: eq.status,
|
||||||
|
category: 'equipment'
|
||||||
|
}))
|
||||||
|
|
||||||
|
successResponse(res, results, '搜索成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '搜索失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
108
backend/src/controllers/settingsController.ts
Normal file
108
backend/src/controllers/settingsController.ts
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import type { Request, Response } from 'express'
|
||||||
|
import prisma from '../utils/prisma'
|
||||||
|
import { successResponse, errorResponse } from '../utils/response'
|
||||||
|
|
||||||
|
const DEFAULT_SITE_NAME = '摄影资产借还系统'
|
||||||
|
const DEFAULT_THEME_COLOR = '#409EFF'
|
||||||
|
const DEFAULT_LOGO_URL = 'https://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png'
|
||||||
|
|
||||||
|
const mapConfigResponse = (config: any) => {
|
||||||
|
if (!config) {
|
||||||
|
return {
|
||||||
|
site_name: DEFAULT_SITE_NAME,
|
||||||
|
logo_url: DEFAULT_LOGO_URL,
|
||||||
|
favicon_url: '',
|
||||||
|
theme_color: DEFAULT_THEME_COLOR,
|
||||||
|
updated_at: null,
|
||||||
|
updated_by: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
site_name: config.site_name || DEFAULT_SITE_NAME,
|
||||||
|
logo_url: DEFAULT_LOGO_URL,
|
||||||
|
favicon_url: '',
|
||||||
|
theme_color: config.theme_color || DEFAULT_THEME_COLOR,
|
||||||
|
updated_at: config.updated_at,
|
||||||
|
updated_by: config.updatedBy
|
||||||
|
? {
|
||||||
|
id: config.updatedBy.id,
|
||||||
|
username: config.updatedBy.username,
|
||||||
|
name: config.updatedBy.name
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getSiteConfig = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const config = await prisma.siteConfig.findFirst({
|
||||||
|
orderBy: { updated_at: 'desc' },
|
||||||
|
include: {
|
||||||
|
updatedBy: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
name: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
successResponse(res, mapConfigResponse(config), '获取成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '获取站点配置失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateSiteConfig = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { site_name, theme_color } = req.body as { site_name?: string; theme_color?: string }
|
||||||
|
|
||||||
|
if (!site_name) {
|
||||||
|
return errorResponse(res, '网站名称不能为空', 422)
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.siteConfig.findFirst({
|
||||||
|
orderBy: { updated_at: 'desc' }
|
||||||
|
})
|
||||||
|
|
||||||
|
const data: any = {
|
||||||
|
site_name: site_name.trim(),
|
||||||
|
theme_color: theme_color || DEFAULT_THEME_COLOR,
|
||||||
|
updated_at: new Date(),
|
||||||
|
updated_by: res.locals.user?.id ?? null,
|
||||||
|
logo_url: DEFAULT_LOGO_URL,
|
||||||
|
favicon_url: null
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = existing
|
||||||
|
? await prisma.siteConfig.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data
|
||||||
|
})
|
||||||
|
: await prisma.siteConfig.create({
|
||||||
|
data: {
|
||||||
|
site_name: data.site_name,
|
||||||
|
theme_color: data.theme_color,
|
||||||
|
logo_url: data.logo_url,
|
||||||
|
favicon_url: data.favicon_url,
|
||||||
|
updated_by: data.updated_by
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await prisma.siteConfig.findUnique({
|
||||||
|
where: { id: result.id },
|
||||||
|
include: {
|
||||||
|
updatedBy: {
|
||||||
|
select: { id: true, username: true, name: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
successResponse(res, mapConfigResponse(response), '保存成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '更新站点配置失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
202
backend/src/controllers/statisticsController.ts
Normal file
202
backend/src/controllers/statisticsController.ts
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
import { Request, Response } from 'express'
|
||||||
|
import prisma from '../utils/prisma'
|
||||||
|
import { successResponse, errorResponse } from '../utils/response'
|
||||||
|
|
||||||
|
// 获取首页统计数据
|
||||||
|
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(),
|
||||||
|
prisma.borrowRecord.count({
|
||||||
|
where: {
|
||||||
|
borrow_time: {
|
||||||
|
gte: startOfToday
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
prisma.borrowRecord.count({
|
||||||
|
where: {
|
||||||
|
return_time: {
|
||||||
|
gte: startOfToday
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
])
|
||||||
|
|
||||||
|
const totalEquipmentQuantity = equipmentList.reduce((sum, eq) => sum + (eq.total_quantity || 0), 0)
|
||||||
|
const availableEquipmentQuantity = equipmentList.reduce(
|
||||||
|
(sum, eq) => sum + (eq.remaining_quantity || 0),
|
||||||
|
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 usageRate =
|
||||||
|
totalEquipmentQuantity > 0 ? (borrowedEquipmentQuantity / totalEquipmentQuantity) * 100 : 0
|
||||||
|
const valueUsageRate = totalValue > 0 ? (borrowedValue / totalValue) * 100 : 0
|
||||||
|
|
||||||
|
successResponse(res, {
|
||||||
|
total_equipment: totalEquipmentQuantity,
|
||||||
|
available_equipment: availableEquipmentQuantity,
|
||||||
|
borrowed_equipment: borrowedEquipmentQuantity,
|
||||||
|
maintenance_equipment: maintenanceEquipmentQuantity,
|
||||||
|
usage_rate: usageRate,
|
||||||
|
today_borrow: todayBorrowCount,
|
||||||
|
today_return: todayReturnCount,
|
||||||
|
total_value: totalValue,
|
||||||
|
borrowed_value: borrowedValue,
|
||||||
|
available_value: availableValue,
|
||||||
|
maintenance_value: maintenanceValue,
|
||||||
|
value_usage_rate: valueUsageRate,
|
||||||
|
today_borrow_value: todayBorrowValue,
|
||||||
|
today_return_value: todayReturnValue,
|
||||||
|
inventory_by_type: inventoryByType
|
||||||
|
}, '获取成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '获取统计数据失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取资产价值统计
|
||||||
|
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 valueByType = ['相机', '镜头', '三脚架', '闪光灯', '摄影配饰品'].map(type => {
|
||||||
|
const typeEquipment = equipmentList.filter(eq => eq.equipment_type === type)
|
||||||
|
return {
|
||||||
|
equipment_type: type,
|
||||||
|
total_value: typeEquipment.reduce((sum, eq) => sum + Number(eq.total_value), 0),
|
||||||
|
borrowed_value: typeEquipment.reduce((sum, eq) => sum + Number(eq.borrowed_value), 0),
|
||||||
|
available_value: typeEquipment.reduce((sum, eq) => sum + Number(eq.available_value), 0)
|
||||||
|
}
|
||||||
|
}).filter(item => item.total_value > 0)
|
||||||
|
|
||||||
|
// 价值最高的资产(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)
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.total_value - a.total_value)
|
||||||
|
.slice(0, 10)
|
||||||
|
|
||||||
|
successResponse(res, {
|
||||||
|
total_value: totalValue,
|
||||||
|
borrowed_value: borrowedValue,
|
||||||
|
available_value: availableValue,
|
||||||
|
maintenance_value: maintenanceValue,
|
||||||
|
value_by_type: valueByType,
|
||||||
|
top_equipment: topEquipment
|
||||||
|
}, '获取成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '获取价值统计失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
166
backend/src/controllers/storageLocationController.ts
Normal file
166
backend/src/controllers/storageLocationController.ts
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
import { Request, Response } from 'express'
|
||||||
|
import prisma from '../utils/prisma'
|
||||||
|
import { successResponse, errorResponse } from '../utils/response'
|
||||||
|
|
||||||
|
const baseSelect = {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
code: true,
|
||||||
|
status: true,
|
||||||
|
description: true,
|
||||||
|
created_at: true,
|
||||||
|
updated_at: true
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getStorageLocationOptions = async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const locations = await prisma.storageLocation.findMany({
|
||||||
|
where: { status: 'active' },
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
select: baseSelect
|
||||||
|
})
|
||||||
|
|
||||||
|
successResponse(res, locations, '获取成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '获取地址列表失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getStorageLocationList = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { page = 1, pageSize = 10, keyword, status } = req.query
|
||||||
|
|
||||||
|
const where: any = {}
|
||||||
|
if (status) {
|
||||||
|
where.status = status
|
||||||
|
}
|
||||||
|
if (keyword) {
|
||||||
|
where.OR = [
|
||||||
|
{ name: { contains: keyword as string } },
|
||||||
|
{ code: { contains: keyword as string } },
|
||||||
|
{ description: { contains: keyword as string } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const skip = (Number(page) - 1) * Number(pageSize)
|
||||||
|
|
||||||
|
const [list, total] = await Promise.all([
|
||||||
|
prisma.storageLocation.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: Number(pageSize),
|
||||||
|
orderBy: { updated_at: 'desc' },
|
||||||
|
select: {
|
||||||
|
...baseSelect,
|
||||||
|
_count: {
|
||||||
|
select: { equipments: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
prisma.storageLocation.count({ where })
|
||||||
|
])
|
||||||
|
|
||||||
|
successResponse(
|
||||||
|
res,
|
||||||
|
{
|
||||||
|
list,
|
||||||
|
total,
|
||||||
|
page: Number(page),
|
||||||
|
pageSize: Number(pageSize)
|
||||||
|
},
|
||||||
|
'获取成功'
|
||||||
|
)
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '获取地址列表失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createStorageLocation = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { name, code, description } = req.body as {
|
||||||
|
name?: string
|
||||||
|
code?: string
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!name || !name.trim()) {
|
||||||
|
return errorResponse(res, '地址名称不能为空', 422)
|
||||||
|
}
|
||||||
|
|
||||||
|
const location = await prisma.storageLocation.create({
|
||||||
|
data: {
|
||||||
|
name: name.trim(),
|
||||||
|
code: code?.trim() || null,
|
||||||
|
description: description?.trim() || null
|
||||||
|
},
|
||||||
|
select: baseSelect
|
||||||
|
})
|
||||||
|
|
||||||
|
successResponse(res, location, '新增地址成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.code === 'P2002') {
|
||||||
|
return errorResponse(res, '地址编码已存在')
|
||||||
|
}
|
||||||
|
errorResponse(res, error.message || '新增地址失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateStorageLocation = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
const { name, code, description, status } = req.body as {
|
||||||
|
name?: string
|
||||||
|
code?: string
|
||||||
|
description?: string
|
||||||
|
status?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.storageLocation.findUnique({
|
||||||
|
where: { id: Number(id) }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return errorResponse(res, '地址不存在', 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.storageLocation.update({
|
||||||
|
where: { id: Number(id) },
|
||||||
|
data: {
|
||||||
|
name: name?.trim() || existing.name,
|
||||||
|
code: code?.trim() || null,
|
||||||
|
description: description?.trim() || null,
|
||||||
|
status: status || existing.status
|
||||||
|
},
|
||||||
|
select: baseSelect
|
||||||
|
})
|
||||||
|
|
||||||
|
successResponse(res, updated, '更新地址成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.code === 'P2002') {
|
||||||
|
return errorResponse(res, '地址编码已存在')
|
||||||
|
}
|
||||||
|
errorResponse(res, error.message || '更新地址失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateStorageLocationStatus = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
const { status } = req.body as { status?: string }
|
||||||
|
|
||||||
|
if (!status) {
|
||||||
|
return errorResponse(res, '状态不能为空', 422)
|
||||||
|
}
|
||||||
|
|
||||||
|
const location = await prisma.storageLocation.update({
|
||||||
|
where: { id: Number(id) },
|
||||||
|
data: { status },
|
||||||
|
select: baseSelect
|
||||||
|
})
|
||||||
|
|
||||||
|
successResponse(res, location, '更新状态成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '更新状态失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
225
backend/src/controllers/userController.ts
Normal file
225
backend/src/controllers/userController.ts
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
import { Request, Response } from 'express'
|
||||||
|
import bcrypt from 'bcryptjs'
|
||||||
|
import prisma from '../utils/prisma'
|
||||||
|
import { successResponse, errorResponse } from '../utils/response'
|
||||||
|
|
||||||
|
const SALT_ROUNDS = 10
|
||||||
|
|
||||||
|
const sanitizeUser = (user: any) => ({
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
phone: user.phone,
|
||||||
|
role: user.role,
|
||||||
|
roles: [user.role],
|
||||||
|
status: user.status,
|
||||||
|
last_login_at: user.last_login_at,
|
||||||
|
created_at: user.created_at,
|
||||||
|
updated_at: user.updated_at
|
||||||
|
})
|
||||||
|
|
||||||
|
export const getUserList = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { page = 1, pageSize = 10, keyword, role, status } = req.query
|
||||||
|
|
||||||
|
const where: any = {}
|
||||||
|
|
||||||
|
if (keyword) {
|
||||||
|
where.OR = [
|
||||||
|
{ username: { contains: keyword as string } },
|
||||||
|
{ name: { contains: keyword as string } },
|
||||||
|
{ email: { contains: keyword as string } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role) {
|
||||||
|
where.role = role
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
where.status = status
|
||||||
|
}
|
||||||
|
|
||||||
|
const skip = (Number(page) - 1) * Number(pageSize)
|
||||||
|
|
||||||
|
const [list, total] = await Promise.all([
|
||||||
|
prisma.user.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: Number(pageSize),
|
||||||
|
orderBy: { created_at: 'desc' }
|
||||||
|
}),
|
||||||
|
prisma.user.count({ where })
|
||||||
|
])
|
||||||
|
|
||||||
|
successResponse(
|
||||||
|
res,
|
||||||
|
{
|
||||||
|
list: list.map(sanitizeUser),
|
||||||
|
total,
|
||||||
|
page: Number(page),
|
||||||
|
pageSize: Number(pageSize)
|
||||||
|
},
|
||||||
|
'获取成功'
|
||||||
|
)
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '获取用户列表失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createUser = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { username, password, name, email, phone, roles, role, status } = req.body as Record<
|
||||||
|
string,
|
||||||
|
any
|
||||||
|
>
|
||||||
|
|
||||||
|
if (!username || !password || !name) {
|
||||||
|
return errorResponse(res, '用户名、姓名和密码为必填项', 422)
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.user.findUnique({ where: { username } })
|
||||||
|
if (existing) {
|
||||||
|
return errorResponse(res, '用户名已存在', 409)
|
||||||
|
}
|
||||||
|
|
||||||
|
const primaryRole =
|
||||||
|
(Array.isArray(roles) ? roles[0] : roles) || role || (username === 'admin' ? 'admin' : 'user')
|
||||||
|
|
||||||
|
const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS)
|
||||||
|
|
||||||
|
const created = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
username,
|
||||||
|
password: hashedPassword,
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
|
role: primaryRole,
|
||||||
|
status: status || 'active'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
successResponse(res, sanitizeUser(created), '创建成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '创建用户失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateUser = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
const { name, email, phone, roles, role, status } = req.body as Record<string, any>
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: Number(id) } })
|
||||||
|
if (!user) {
|
||||||
|
return errorResponse(res, '用户不存在', 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: any = {
|
||||||
|
name: name ?? user.name,
|
||||||
|
email: email ?? user.email,
|
||||||
|
phone: phone ?? user.phone
|
||||||
|
}
|
||||||
|
|
||||||
|
const newRole = (Array.isArray(roles) ? roles[0] : roles) || role
|
||||||
|
if (newRole) {
|
||||||
|
updateData.role = newRole
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
updateData.status = status
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.user.update({
|
||||||
|
where: { id: Number(id) },
|
||||||
|
data: updateData
|
||||||
|
})
|
||||||
|
|
||||||
|
successResponse(res, sanitizeUser(updated), '更新成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '更新用户失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const resetPassword = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
const { new_password } = req.body as { new_password?: string }
|
||||||
|
|
||||||
|
if (!new_password) {
|
||||||
|
return errorResponse(res, '新密码不能为空', 422)
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: Number(id) } })
|
||||||
|
if (!user) {
|
||||||
|
return errorResponse(res, '用户不存在', 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashedPassword = await bcrypt.hash(new_password, SALT_ROUNDS)
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: Number(id) },
|
||||||
|
data: { password: hashedPassword }
|
||||||
|
})
|
||||||
|
|
||||||
|
successResponse(res, null, '密码重置成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '重置密码失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateUserStatus = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
const { status } = req.body as { status?: string }
|
||||||
|
|
||||||
|
if (!status) {
|
||||||
|
return errorResponse(res, '状态不能为空', 422)
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: Number(id) } })
|
||||||
|
if (!user) {
|
||||||
|
return errorResponse(res, '用户不存在', 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.user.update({
|
||||||
|
where: { id: Number(id) },
|
||||||
|
data: { status }
|
||||||
|
})
|
||||||
|
|
||||||
|
successResponse(res, sanitizeUser(updated), '状态更新成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '更新用户状态失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const deleteUser = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
const userId = Number(id)
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: userId } })
|
||||||
|
if (!user) {
|
||||||
|
return errorResponse(res, '用户不存在', 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不允许删除 admin 用户
|
||||||
|
if (user.username === 'admin') {
|
||||||
|
return errorResponse(res, '不能删除管理员账户')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除关联的操作日志
|
||||||
|
await prisma.operationLog.deleteMany({
|
||||||
|
where: { user_id: userId }
|
||||||
|
})
|
||||||
|
|
||||||
|
await prisma.user.delete({ where: { id: userId } })
|
||||||
|
|
||||||
|
successResponse(res, null, '删除成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error.message || '删除用户失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
98
backend/src/index.ts
Normal file
98
backend/src/index.ts
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import path from 'path'
|
||||||
|
import express from 'express'
|
||||||
|
import cors from 'cors'
|
||||||
|
import dotenv from 'dotenv'
|
||||||
|
import session from 'express-session'
|
||||||
|
import equipmentRoutes from './routes/equipment'
|
||||||
|
import { scanEquipment } from './controllers/equipmentController'
|
||||||
|
import borrowRoutes, { returnRouter } from './routes/borrow'
|
||||||
|
import statisticsRoutes from './routes/statistics'
|
||||||
|
import authRoutes from './routes/auth'
|
||||||
|
import barcodeRoutes from './routes/barcode'
|
||||||
|
import settingsRoutes from './routes/settings'
|
||||||
|
import usersRoutes from './routes/users'
|
||||||
|
import personRoutes from './routes/persons'
|
||||||
|
import storageLocationRoutes from './routes/storageLocations'
|
||||||
|
import assetTypeRoutes from './routes/assetTypes'
|
||||||
|
import departmentRoutes from './routes/departments'
|
||||||
|
import operationLogRoutes from './routes/operationLog'
|
||||||
|
import batchImportRoutes from './routes/batchImport'
|
||||||
|
import mountRoutes from './routes/mount'
|
||||||
|
import disposalRoutes from './routes/disposal'
|
||||||
|
import maintenanceRoutes from './routes/maintenance'
|
||||||
|
import batchRoutes from './routes/batch'
|
||||||
|
import exportRoutes from './routes/export'
|
||||||
|
import searchRoutes from './routes/search'
|
||||||
|
import { keycloak, memoryStore } from './utils/keycloak'
|
||||||
|
import { protect, requireAdmin } from './middleware/auth'
|
||||||
|
|
||||||
|
dotenv.config()
|
||||||
|
|
||||||
|
const app = express()
|
||||||
|
const PORT = process.env.PORT || 3000
|
||||||
|
const SESSION_SECRET = process.env.SESSION_SECRET || 'equipment-management-secret'
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
app.use(cors({ origin: true, credentials: true }))
|
||||||
|
app.use(express.json())
|
||||||
|
app.use(express.urlencoded({ extended: true }))
|
||||||
|
app.use(
|
||||||
|
session({
|
||||||
|
secret: SESSION_SECRET,
|
||||||
|
resave: false,
|
||||||
|
saveUninitialized: false,
|
||||||
|
store: memoryStore,
|
||||||
|
cookie: {
|
||||||
|
secure: false,
|
||||||
|
maxAge: 1000 * 60 * 60 // 1 hour
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
if (process.env.AUTH_MODE !== 'local') {
|
||||||
|
app.use(keycloak.middleware({ logout: '/api/auth/logout', admin: '/keycloak' }))
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use('/uploads', express.static(path.resolve(__dirname, '..', 'uploads')))
|
||||||
|
|
||||||
|
// Routes
|
||||||
|
app.get('/api/equipment/scan/:equipment_code', protect(), scanEquipment)
|
||||||
|
app.use('/api/equipment', protect(), requireAdmin(), equipmentRoutes)
|
||||||
|
app.use('/api/borrow', protect(), borrowRoutes)
|
||||||
|
app.use('/api/return', protect(), returnRouter)
|
||||||
|
app.use('/api/statistics', protect(), statisticsRoutes)
|
||||||
|
app.use('/api/barcode', protect(), barcodeRoutes)
|
||||||
|
app.use('/api/auth', authRoutes)
|
||||||
|
app.use('/api/settings', protect(), requireAdmin(), settingsRoutes)
|
||||||
|
app.use('/api/users', protect(), requireAdmin(), usersRoutes)
|
||||||
|
app.use('/api/persons', personRoutes)
|
||||||
|
app.use('/api/storage-locations', storageLocationRoutes)
|
||||||
|
app.use('/api/asset-types', assetTypeRoutes)
|
||||||
|
app.use('/api/departments', departmentRoutes)
|
||||||
|
app.use('/api/operation-logs', operationLogRoutes)
|
||||||
|
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/export', protect(), exportRoutes)
|
||||||
|
app.use('/api/search', protect(), searchRoutes)
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
app.get('/api/health', (req, res) => {
|
||||||
|
res.json({ status: 'ok', message: 'Server is running' })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Error handling middleware
|
||||||
|
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
|
console.error('Error:', err)
|
||||||
|
res.status(err.status || 500).json({
|
||||||
|
code: err.status || 500,
|
||||||
|
message: err.message || 'Internal Server Error',
|
||||||
|
data: null
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`Server is running on http://localhost:${PORT}`)
|
||||||
|
})
|
||||||
|
|
||||||
58
backend/src/middleware/auth.ts
Normal file
58
backend/src/middleware/auth.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import type { Request, Response, NextFunction, RequestHandler } from 'express'
|
||||||
|
import { keycloak } from '../utils/keycloak'
|
||||||
|
|
||||||
|
const isLocalAuth = () => process.env.AUTH_MODE === 'local'
|
||||||
|
|
||||||
|
// 选择保护策略:local 使用会话校验;默认使用 Keycloak 保护
|
||||||
|
export function protect(): RequestHandler {
|
||||||
|
if (isLocalAuth()) {
|
||||||
|
return function localSessionProtect(req: Request, res: Response, next: NextFunction) {
|
||||||
|
const sessionUser = (req as any).session?.user
|
||||||
|
if (sessionUser) {
|
||||||
|
res.locals.user = sessionUser
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
return res.status(401).json({ code: 401, message: '未登录', data: null })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const keycloakMiddleware = keycloak.protect()
|
||||||
|
|
||||||
|
return function keycloakWrapper(req: Request, res: Response, next: NextFunction) {
|
||||||
|
keycloakMiddleware(req, res, (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 || []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireAdmin(): RequestHandler {
|
||||||
|
return function adminGuard(req: Request, res: Response, next: NextFunction) {
|
||||||
|
if (isLocalAuth()) {
|
||||||
|
const sessionUser = (req as any).session?.user
|
||||||
|
if (sessionUser && (sessionUser.role === 'admin' || sessionUser.roles?.includes('admin'))) {
|
||||||
|
res.locals.user = sessionUser
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
return res.status(403).json({ code: 403, message: '权限不足', data: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
const roles: string[] =
|
||||||
|
((req as any).kauth?.grant?.access_token?.content?.realm_access?.roles as string[]) || []
|
||||||
|
if (roles.includes('admin')) {
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
return res.status(403).json({ code: 403, message: '权限不足', data: null })
|
||||||
|
}
|
||||||
|
}
|
||||||
19
backend/src/routes/assetTypes.ts
Normal file
19
backend/src/routes/assetTypes.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import {
|
||||||
|
getAssetTypeOptions,
|
||||||
|
getAssetTypeList,
|
||||||
|
createAssetType,
|
||||||
|
updateAssetType,
|
||||||
|
deleteAssetType
|
||||||
|
} from '../controllers/assetTypeController'
|
||||||
|
import { protect, requireAdmin } from '../middleware/auth'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
router.get('/options', protect(), getAssetTypeOptions)
|
||||||
|
router.get('/list', protect(), requireAdmin(), getAssetTypeList)
|
||||||
|
router.post('/', protect(), requireAdmin(), createAssetType)
|
||||||
|
router.put('/:id', protect(), requireAdmin(), updateAssetType)
|
||||||
|
router.delete('/:id', protect(), requireAdmin(), deleteAssetType)
|
||||||
|
|
||||||
|
export default router
|
||||||
117
backend/src/routes/auth.ts
Normal file
117
backend/src/routes/auth.ts
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import { successResponse, errorResponse } from '../utils/response'
|
||||||
|
import { keycloak } from '../utils/keycloak'
|
||||||
|
import type { Request, Response } from 'express'
|
||||||
|
import bcrypt from 'bcryptjs'
|
||||||
|
import prisma from '../utils/prisma'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
// 获取当前用户信息
|
||||||
|
router.get('/user', (req, res) => {
|
||||||
|
if (process.env.AUTH_MODE === 'local') {
|
||||||
|
const user = (req as any).session?.user
|
||||||
|
if (!user) {
|
||||||
|
return errorResponse(res, '未登录', 401)
|
||||||
|
}
|
||||||
|
return successResponse(res, user, '获取成功')
|
||||||
|
}
|
||||||
|
const grant = (req as any).kauth?.grant
|
||||||
|
const token = grant?.access_token
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return errorResponse(res, '无法获取用户信息', 401)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenContent = token.content as Record<string, any>
|
||||||
|
|
||||||
|
successResponse(res, {
|
||||||
|
id: tokenContent.sub,
|
||||||
|
username: tokenContent.preferred_username,
|
||||||
|
name: tokenContent.name || tokenContent.preferred_username,
|
||||||
|
email: tokenContent.email,
|
||||||
|
roles: tokenContent.realm_access?.roles || []
|
||||||
|
}, '获取成功')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Keycloak 配置信息(前端使用)
|
||||||
|
router.get('/config', (req, res) => {
|
||||||
|
successResponse(res, {
|
||||||
|
url: process.env.KEYCLOAK_URL || 'http://localhost:8080',
|
||||||
|
realm: process.env.KEYCLOAK_REALM || 'equipment-management',
|
||||||
|
clientId: process.env.KEYCLOAK_CLIENT_ID || 'equipment-client'
|
||||||
|
}, '获取成功')
|
||||||
|
})
|
||||||
|
|
||||||
|
// 退出登录
|
||||||
|
router.post('/logout', async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (process.env.AUTH_MODE === 'local') {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
req.session?.destroy((err) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return successResponse(res, null, '退出成功')
|
||||||
|
}
|
||||||
|
// Keycloak 模式下仅返回成功,由前端触发 keycloak.logout()
|
||||||
|
return successResponse(res, null, '退出成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
errorResponse(res, error?.message || '退出失败')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 本地登录模式(不跳转 Keycloak)
|
||||||
|
router.post('/local/login', async (req: Request, res: Response) => {
|
||||||
|
if (process.env.AUTH_MODE !== 'local') {
|
||||||
|
return errorResponse(res, '本地登录未启用', 400)
|
||||||
|
}
|
||||||
|
const { username, password } = req.body as { username?: string; password?: string }
|
||||||
|
if (!username || !password) {
|
||||||
|
return errorResponse(res, '用户名与密码必填', 422)
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { username } })
|
||||||
|
if (!user || user.status !== 'active') {
|
||||||
|
return errorResponse(res, '用户名或密码错误', 401)
|
||||||
|
}
|
||||||
|
|
||||||
|
const validPassword = await bcrypt.compare(password, user.password)
|
||||||
|
if (!validPassword) {
|
||||||
|
return errorResponse(res, '用户名或密码错误', 401)
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { last_login_at: new Date() }
|
||||||
|
})
|
||||||
|
|
||||||
|
const userPayload = {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
phone: user.phone,
|
||||||
|
role: user.role,
|
||||||
|
roles: [user.role],
|
||||||
|
status: user.status
|
||||||
|
}
|
||||||
|
|
||||||
|
;(req as any).session.user = userPayload
|
||||||
|
|
||||||
|
return successResponse(
|
||||||
|
res,
|
||||||
|
{
|
||||||
|
token: 'session',
|
||||||
|
user: userPayload
|
||||||
|
},
|
||||||
|
'登录成功'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
|
|
||||||
13
backend/src/routes/barcode.ts
Normal file
13
backend/src/routes/barcode.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import {
|
||||||
|
getEquipmentForBarcode,
|
||||||
|
getEquipmentBatchForBarcode
|
||||||
|
} from '../controllers/barcodeController'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
router.get('/:equipment_code', getEquipmentForBarcode)
|
||||||
|
router.post('/batch', getEquipmentBatchForBarcode)
|
||||||
|
|
||||||
|
export default router
|
||||||
|
|
||||||
10
backend/src/routes/batch.ts
Normal file
10
backend/src/routes/batch.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import { batchBorrow, batchReturn, batchTransfer } from '../controllers/batchController'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
router.post('/borrow', batchBorrow)
|
||||||
|
router.post('/return', batchReturn)
|
||||||
|
router.post('/transfer', batchTransfer)
|
||||||
|
|
||||||
|
export default router
|
||||||
13
backend/src/routes/batchImport.ts
Normal file
13
backend/src/routes/batchImport.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import { batchImportPersons, batchImportEquipments } from '../controllers/batchImportController'
|
||||||
|
import { protect, requireAdmin } from '../middleware/auth'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
router.use(protect())
|
||||||
|
router.use(requireAdmin())
|
||||||
|
|
||||||
|
router.post('/persons', batchImportPersons)
|
||||||
|
router.post('/equipments', batchImportEquipments)
|
||||||
|
|
||||||
|
export default router
|
||||||
32
backend/src/routes/borrow.ts
Normal file
32
backend/src/routes/borrow.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import {
|
||||||
|
borrowEquipment,
|
||||||
|
scanBorrow,
|
||||||
|
scanBorrowBatch,
|
||||||
|
getBorrowRecordByCode,
|
||||||
|
getBorrowRecordList
|
||||||
|
} from '../controllers/borrowController'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
// 借用相关
|
||||||
|
router.post('/', borrowEquipment)
|
||||||
|
router.post('/scan', scanBorrow)
|
||||||
|
router.post('/scan-batch', scanBorrowBatch)
|
||||||
|
router.get('/scan/:equipment_code', getBorrowRecordByCode)
|
||||||
|
router.get('/records', getBorrowRecordList)
|
||||||
|
|
||||||
|
export default router
|
||||||
|
|
||||||
|
// 归还路由单独导出
|
||||||
|
export const returnRouter = Router()
|
||||||
|
|
||||||
|
import {
|
||||||
|
returnEquipment,
|
||||||
|
scanReturn,
|
||||||
|
scanReturnBatch
|
||||||
|
} from '../controllers/borrowController'
|
||||||
|
|
||||||
|
returnRouter.post('/', returnEquipment)
|
||||||
|
returnRouter.post('/scan', scanReturn)
|
||||||
|
returnRouter.post('/scan-batch', scanReturnBatch)
|
||||||
21
backend/src/routes/departments.ts
Normal file
21
backend/src/routes/departments.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import {
|
||||||
|
getDepartmentOptions,
|
||||||
|
getDepartmentTree,
|
||||||
|
getDepartmentList,
|
||||||
|
createDepartment,
|
||||||
|
updateDepartment,
|
||||||
|
deleteDepartment
|
||||||
|
} from '../controllers/departmentController'
|
||||||
|
import { protect, requireAdmin } from '../middleware/auth'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
router.get('/options', protect(), getDepartmentOptions)
|
||||||
|
router.get('/tree', protect(), getDepartmentTree)
|
||||||
|
router.get('/list', protect(), requireAdmin(), getDepartmentList)
|
||||||
|
router.post('/', protect(), requireAdmin(), createDepartment)
|
||||||
|
router.put('/:id', protect(), requireAdmin(), updateDepartment)
|
||||||
|
router.delete('/:id', protect(), requireAdmin(), deleteDepartment)
|
||||||
|
|
||||||
|
export default router
|
||||||
20
backend/src/routes/disposal.ts
Normal file
20
backend/src/routes/disposal.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import {
|
||||||
|
createDisposal,
|
||||||
|
getDisposalList,
|
||||||
|
getDisposalDetail,
|
||||||
|
approveDisposal,
|
||||||
|
completeDisposal,
|
||||||
|
getEquipmentDisposalHistory
|
||||||
|
} from '../controllers/disposalController'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
router.post('/', createDisposal)
|
||||||
|
router.get('/list', getDisposalList)
|
||||||
|
router.get('/:id', getDisposalDetail)
|
||||||
|
router.put('/:id/approve', approveDisposal)
|
||||||
|
router.put('/:id/complete', completeDisposal)
|
||||||
|
router.get('/equipment/:equipment_id/history', getEquipmentDisposalHistory)
|
||||||
|
|
||||||
|
export default router
|
||||||
29
backend/src/routes/equipment.ts
Normal file
29
backend/src/routes/equipment.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import {
|
||||||
|
getEquipmentList,
|
||||||
|
getEquipmentDetail,
|
||||||
|
createEquipment,
|
||||||
|
updateEquipment,
|
||||||
|
deleteEquipment,
|
||||||
|
generateCode,
|
||||||
|
verifyCode,
|
||||||
|
scanEquipment
|
||||||
|
} from '../controllers/equipmentController'
|
||||||
|
import { requireAdmin } from '../middleware/auth'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
router.get('/scan/:equipment_code', scanEquipment)
|
||||||
|
|
||||||
|
router.use(requireAdmin())
|
||||||
|
|
||||||
|
router.get('/list', getEquipmentList)
|
||||||
|
router.get('/:id', getEquipmentDetail)
|
||||||
|
router.post('/', createEquipment)
|
||||||
|
router.put('/:id', updateEquipment)
|
||||||
|
router.delete('/:id', deleteEquipment)
|
||||||
|
router.post('/generate-code', generateCode)
|
||||||
|
router.post('/verify-code', verifyCode)
|
||||||
|
|
||||||
|
export default router
|
||||||
|
|
||||||
11
backend/src/routes/export.ts
Normal file
11
backend/src/routes/export.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import { exportEquipment, exportBorrowRecords, generateImportTemplate, generatePersonTemplate } from '../controllers/exportController'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
router.get('/equipment', exportEquipment)
|
||||||
|
router.get('/borrow-records', exportBorrowRecords)
|
||||||
|
router.get('/template', generateImportTemplate)
|
||||||
|
router.get('/person-template', generatePersonTemplate)
|
||||||
|
|
||||||
|
export default router
|
||||||
22
backend/src/routes/maintenance.ts
Normal file
22
backend/src/routes/maintenance.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import {
|
||||||
|
createMaintenance,
|
||||||
|
getMaintenanceList,
|
||||||
|
getMaintenanceDetail,
|
||||||
|
updateMaintenance,
|
||||||
|
completeMaintenance,
|
||||||
|
cancelMaintenance,
|
||||||
|
getEquipmentMaintenanceHistory
|
||||||
|
} from '../controllers/maintenanceController'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
router.post('/', createMaintenance)
|
||||||
|
router.get('/list', getMaintenanceList)
|
||||||
|
router.get('/:id', getMaintenanceDetail)
|
||||||
|
router.put('/:id', updateMaintenance)
|
||||||
|
router.put('/:id/complete', completeMaintenance)
|
||||||
|
router.put('/:id/cancel', cancelMaintenance)
|
||||||
|
router.get('/equipment/:equipment_id/history', getEquipmentMaintenanceHistory)
|
||||||
|
|
||||||
|
export default router
|
||||||
31
backend/src/routes/mount.ts
Normal file
31
backend/src/routes/mount.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import {
|
||||||
|
mountChild,
|
||||||
|
unmountChild,
|
||||||
|
replaceChild,
|
||||||
|
getChildren,
|
||||||
|
getMountHistory,
|
||||||
|
getAvailableChildren
|
||||||
|
} from '../controllers/mountController'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
// 获取可挂载的子资产列表
|
||||||
|
router.get('/available-children', getAvailableChildren)
|
||||||
|
|
||||||
|
// 获取资产的挂载历史
|
||||||
|
router.get('/:id/mount-history', getMountHistory)
|
||||||
|
|
||||||
|
// 获取父资产的子资产列表
|
||||||
|
router.get('/:parentId/children', getChildren)
|
||||||
|
|
||||||
|
// 挂载子资产到父资产
|
||||||
|
router.post('/:parentId/mount', mountChild)
|
||||||
|
|
||||||
|
// 从父资产卸载子资产
|
||||||
|
router.post('/:parentId/unmount', unmountChild)
|
||||||
|
|
||||||
|
// 更换配件
|
||||||
|
router.post('/:parentId/replace', replaceChild)
|
||||||
|
|
||||||
|
export default router
|
||||||
13
backend/src/routes/operationLog.ts
Normal file
13
backend/src/routes/operationLog.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import { getOperationLogList, getOperationTypes } from '../controllers/operationLogController'
|
||||||
|
import { protect, requireAdmin } from '../middleware/auth'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
router.use(protect())
|
||||||
|
router.use(requireAdmin())
|
||||||
|
|
||||||
|
router.get('/list', getOperationLogList)
|
||||||
|
router.get('/types', getOperationTypes)
|
||||||
|
|
||||||
|
export default router
|
||||||
27
backend/src/routes/persons.ts
Normal file
27
backend/src/routes/persons.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import {
|
||||||
|
getPersonList,
|
||||||
|
getPersonOptions,
|
||||||
|
createPerson,
|
||||||
|
updatePerson,
|
||||||
|
updatePersonStatus,
|
||||||
|
getPersonDetail,
|
||||||
|
deletePerson
|
||||||
|
} from '../controllers/personController'
|
||||||
|
import { protect, requireAdmin } from '../middleware/auth'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
router.use(protect())
|
||||||
|
|
||||||
|
router.get('/', getPersonList)
|
||||||
|
router.get('/options', getPersonOptions)
|
||||||
|
router.get('/:id', getPersonDetail)
|
||||||
|
|
||||||
|
router.post('/', requireAdmin(), createPerson)
|
||||||
|
router.put('/:id', requireAdmin(), updatePerson)
|
||||||
|
router.patch('/:id/status', requireAdmin(), updatePersonStatus)
|
||||||
|
router.delete('/:id', requireAdmin(), deletePerson)
|
||||||
|
|
||||||
|
export default router
|
||||||
|
|
||||||
8
backend/src/routes/search.ts
Normal file
8
backend/src/routes/search.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import { globalSearch } from '../controllers/searchController'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
router.get('/', globalSearch)
|
||||||
|
|
||||||
|
export default router
|
||||||
17
backend/src/routes/settings.ts
Normal file
17
backend/src/routes/settings.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import { getSiteConfig, updateSiteConfig } from '../controllers/settingsController'
|
||||||
|
import { protect, requireAdmin } from '../middleware/auth'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
router.get('/site', getSiteConfig)
|
||||||
|
|
||||||
|
router.put(
|
||||||
|
'/site',
|
||||||
|
protect(),
|
||||||
|
requireAdmin(),
|
||||||
|
updateSiteConfig
|
||||||
|
)
|
||||||
|
|
||||||
|
export default router
|
||||||
|
|
||||||
13
backend/src/routes/statistics.ts
Normal file
13
backend/src/routes/statistics.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import {
|
||||||
|
getDashboardStats,
|
||||||
|
getValueStatistics
|
||||||
|
} from '../controllers/statisticsController'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
router.get('/dashboard', getDashboardStats)
|
||||||
|
router.get('/value', getValueStatistics)
|
||||||
|
|
||||||
|
export default router
|
||||||
|
|
||||||
20
backend/src/routes/storageLocations.ts
Normal file
20
backend/src/routes/storageLocations.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import {
|
||||||
|
getStorageLocationOptions,
|
||||||
|
getStorageLocationList,
|
||||||
|
createStorageLocation,
|
||||||
|
updateStorageLocation,
|
||||||
|
updateStorageLocationStatus
|
||||||
|
} from '../controllers/storageLocationController'
|
||||||
|
import { protect, requireAdmin } from '../middleware/auth'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
router.get('/options', protect(), getStorageLocationOptions)
|
||||||
|
router.get('/list', protect(), requireAdmin(), getStorageLocationList)
|
||||||
|
router.post('/', protect(), requireAdmin(), createStorageLocation)
|
||||||
|
router.put('/:id', protect(), requireAdmin(), updateStorageLocation)
|
||||||
|
router.patch('/:id/status', protect(), requireAdmin(), updateStorageLocationStatus)
|
||||||
|
|
||||||
|
export default router
|
||||||
|
|
||||||
21
backend/src/routes/users.ts
Normal file
21
backend/src/routes/users.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import {
|
||||||
|
getUserList,
|
||||||
|
createUser,
|
||||||
|
updateUser,
|
||||||
|
resetPassword,
|
||||||
|
updateUserStatus,
|
||||||
|
deleteUser
|
||||||
|
} from '../controllers/userController'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
router.get('/', getUserList)
|
||||||
|
router.post('/', createUser)
|
||||||
|
router.put('/:id', updateUser)
|
||||||
|
router.post('/:id/reset-password', resetPassword)
|
||||||
|
router.patch('/:id/status', updateUserStatus)
|
||||||
|
router.delete('/:id', deleteUser)
|
||||||
|
|
||||||
|
export default router
|
||||||
|
|
||||||
42
backend/src/types/express-session.d.ts
vendored
Normal file
42
backend/src/types/express-session.d.ts
vendored
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
declare module 'express-session' {
|
||||||
|
import { RequestHandler } from 'express'
|
||||||
|
|
||||||
|
interface SessionOptions {
|
||||||
|
secret: string
|
||||||
|
resave?: boolean
|
||||||
|
saveUninitialized?: boolean
|
||||||
|
store?: Store
|
||||||
|
cookie?: {
|
||||||
|
secure?: boolean
|
||||||
|
maxAge?: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Store {
|
||||||
|
get: (sid: string, callback: (err: any, sess: any | null) => void) => void
|
||||||
|
set: (sid: string, sess: any, callback?: (err?: any) => void) => void
|
||||||
|
destroy: (sid: string, callback?: (err?: any) => void) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
class MemoryStore implements Store {
|
||||||
|
get: Store['get']
|
||||||
|
set: Store['set']
|
||||||
|
destroy: Store['destroy']
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Session {
|
||||||
|
[key: string]: any
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionRequest extends Express.Request {
|
||||||
|
session?: Session
|
||||||
|
}
|
||||||
|
|
||||||
|
function session(options: SessionOptions): RequestHandler
|
||||||
|
|
||||||
|
namespace session {
|
||||||
|
export { MemoryStore }
|
||||||
|
}
|
||||||
|
|
||||||
|
export = session
|
||||||
|
}
|
||||||
106
backend/src/utils/equipmentCode.ts
Normal file
106
backend/src/utils/equipmentCode.ts
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
// 资产类型数字代码映射(纯数字编码)
|
||||||
|
const EQUIPMENT_TYPE_NUMBERS: Record<string, number> = {
|
||||||
|
'相机': 1,
|
||||||
|
'镜头': 2,
|
||||||
|
'三脚架': 3,
|
||||||
|
'闪光灯': 4,
|
||||||
|
'摄影配饰品': 5
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成资产编码(纯数字格式)
|
||||||
|
* 格式: {类型数字}{地址ID(4位)}{序号(5位)}
|
||||||
|
* 例如: 1000100001 (类型1,地址1,序号00001)
|
||||||
|
* 如果没有地址,地址ID为0000
|
||||||
|
*
|
||||||
|
* 确保唯一性:
|
||||||
|
* 1. 查找该类型和地址的最大序号,避免并发问题
|
||||||
|
* 2. 检查生成的编码是否已存在,如果存在则递增序号重试
|
||||||
|
* 3. 最多重试10次,防止无限循环
|
||||||
|
*/
|
||||||
|
export const generateEquipmentCode = async (
|
||||||
|
equipmentType: string,
|
||||||
|
prisma: any,
|
||||||
|
storageLocationId?: number | null
|
||||||
|
): Promise<string> => {
|
||||||
|
const typeNumber = EQUIPMENT_TYPE_NUMBERS[equipmentType] || 0
|
||||||
|
|
||||||
|
// 获取地址ID,如果没有地址则为0
|
||||||
|
const locationId = storageLocationId || 0
|
||||||
|
|
||||||
|
// 格式前缀:{类型数字}{地址ID(4位)}
|
||||||
|
const typeCode = String(typeNumber)
|
||||||
|
const locationCode = String(locationId).padStart(4, '0')
|
||||||
|
const prefix = `${typeCode}${locationCode}`
|
||||||
|
|
||||||
|
// 查找该类型和地址的所有资产,提取最大序号
|
||||||
|
// 使用这种方式比count更安全,可以处理并发情况
|
||||||
|
const existingEquipment = await prisma.equipment.findMany({
|
||||||
|
where: {
|
||||||
|
equipment_type: equipmentType,
|
||||||
|
storage_location_id: storageLocationId || null,
|
||||||
|
equipment_code: {
|
||||||
|
startsWith: prefix
|
||||||
|
}
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
equipment_code: true
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
equipment_code: 'desc'
|
||||||
|
},
|
||||||
|
take: 1 // 只取最大的一条
|
||||||
|
})
|
||||||
|
|
||||||
|
// 计算起始序号
|
||||||
|
let startSequence = 1
|
||||||
|
if (existingEquipment.length > 0) {
|
||||||
|
// 从最大编码中提取序号
|
||||||
|
const maxCode = existingEquipment[0].equipment_code
|
||||||
|
if (maxCode.startsWith(prefix)) {
|
||||||
|
const sequencePart = maxCode.substring(prefix.length)
|
||||||
|
const maxSequence = parseInt(sequencePart, 10)
|
||||||
|
if (!isNaN(maxSequence)) {
|
||||||
|
startSequence = maxSequence + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查序号是否溢出(5位数字最大99999)
|
||||||
|
if (startSequence > 99999) {
|
||||||
|
throw new Error(`资产编码序号已溢出,该类型和地址的资产数量已超过99999`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成编码并检查唯一性,最多重试10次
|
||||||
|
const maxRetries = 10
|
||||||
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||||
|
const sequence = String(startSequence + attempt).padStart(5, '0')
|
||||||
|
const code = `${prefix}${sequence}`
|
||||||
|
|
||||||
|
// 检查编码是否已存在
|
||||||
|
const existing = await prisma.equipment.findUnique({
|
||||||
|
where: { equipment_code: code }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
// 编码唯一,返回
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果编码已存在,继续尝试下一个序号
|
||||||
|
console.warn(`资产编码 ${code} 已存在,尝试下一个序号`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果重试10次后仍然冲突,抛出错误
|
||||||
|
throw new Error(`无法生成唯一的资产编码,已重试${maxRetries}次`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证资产编码格式(纯数字)
|
||||||
|
*/
|
||||||
|
export const validateEquipmentCode = (code: string): boolean => {
|
||||||
|
// 纯数字格式,长度10位:{类型1位}{地址4位}{序号5位}
|
||||||
|
const pattern = /^\d{10}$/
|
||||||
|
return pattern.test(code)
|
||||||
|
}
|
||||||
|
|
||||||
49
backend/src/utils/keycloak.ts
Normal file
49
backend/src/utils/keycloak.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import session from 'express-session'
|
||||||
|
import Keycloak from 'keycloak-connect'
|
||||||
|
|
||||||
|
type KeycloakEnvConfig = {
|
||||||
|
serverUrl: string
|
||||||
|
realm: string
|
||||||
|
clientId: string
|
||||||
|
clientSecret?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredEnv = ['KEYCLOAK_URL', 'KEYCLOAK_REALM', 'KEYCLOAK_CLIENT_ID'] as const
|
||||||
|
|
||||||
|
requiredEnv.forEach((key) => {
|
||||||
|
if (!process.env[key]) {
|
||||||
|
console.warn(`[Keycloak] 环境变量 ${key} 未设置,Keycloak 认证可能无法正常工作。`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const keycloakConfig: KeycloakEnvConfig = {
|
||||||
|
serverUrl: process.env.KEYCLOAK_URL || 'http://localhost:8080',
|
||||||
|
realm: process.env.KEYCLOAK_REALM || 'equipment-management',
|
||||||
|
clientId: process.env.KEYCLOAK_CLIENT_ID || 'equipment-client',
|
||||||
|
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET
|
||||||
|
}
|
||||||
|
|
||||||
|
const memoryStore = new session.MemoryStore()
|
||||||
|
|
||||||
|
const keycloak = new Keycloak(
|
||||||
|
{
|
||||||
|
store: memoryStore,
|
||||||
|
scope: 'openid profile email'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
realm: keycloakConfig.realm,
|
||||||
|
'auth-server-url': keycloakConfig.serverUrl,
|
||||||
|
'ssl-required': 'external',
|
||||||
|
resource: keycloakConfig.clientId,
|
||||||
|
'public-client': !keycloakConfig.clientSecret,
|
||||||
|
credentials: keycloakConfig.clientSecret
|
||||||
|
? { secret: keycloakConfig.clientSecret }
|
||||||
|
: undefined,
|
||||||
|
'confidential-port': 0,
|
||||||
|
'bearer-only': false,
|
||||||
|
'verify-token-audience': true,
|
||||||
|
'use-resource-role-mappings': true
|
||||||
|
} as any
|
||||||
|
)
|
||||||
|
|
||||||
|
export { keycloak, memoryStore }
|
||||||
8
backend/src/utils/prisma.ts
Normal file
8
backend/src/utils/prisma.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
|
||||||
|
const prisma = new PrismaClient({
|
||||||
|
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error']
|
||||||
|
})
|
||||||
|
|
||||||
|
export default prisma
|
||||||
|
|
||||||
24
backend/src/utils/response.ts
Normal file
24
backend/src/utils/response.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { Response } from 'express'
|
||||||
|
|
||||||
|
export interface ApiResponse<T = any> {
|
||||||
|
code: number
|
||||||
|
message: string
|
||||||
|
data: T
|
||||||
|
}
|
||||||
|
|
||||||
|
export const successResponse = <T>(res: Response, data: T, message: string = '操作成功'): void => {
|
||||||
|
res.json({
|
||||||
|
code: 200,
|
||||||
|
message,
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const errorResponse = (res: Response, message: string = '操作失败', code: number = 400): void => {
|
||||||
|
res.status(code).json({
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
data: null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
22
backend/tsconfig.json
Normal file
22
backend/tsconfig.json
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["ES2020"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
|
|
||||||
41
deploy.sh
Normal file
41
deploy.sh
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# 摄影设备借还系统部署脚本
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "=========================================="
|
||||||
|
echo " 摄影设备借还系统 - 部署脚本"
|
||||||
|
echo "=========================================="
|
||||||
|
|
||||||
|
# 进入项目目录
|
||||||
|
cd /opt/equipment-management
|
||||||
|
|
||||||
|
# 停止旧容器
|
||||||
|
echo ">>> 停止旧容器..."
|
||||||
|
sudo docker-compose down 2>/dev/null || true
|
||||||
|
|
||||||
|
# 构建并启动容器
|
||||||
|
echo ">>> 构建Docker镜像..."
|
||||||
|
sudo docker-compose build --no-cache
|
||||||
|
|
||||||
|
echo ">>> 启动服务..."
|
||||||
|
sudo docker-compose up -d
|
||||||
|
|
||||||
|
# 等待服务启动
|
||||||
|
echo ">>> 等待服务启动..."
|
||||||
|
sleep 10
|
||||||
|
|
||||||
|
# 检查服务状态
|
||||||
|
echo ">>> 检查服务状态..."
|
||||||
|
sudo docker-compose ps
|
||||||
|
|
||||||
|
# 检查后端健康状态
|
||||||
|
echo ">>> 检查后端API..."
|
||||||
|
curl -s http://localhost:3000/api/health || echo "后端服务启动中..."
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
echo " 部署完成!"
|
||||||
|
echo " 前端访问: http://1.13.22.133"
|
||||||
|
echo " 后端API: http://1.13.22.133:3000/api"
|
||||||
|
echo "=========================================="
|
||||||
62
docker-compose.yml
Normal file
62
docker-compose.yml
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# MySQL数据库
|
||||||
|
mysql:
|
||||||
|
image: mysql:8.0
|
||||||
|
container_name: equipment-mysql
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: jsjj8888
|
||||||
|
MYSQL_DATABASE: equipment_management
|
||||||
|
MYSQL_CHARSET: utf8mb4
|
||||||
|
MYSQL_COLLATION: utf8mb4_unicode_ci
|
||||||
|
ports:
|
||||||
|
- "3306:3306"
|
||||||
|
volumes:
|
||||||
|
- 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"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# 后端服务
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: equipment-backend
|
||||||
|
restart: always
|
||||||
|
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
|
||||||
|
depends_on:
|
||||||
|
mysql:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- backend_uploads:/app/uploads
|
||||||
|
|
||||||
|
# 前端服务
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
VITE_API_BASE_URL: /api
|
||||||
|
VITE_AUTH_MODE: local
|
||||||
|
container_name: equipment-frontend
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mysql_data:
|
||||||
|
backend_uploads:
|
||||||
34
frontend/Dockerfile
Normal file
34
frontend/Dockerfile
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
FROM node:20-alpine as builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 构建参数
|
||||||
|
ARG VITE_API_BASE_URL=http://localhost:3000/api
|
||||||
|
ARG VITE_AUTH_MODE=local
|
||||||
|
|
||||||
|
# 设置环境变量
|
||||||
|
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
|
||||||
|
ENV VITE_AUTH_MODE=$VITE_AUTH_MODE
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install --registry=https://registry.npmmirror.com
|
||||||
|
|
||||||
|
# 复制源代码
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# 构建前端
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# 生产环境使用nginx
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# 复制构建产物
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# 复制nginx配置
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>固定资产管理系统</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
40
frontend/nginx.conf
Normal file
40
frontend/nginx.conf
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Gzip压缩
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||||
|
|
||||||
|
# 前端路由支持
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# API代理
|
||||||
|
location /api {
|
||||||
|
proxy_pass http://backend:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 上传文件代理
|
||||||
|
location /uploads {
|
||||||
|
proxy_pass http://backend:3000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 静态资源缓存
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
3909
frontend/package-lock.json
generated
Normal file
3909
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
38
frontend/package.json
Normal file
38
frontend/package.json
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@heroicons/vue": "^2.2.0",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
|
"axios": "^1.13.2",
|
||||||
|
"echarts": "^6.0.0",
|
||||||
|
"html2canvas": "^1.4.1",
|
||||||
|
"jsbarcode": "^3.12.1",
|
||||||
|
"keycloak-js": "^23.0.7",
|
||||||
|
"pinia": "^3.0.4",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
|
"vue": "^3.5.24",
|
||||||
|
"vue-router": "^4.6.3",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
|
"@types/node": "^24.10.0",
|
||||||
|
"@types/xlsx": "^0.0.35",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
|
"@vue/tsconfig": "^0.8.1",
|
||||||
|
"autoprefixer": "^10.4.22",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^3.4.15",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"vite": "^7.2.2",
|
||||||
|
"vue-tsc": "^3.1.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
10
frontend/postcss.config.js
Normal file
10
frontend/postcss.config.js
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import tailwindcss from 'tailwindcss'
|
||||||
|
import autoprefixer from 'autoprefixer'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
plugins: [
|
||||||
|
tailwindcss(),
|
||||||
|
autoprefixer(),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
15
frontend/src/App.vue
Normal file
15
frontend/src/App.vue
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<template>
|
||||||
|
<div id="app" class="min-h-screen bg-system-background-secondary">
|
||||||
|
<router-view />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// App root component
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
#app {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
45
frontend/src/api/assetType.ts
Normal file
45
frontend/src/api/assetType.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import api from './index'
|
||||||
|
import type { ApiResponse, PaginatedResponse, AssetType } from '@/types'
|
||||||
|
|
||||||
|
export const getAssetTypeOptions = async () => {
|
||||||
|
const response = await api.get<ApiResponse<AssetType[]>>('/asset-types/options')
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getAssetTypeList = async (params: {
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
keyword?: string
|
||||||
|
status?: string
|
||||||
|
}) => {
|
||||||
|
const response = await api.get<ApiResponse<PaginatedResponse<AssetType>>>('/asset-types/list', {
|
||||||
|
params
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createAssetType = async (data: {
|
||||||
|
name: string
|
||||||
|
short_name?: string
|
||||||
|
can_be_parent?: boolean
|
||||||
|
can_be_child?: boolean
|
||||||
|
}) => {
|
||||||
|
const response = await api.post<ApiResponse<AssetType>>('/asset-types', data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateAssetType = async (id: number, data: {
|
||||||
|
name?: string
|
||||||
|
short_name?: string
|
||||||
|
status?: string
|
||||||
|
can_be_parent?: boolean
|
||||||
|
can_be_child?: boolean
|
||||||
|
}) => {
|
||||||
|
const response = await api.put<ApiResponse<AssetType>>(`/asset-types/${id}`, data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deleteAssetType = async (id: number) => {
|
||||||
|
const response = await api.delete<ApiResponse<null>>(`/asset-types/${id}`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
41
frontend/src/api/barcode.ts
Normal file
41
frontend/src/api/barcode.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import api from './index'
|
||||||
|
import type { ApiResponse } from '@/types'
|
||||||
|
|
||||||
|
// 获取设备信息用于生成条码
|
||||||
|
export const getEquipmentForBarcode = async (equipmentCode: string) => {
|
||||||
|
const response = await api.get<
|
||||||
|
ApiResponse<{
|
||||||
|
equipment_code: string
|
||||||
|
equipment_name: string
|
||||||
|
equipment_model?: string
|
||||||
|
equipment_type: string
|
||||||
|
}>
|
||||||
|
>(`/barcode/${equipmentCode}`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量获取设备信息用于生成条码
|
||||||
|
export const getEquipmentBatchForBarcode = async (equipmentIds: number[]) => {
|
||||||
|
const response = await api.post<
|
||||||
|
ApiResponse<
|
||||||
|
Array<{
|
||||||
|
id: number
|
||||||
|
equipment_code: string
|
||||||
|
equipment_name: string
|
||||||
|
equipment_model?: string
|
||||||
|
equipment_type: string
|
||||||
|
storage_location_id?: number | null
|
||||||
|
storageLocation?: {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
code?: string | null
|
||||||
|
} | null
|
||||||
|
}>
|
||||||
|
>
|
||||||
|
>(`/barcode/batch`, {
|
||||||
|
equipment_ids: equipmentIds
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
40
frontend/src/api/batch.ts
Normal file
40
frontend/src/api/batch.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import request from './index'
|
||||||
|
|
||||||
|
export interface BatchResult {
|
||||||
|
success_count: number
|
||||||
|
failed_count: number
|
||||||
|
results: Array<{
|
||||||
|
equipment_id: number
|
||||||
|
equipment_code?: string
|
||||||
|
status: 'success' | 'failed'
|
||||||
|
message: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量借出
|
||||||
|
export const batchBorrow = (data: {
|
||||||
|
equipment_ids: number[]
|
||||||
|
person_id: number
|
||||||
|
expected_return_time?: string
|
||||||
|
is_long_term?: boolean
|
||||||
|
notes?: string
|
||||||
|
}) => {
|
||||||
|
return request.post<BatchResult>('/batch/borrow', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量归还
|
||||||
|
export const batchReturn = (data: {
|
||||||
|
equipment_ids: number[]
|
||||||
|
return_condition?: string
|
||||||
|
notes?: string
|
||||||
|
}) => {
|
||||||
|
return request.post<BatchResult>('/batch/return', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量转移部门
|
||||||
|
export const batchTransfer = (data: {
|
||||||
|
equipment_ids: number[]
|
||||||
|
department_id: number
|
||||||
|
}) => {
|
||||||
|
return request.post<BatchResult>('/batch/transfer', data)
|
||||||
|
}
|
||||||
43
frontend/src/api/batchImport.ts
Normal file
43
frontend/src/api/batchImport.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import api from '@/api'
|
||||||
|
import type { ApiResponse } from '@/types'
|
||||||
|
|
||||||
|
interface PersonImportData {
|
||||||
|
nickname: string
|
||||||
|
real_name: string
|
||||||
|
id_number?: string
|
||||||
|
department?: string
|
||||||
|
position?: string
|
||||||
|
phone?: string
|
||||||
|
email?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EquipmentImportData {
|
||||||
|
equipment_name: string
|
||||||
|
equipment_type: string
|
||||||
|
equipment_model?: string
|
||||||
|
department?: string
|
||||||
|
storage_location?: string
|
||||||
|
unit_price?: number
|
||||||
|
total_quantity: number
|
||||||
|
supplier?: string
|
||||||
|
purchase_date?: string
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportResult {
|
||||||
|
success: number
|
||||||
|
failed: number
|
||||||
|
errors: { row: number; message: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const batchImportPersons = async (persons: PersonImportData[]) => {
|
||||||
|
const response = await api.post<ApiResponse<ImportResult>>('/batch-import/persons', { persons })
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const batchImportEquipments = async (equipments: EquipmentImportData[]) => {
|
||||||
|
const response = await api.post<ApiResponse<ImportResult>>('/batch-import/equipments', { equipments })
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { ImportResult }
|
||||||
146
frontend/src/api/borrow.ts
Normal file
146
frontend/src/api/borrow.ts
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
import api from './index'
|
||||||
|
import type {
|
||||||
|
BorrowRecord,
|
||||||
|
PaginatedResponse,
|
||||||
|
ApiResponse,
|
||||||
|
EquipmentStatus,
|
||||||
|
StorageLocation
|
||||||
|
} from '@/types'
|
||||||
|
|
||||||
|
export interface ScanBorrowResult {
|
||||||
|
id: number
|
||||||
|
equipment_code: string
|
||||||
|
equipment_name: string
|
||||||
|
borrow_time: string
|
||||||
|
status: string
|
||||||
|
remaining_quantity?: number
|
||||||
|
equipment_status?: EquipmentStatus
|
||||||
|
storage_location_id?: number | null
|
||||||
|
storageLocation?: StorageLocation | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 借用设备
|
||||||
|
export const borrowEquipment = async (data: {
|
||||||
|
equipment_id?: number
|
||||||
|
equipment_code?: string
|
||||||
|
person_id: number
|
||||||
|
expected_return_time?: string
|
||||||
|
notes?: string
|
||||||
|
is_long_term?: boolean
|
||||||
|
}) => {
|
||||||
|
const response = await api.post<ApiResponse<BorrowRecord>>('/borrow', data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// 扫码出库
|
||||||
|
export const scanBorrow = async (data: {
|
||||||
|
equipment_code: string
|
||||||
|
person_id: number
|
||||||
|
expected_return_time?: string
|
||||||
|
notes?: string
|
||||||
|
is_long_term?: boolean
|
||||||
|
}) => {
|
||||||
|
const response = await api.post<ApiResponse<ScanBorrowResult>>('/borrow/scan', data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量扫码出库
|
||||||
|
export const scanBorrowBatch = async (data: {
|
||||||
|
equipment_codes: string[]
|
||||||
|
person_id: number
|
||||||
|
expected_return_time?: string
|
||||||
|
notes?: string
|
||||||
|
is_long_term?: boolean
|
||||||
|
}) => {
|
||||||
|
const response = await api.post<ApiResponse<{
|
||||||
|
success_count: number
|
||||||
|
failed_count: number
|
||||||
|
results: Array<{
|
||||||
|
equipment_code: string
|
||||||
|
status: string
|
||||||
|
message: string
|
||||||
|
}>
|
||||||
|
}>>('/borrow/scan-batch', data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// 归还设备
|
||||||
|
export const returnEquipment = async (data: {
|
||||||
|
equipment_id: number
|
||||||
|
equipment_code: string
|
||||||
|
return_condition?: string
|
||||||
|
notes?: string
|
||||||
|
}) => {
|
||||||
|
const response = await api.post<ApiResponse<BorrowRecord>>('/return', data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// 扫码入库
|
||||||
|
export const scanReturn = async (data: {
|
||||||
|
equipment_code: string
|
||||||
|
return_condition?: string
|
||||||
|
notes?: string
|
||||||
|
person_id?: number
|
||||||
|
borrow_record_id?: number
|
||||||
|
}) => {
|
||||||
|
const response = await api.post<
|
||||||
|
ApiResponse<{
|
||||||
|
id: number
|
||||||
|
equipment_code: string
|
||||||
|
equipment_name: string
|
||||||
|
return_time: string
|
||||||
|
status: string
|
||||||
|
person_id?: number
|
||||||
|
}>
|
||||||
|
>('/return/scan', data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量扫码入库
|
||||||
|
export const scanReturnBatch = async (data: {
|
||||||
|
equipment_codes: string[]
|
||||||
|
return_condition?: string
|
||||||
|
notes?: string
|
||||||
|
}) => {
|
||||||
|
const response = await api.post<ApiResponse<{
|
||||||
|
success_count: number
|
||||||
|
failed_count: number
|
||||||
|
results: Array<{
|
||||||
|
equipment_code: string
|
||||||
|
status: string
|
||||||
|
message: string
|
||||||
|
}>
|
||||||
|
}>>('/return/scan-batch', data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据设备编码查询借用记录
|
||||||
|
export const getBorrowRecordByCode = async (equipmentCode: string) => {
|
||||||
|
const response = await api.get<ApiResponse<{
|
||||||
|
equipment_code: string
|
||||||
|
equipment_name: string
|
||||||
|
unit_price: number
|
||||||
|
equipment_value: number
|
||||||
|
storage_location_id?: number | null
|
||||||
|
storageLocation?: StorageLocation | null
|
||||||
|
borrow_records: BorrowRecord[]
|
||||||
|
can_return: boolean
|
||||||
|
message: string
|
||||||
|
}>>(`/borrow/scan/${equipmentCode}`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取借用记录列表
|
||||||
|
export const getBorrowRecordList = async (params: {
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
status?: string
|
||||||
|
person_name?: string
|
||||||
|
person_id?: number
|
||||||
|
}) => {
|
||||||
|
const response = await api.get<ApiResponse<PaginatedResponse<BorrowRecord>>>('/borrow/records', {
|
||||||
|
params
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
39
frontend/src/api/department.ts
Normal file
39
frontend/src/api/department.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import api from './index'
|
||||||
|
import type { ApiResponse, PaginatedResponse, Department, DepartmentTreeNode } from '@/types'
|
||||||
|
|
||||||
|
export const getDepartmentOptions = async () => {
|
||||||
|
const response = await api.get<ApiResponse<Department[]>>('/departments/options')
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getDepartmentTree = async () => {
|
||||||
|
const response = await api.get<ApiResponse<DepartmentTreeNode[]>>('/departments/tree')
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getDepartmentList = async (params: {
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
keyword?: string
|
||||||
|
status?: string
|
||||||
|
}) => {
|
||||||
|
const response = await api.get<ApiResponse<PaginatedResponse<Department>>>('/departments/list', {
|
||||||
|
params
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createDepartment = async (data: { name: string; parent_id?: number | null }) => {
|
||||||
|
const response = await api.post<ApiResponse<Department>>('/departments', data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateDepartment = async (id: number, data: { name?: string; status?: string; parent_id?: number | null }) => {
|
||||||
|
const response = await api.put<ApiResponse<Department>>(`/departments/${id}`, data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deleteDepartment = async (id: number) => {
|
||||||
|
const response = await api.delete<ApiResponse<null>>(`/departments/${id}`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
63
frontend/src/api/disposal.ts
Normal file
63
frontend/src/api/disposal.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import request from './index'
|
||||||
|
|
||||||
|
export interface DisposalRecord {
|
||||||
|
id: number
|
||||||
|
equipment_id: number
|
||||||
|
equipment_code: string
|
||||||
|
disposal_reason: string
|
||||||
|
disposal_method: string
|
||||||
|
estimated_value: number
|
||||||
|
actual_value?: number
|
||||||
|
status: 'pending' | 'approved' | 'rejected' | 'completed'
|
||||||
|
applied_by: number
|
||||||
|
applied_at: string
|
||||||
|
approved_by?: number
|
||||||
|
approved_at?: string
|
||||||
|
completed_at?: string
|
||||||
|
notes?: string
|
||||||
|
equipment?: {
|
||||||
|
equipment_name: string
|
||||||
|
equipment_type: string
|
||||||
|
unit_price: number
|
||||||
|
}
|
||||||
|
applier?: { id: number; name: string; username: string }
|
||||||
|
approver?: { id: number; name: string; username: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateDisposalParams {
|
||||||
|
equipment_id: number
|
||||||
|
disposal_reason: string
|
||||||
|
disposal_method: string
|
||||||
|
estimated_value: number
|
||||||
|
notes?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建报废申请
|
||||||
|
export const createDisposal = (data: CreateDisposalParams) => {
|
||||||
|
return request.post('/disposal', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取报废记录列表
|
||||||
|
export const getDisposalList = (params: { page?: number; pageSize?: number; status?: string; keyword?: string; equipment_id?: number }) => {
|
||||||
|
return request.get('/disposal/list', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取报废记录详情
|
||||||
|
export const getDisposalDetail = (id: number) => {
|
||||||
|
return request.get(`/disposal/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 审批报废申请
|
||||||
|
export const approveDisposal = (id: number, data: { approved: boolean; reject_reason?: string }) => {
|
||||||
|
return request.put(`/disposal/${id}/approve`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 完成报废处置
|
||||||
|
export const completeDisposal = (id: number, data: { actual_value?: number; notes?: string }) => {
|
||||||
|
return request.put(`/disposal/${id}/complete`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取资产报废历史
|
||||||
|
export const getEquipmentDisposalHistory = (equipmentId: number) => {
|
||||||
|
return request.get(`/disposal/equipment/${equipmentId}/history`)
|
||||||
|
}
|
||||||
97
frontend/src/api/equipment.ts
Normal file
97
frontend/src/api/equipment.ts
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import api from './index'
|
||||||
|
import type { Equipment, PaginatedResponse, ApiResponse } from '@/types'
|
||||||
|
|
||||||
|
const transformEquipmentPayload = (data: Partial<Equipment>) => {
|
||||||
|
const payload: Record<string, any> = { ...data }
|
||||||
|
if (payload.storageLocation) {
|
||||||
|
payload.storage_location_id = payload.storageLocation?.id
|
||||||
|
delete payload.storageLocation
|
||||||
|
}
|
||||||
|
if (payload.storage_location_id !== undefined && payload.storage_location_id !== null) {
|
||||||
|
payload.storage_location_id = Number(payload.storage_location_id)
|
||||||
|
} else {
|
||||||
|
delete payload.storage_location_id
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取资产列表
|
||||||
|
export const getEquipmentList = async (params: {
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
equipmentType?: string
|
||||||
|
status?: string
|
||||||
|
keyword?: string
|
||||||
|
storageLocationId?: number
|
||||||
|
departmentId?: number
|
||||||
|
mount_status?: string
|
||||||
|
}) => {
|
||||||
|
const { storageLocationId, departmentId, ...rest } = params
|
||||||
|
const response = await api.get<ApiResponse<PaginatedResponse<Equipment>>>('/equipment/list', {
|
||||||
|
params: {
|
||||||
|
...rest,
|
||||||
|
storage_location_id: storageLocationId,
|
||||||
|
department_id: departmentId
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取资产详情
|
||||||
|
export const getEquipmentDetail = async (id: number) => {
|
||||||
|
const response = await api.get<ApiResponse<Equipment>>(`/equipment/${id}`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增资产
|
||||||
|
export const createEquipment = async (data: Partial<Equipment>) => {
|
||||||
|
const response = await api.post<ApiResponse<Equipment>>('/equipment', transformEquipmentPayload(data))
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新资产
|
||||||
|
export const updateEquipment = async (id: number, data: Partial<Equipment>) => {
|
||||||
|
const response = await api.put<ApiResponse<Equipment>>(`/equipment/${id}`, transformEquipmentPayload(data))
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除资产
|
||||||
|
export const deleteEquipment = async (id: number) => {
|
||||||
|
const response = await api.delete<ApiResponse<void>>(`/equipment/${id}`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成资产编码
|
||||||
|
export const generateEquipmentCode = async (
|
||||||
|
equipmentType: string,
|
||||||
|
codeGenerationMode: string = 'auto',
|
||||||
|
storageLocationId?: number | null
|
||||||
|
) => {
|
||||||
|
const response = await api.post<ApiResponse<{ equipment_code: string; code_format: string }>>(
|
||||||
|
'/equipment/generate-code',
|
||||||
|
{
|
||||||
|
equipment_type: equipmentType,
|
||||||
|
code_generation_mode: codeGenerationMode,
|
||||||
|
storage_location_id: storageLocationId
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证资产编码
|
||||||
|
export const verifyEquipmentCode = async (equipmentCode: string) => {
|
||||||
|
const response = await api.post<ApiResponse<{ is_valid: boolean; is_unique: boolean; message: string }>>(
|
||||||
|
'/equipment/verify-code',
|
||||||
|
{
|
||||||
|
equipment_code: equipmentCode
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据资产编码查询资产信息(扫码)
|
||||||
|
export const scanEquipment = async (equipmentCode: string) => {
|
||||||
|
const response = await api.get<ApiResponse<Equipment>>(`/equipment/scan/${equipmentCode}`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
39
frontend/src/api/export.ts
Normal file
39
frontend/src/api/export.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import request from './index'
|
||||||
|
|
||||||
|
// 导出资产列表
|
||||||
|
export const exportEquipment = (params?: {
|
||||||
|
status?: string
|
||||||
|
equipment_type?: string
|
||||||
|
department_id?: number
|
||||||
|
keyword?: string
|
||||||
|
}) => {
|
||||||
|
return request.get('/export/equipment', {
|
||||||
|
params,
|
||||||
|
responseType: 'blob'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出借用记录
|
||||||
|
export const exportBorrowRecords = (params?: {
|
||||||
|
status?: string
|
||||||
|
person_name?: string
|
||||||
|
start_date?: string
|
||||||
|
end_date?: string
|
||||||
|
}) => {
|
||||||
|
return request.get('/export/borrow-records', {
|
||||||
|
params,
|
||||||
|
responseType: 'blob'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下载文件辅助函数
|
||||||
|
export const downloadFile = (blob: Blob, filename: string) => {
|
||||||
|
const url = window.URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = filename
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
window.URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
81
frontend/src/api/index.ts
Normal file
81
frontend/src/api/index.ts
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import type { AxiosInstance, AxiosResponse } from 'axios'
|
||||||
|
import type { ApiResponse } from '@/types'
|
||||||
|
|
||||||
|
// 创建axios实例
|
||||||
|
const api: AxiosInstance = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api',
|
||||||
|
timeout: 10000,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
api.defaults.withCredentials = true
|
||||||
|
|
||||||
|
// 请求拦截器
|
||||||
|
api.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
// 从localStorage获取token
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const authMode = import.meta.env.VITE_AUTH_MODE || 'keycloak'
|
||||||
|
|
||||||
|
// 响应拦截器
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response: AxiosResponse<ApiResponse>) => {
|
||||||
|
const data = response.data as ApiResponse
|
||||||
|
if (data?.code === 200) {
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(data?.message || '请求失败'))
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
// 处理HTTP错误
|
||||||
|
if (error.response) {
|
||||||
|
const requestUrl: string = error.config?.url || ''
|
||||||
|
switch (error.response.status) {
|
||||||
|
case 401:
|
||||||
|
// 未授权,跳转到登录页
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
{
|
||||||
|
const isAuthStatusCheck = requestUrl.includes('/auth/user')
|
||||||
|
const isLoginAttempt = requestUrl.includes('/auth/local/login')
|
||||||
|
const shouldRedirect =
|
||||||
|
authMode !== 'local' || (!isAuthStatusCheck && !isLoginAttempt)
|
||||||
|
if (shouldRedirect && window.location.pathname !== '/login') {
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 403:
|
||||||
|
// 禁止访问
|
||||||
|
console.error('禁止访问')
|
||||||
|
break
|
||||||
|
case 404:
|
||||||
|
// 资源不存在
|
||||||
|
console.error('资源不存在')
|
||||||
|
break
|
||||||
|
case 500:
|
||||||
|
// 服务器错误
|
||||||
|
console.error('服务器错误')
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
console.error('请求失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default api
|
||||||
|
|
||||||
65
frontend/src/api/maintenance.ts
Normal file
65
frontend/src/api/maintenance.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import request from './index'
|
||||||
|
|
||||||
|
export interface MaintenanceRecord {
|
||||||
|
id: number
|
||||||
|
equipment_id: number
|
||||||
|
equipment_code: string
|
||||||
|
issue_description: string
|
||||||
|
repair_vendor?: string
|
||||||
|
estimated_cost: number
|
||||||
|
actual_cost?: number
|
||||||
|
start_date: string
|
||||||
|
expected_end_date?: string
|
||||||
|
actual_end_date?: string
|
||||||
|
status: 'in_progress' | 'completed' | 'cancelled'
|
||||||
|
repair_result?: string
|
||||||
|
created_by: number
|
||||||
|
equipment?: {
|
||||||
|
equipment_name: string
|
||||||
|
equipment_type: string
|
||||||
|
}
|
||||||
|
creator?: { id: number; name: string; username: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateMaintenanceParams {
|
||||||
|
equipment_id: number
|
||||||
|
issue_description: string
|
||||||
|
repair_vendor?: string
|
||||||
|
estimated_cost: number
|
||||||
|
expected_end_date?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建维修记录
|
||||||
|
export const createMaintenance = (data: CreateMaintenanceParams) => {
|
||||||
|
return request.post('/maintenance', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取维修记录列表
|
||||||
|
export const getMaintenanceList = (params: { page?: number; pageSize?: number; status?: string; keyword?: string; equipment_id?: number }) => {
|
||||||
|
return request.get('/maintenance/list', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取维修记录详情
|
||||||
|
export const getMaintenanceDetail = (id: number) => {
|
||||||
|
return request.get(`/maintenance/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新维修记录
|
||||||
|
export const updateMaintenance = (id: number, data: Partial<CreateMaintenanceParams>) => {
|
||||||
|
return request.put(`/maintenance/${id}`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 完成维修
|
||||||
|
export const completeMaintenance = (id: number, data: { actual_cost?: number; repair_result?: string }) => {
|
||||||
|
return request.put(`/maintenance/${id}/complete`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取消维修
|
||||||
|
export const cancelMaintenance = (id: number, data: { reason?: string }) => {
|
||||||
|
return request.put(`/maintenance/${id}/cancel`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取资产维修历史
|
||||||
|
export const getEquipmentMaintenanceHistory = (equipmentId: number) => {
|
||||||
|
return request.get(`/maintenance/equipment/${equipmentId}/history`)
|
||||||
|
}
|
||||||
97
frontend/src/api/mount.ts
Normal file
97
frontend/src/api/mount.ts
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import api from './index'
|
||||||
|
import type { ApiResponse } from '@/types'
|
||||||
|
|
||||||
|
export interface MountChildRequest {
|
||||||
|
child_id: number
|
||||||
|
notes?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnmountChildRequest {
|
||||||
|
child_id: number
|
||||||
|
notes?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplaceChildRequest {
|
||||||
|
old_child_id: number
|
||||||
|
new_child_id: number
|
||||||
|
notes?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChildEquipment {
|
||||||
|
id: number
|
||||||
|
equipment_code: string
|
||||||
|
equipment_name: string
|
||||||
|
equipment_model?: string
|
||||||
|
equipment_type: string
|
||||||
|
unit_price: number
|
||||||
|
status: string
|
||||||
|
mount_status?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MountHistory {
|
||||||
|
id: number
|
||||||
|
child_id: number
|
||||||
|
parent_id: number | null
|
||||||
|
operation_type: 'mount' | 'unmount' | 'replace'
|
||||||
|
operated_at: string
|
||||||
|
operated_by: number
|
||||||
|
notes?: string
|
||||||
|
child: {
|
||||||
|
id: number
|
||||||
|
equipment_code: string
|
||||||
|
equipment_name: string
|
||||||
|
}
|
||||||
|
parent?: {
|
||||||
|
id: number
|
||||||
|
equipment_code: string
|
||||||
|
equipment_name: string
|
||||||
|
} | null
|
||||||
|
operator: {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
username: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 挂载子资产到父资产
|
||||||
|
export const mountChild = async (parentId: number, data: MountChildRequest): Promise<ApiResponse<any>> => {
|
||||||
|
const response = await api.post(`/mount/${parentId}/mount`, data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从父资产卸载子资产
|
||||||
|
export const unmountChild = async (parentId: number, data: UnmountChildRequest): Promise<ApiResponse<any>> => {
|
||||||
|
const response = await api.post(`/mount/${parentId}/unmount`, data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更换配件
|
||||||
|
export const replaceChild = async (parentId: number, data: ReplaceChildRequest): Promise<ApiResponse<any>> => {
|
||||||
|
const response = await api.post(`/mount/${parentId}/replace`, data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取父资产的子资产列表
|
||||||
|
export const getChildren = async (parentId: number): Promise<ApiResponse<{
|
||||||
|
children: ChildEquipment[]
|
||||||
|
total_children_value: number
|
||||||
|
total_value_with_children: number
|
||||||
|
}>> => {
|
||||||
|
const response = await api.get(`/mount/${parentId}/children`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取资产的挂载历史
|
||||||
|
export const getMountHistory = async (equipmentId: number): Promise<ApiResponse<MountHistory[]>> => {
|
||||||
|
const response = await api.get(`/mount/${equipmentId}/mount-history`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取可挂载的子资产列表
|
||||||
|
export const getAvailableChildren = async (params?: {
|
||||||
|
keyword?: string
|
||||||
|
equipment_type?: string
|
||||||
|
}): Promise<ApiResponse<ChildEquipment[]>> => {
|
||||||
|
const response = await api.get('/mount/available-children', { params })
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
42
frontend/src/api/operationLog.ts
Normal file
42
frontend/src/api/operationLog.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import api from '@/api'
|
||||||
|
|
||||||
|
interface OperationLog {
|
||||||
|
id: number
|
||||||
|
user_id: number | null
|
||||||
|
username: string | null
|
||||||
|
operation_type: string
|
||||||
|
equipment_id: number | null
|
||||||
|
equipment_code: string | null
|
||||||
|
operation_details: string | null
|
||||||
|
ip_address: string | null
|
||||||
|
created_at: string
|
||||||
|
user?: {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OperationLogListResponse {
|
||||||
|
list: OperationLog[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OperationLogQuery {
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
operationType?: string
|
||||||
|
username?: string
|
||||||
|
startDate?: string
|
||||||
|
endDate?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getOperationLogList = (params: OperationLogQuery) => {
|
||||||
|
return api.get<{ data: OperationLogListResponse }>('/operation-logs/list', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getOperationTypes = () => {
|
||||||
|
return api.get<{ data: string[] }>('/operation-logs/types')
|
||||||
|
}
|
||||||
66
frontend/src/api/person.ts
Normal file
66
frontend/src/api/person.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import api from './index'
|
||||||
|
import type { ApiResponse, PaginatedResponse, Person } from '@/types'
|
||||||
|
|
||||||
|
export const getPersonList = async (params: {
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
keyword?: string
|
||||||
|
status?: string
|
||||||
|
}) => {
|
||||||
|
const response = await api.get<ApiResponse<PaginatedResponse<Person>>>('/persons', { params })
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getPersonDetail = async (id: number) => {
|
||||||
|
const response = await api.get<ApiResponse<Person>>(`/persons/${id}`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createPerson = async (data: {
|
||||||
|
nickname: string
|
||||||
|
real_name: string
|
||||||
|
id_number?: string
|
||||||
|
department?: string
|
||||||
|
position?: string
|
||||||
|
phone?: string
|
||||||
|
email?: string
|
||||||
|
status?: string
|
||||||
|
remarks?: string
|
||||||
|
}) => {
|
||||||
|
const response = await api.post<ApiResponse<Person>>('/persons', data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updatePerson = async (
|
||||||
|
id: number,
|
||||||
|
data: {
|
||||||
|
nickname?: string
|
||||||
|
real_name?: string
|
||||||
|
id_number?: string
|
||||||
|
department?: string
|
||||||
|
position?: string
|
||||||
|
phone?: string
|
||||||
|
email?: string
|
||||||
|
status?: string
|
||||||
|
remarks?: string
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
const response = await api.put<ApiResponse<Person>>(`/persons/${id}`, data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updatePersonStatus = async (id: number, status: 'active' | 'inactive') => {
|
||||||
|
const response = await api.patch<ApiResponse<Person>>(`/persons/${id}/status`, { status })
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const deletePerson = async (id: number) => {
|
||||||
|
const response = await api.delete<ApiResponse<null>>(`/persons/${id}`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getPersonOptions = async () => {
|
||||||
|
const response = await api.get<ApiResponse<Person[]>>('/persons/options')
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
15
frontend/src/api/search.ts
Normal file
15
frontend/src/api/search.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import request from './index'
|
||||||
|
|
||||||
|
export interface SearchResult {
|
||||||
|
id: number
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
status: string
|
||||||
|
category: 'equipment'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全局搜索
|
||||||
|
export const globalSearch = (keyword: string) => {
|
||||||
|
return request.get<SearchResult[]>('/search', { params: { keyword } })
|
||||||
|
}
|
||||||
26
frontend/src/api/settings.ts
Normal file
26
frontend/src/api/settings.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import api from './index'
|
||||||
|
import type { ApiResponse } from '@/types'
|
||||||
|
|
||||||
|
export interface SiteConfigResponse {
|
||||||
|
site_name: string
|
||||||
|
logo_url: string
|
||||||
|
favicon_url: string
|
||||||
|
theme_color: string
|
||||||
|
updated_at: string | null
|
||||||
|
updated_by: {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
name: string
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getSiteConfig = async () => {
|
||||||
|
const response = await api.get<ApiResponse<SiteConfigResponse>>('/settings/site')
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateSiteConfig = async (data: { site_name: string; theme_color?: string }) => {
|
||||||
|
const response = await api.put<ApiResponse<SiteConfigResponse>>('/settings/site', data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
19
frontend/src/api/statistics.ts
Normal file
19
frontend/src/api/statistics.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import api from './index'
|
||||||
|
import type { DashboardStats, ValueStatistics, ApiResponse } from '@/types'
|
||||||
|
|
||||||
|
// 获取首页统计数据
|
||||||
|
export const getDashboardStats = async () => {
|
||||||
|
const response = await api.get<ApiResponse<DashboardStats>>('/statistics/dashboard')
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取设备价值统计
|
||||||
|
export const getValueStatistics = async (params?: {
|
||||||
|
equipment_type?: string
|
||||||
|
start_date?: string
|
||||||
|
end_date?: string
|
||||||
|
}) => {
|
||||||
|
const response = await api.get<ApiResponse<ValueStatistics>>('/statistics/value', { params })
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
37
frontend/src/api/storageLocation.ts
Normal file
37
frontend/src/api/storageLocation.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import api from './index'
|
||||||
|
import type { ApiResponse, PaginatedResponse, StorageLocation } from '@/types'
|
||||||
|
|
||||||
|
export const getStorageLocationOptions = async () => {
|
||||||
|
const response = await api.get<ApiResponse<StorageLocation[]>>('/storage-locations/options')
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getStorageLocationList = async (params: {
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
keyword?: string
|
||||||
|
status?: string
|
||||||
|
}) => {
|
||||||
|
const response = await api.get<ApiResponse<PaginatedResponse<StorageLocation>>>('/storage-locations/list', {
|
||||||
|
params
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createStorageLocation = async (data: Partial<StorageLocation>) => {
|
||||||
|
const response = await api.post<ApiResponse<StorageLocation>>('/storage-locations', data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateStorageLocation = async (id: number, data: Partial<StorageLocation>) => {
|
||||||
|
const response = await api.put<ApiResponse<StorageLocation>>(`/storage-locations/${id}`, data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateStorageLocationStatus = async (id: number, status: 'active' | 'inactive') => {
|
||||||
|
const response = await api.patch<ApiResponse<StorageLocation>>(`/storage-locations/${id}/status`, {
|
||||||
|
status
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
62
frontend/src/api/users.ts
Normal file
62
frontend/src/api/users.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import api from './index'
|
||||||
|
import type { ApiResponse, PaginatedResponse, User } from '@/types'
|
||||||
|
|
||||||
|
export interface UserListParams {
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
keyword?: string
|
||||||
|
role?: string
|
||||||
|
status?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getUserList = async (params: UserListParams = {}) => {
|
||||||
|
const response = await api.get<ApiResponse<PaginatedResponse<User>>>('/users', { params })
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createUser = async (data: {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
name: string
|
||||||
|
email?: string
|
||||||
|
phone?: string
|
||||||
|
roles?: string[]
|
||||||
|
role?: string
|
||||||
|
status?: string
|
||||||
|
}) => {
|
||||||
|
const response = await api.post<ApiResponse<User>>('/users', data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateUser = async (
|
||||||
|
id: number | string,
|
||||||
|
data: {
|
||||||
|
name?: string
|
||||||
|
email?: string
|
||||||
|
phone?: string
|
||||||
|
roles?: string[]
|
||||||
|
role?: string
|
||||||
|
status?: string
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
const response = await api.put<ApiResponse<User>>(`/users/${id}`, data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateUserStatus = async (id: number | string, status: 'active' | 'inactive') => {
|
||||||
|
const response = await api.patch<ApiResponse<User>>(`/users/${id}/status`, { status })
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const resetUserPassword = async (id: number | string, newPassword: string) => {
|
||||||
|
const response = await api.post<ApiResponse<null>>(`/users/${id}/reset-password`, {
|
||||||
|
new_password: newPassword
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const deleteUser = async (id: number | string) => {
|
||||||
|
const response = await api.delete<ApiResponse<null>>(`/users/${id}`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
1
frontend/src/assets/vue.svg
Normal file
1
frontend/src/assets/vue.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 496 B |
9
frontend/src/auth/keycloak.ts
Normal file
9
frontend/src/auth/keycloak.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import Keycloak from 'keycloak-js'
|
||||||
|
|
||||||
|
const keycloak = new Keycloak({
|
||||||
|
url: import.meta.env.VITE_KEYCLOAK_URL || 'http://localhost:8080',
|
||||||
|
realm: import.meta.env.VITE_KEYCLOAK_REALM || 'equipment-management',
|
||||||
|
clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID || 'equipment-client'
|
||||||
|
})
|
||||||
|
|
||||||
|
export default keycloak
|
||||||
340
frontend/src/components/BarcodePrint.vue
Normal file
340
frontend/src/components/BarcodePrint.vue
Normal file
@ -0,0 +1,340 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card-apple">
|
||||||
|
<h2 class="text-apple-title mb-4">二维码打印</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div
|
||||||
|
v-for="equipment in equipmentList"
|
||||||
|
:key="equipment.equipment_code"
|
||||||
|
class="qrcode-card"
|
||||||
|
>
|
||||||
|
<!-- 顶部公司名称 -->
|
||||||
|
<div class="qrcode-header">
|
||||||
|
<span class="header-title">{{ siteName }}</span>
|
||||||
|
</div>
|
||||||
|
<!-- 下方内容区 -->
|
||||||
|
<div class="qrcode-body">
|
||||||
|
<!-- 左侧信息 -->
|
||||||
|
<div class="qrcode-info">
|
||||||
|
<p class="info-row">{{ equipment.assetType?.name || equipment.equipment_type || '-' }}</p>
|
||||||
|
<p class="info-row">{{ equipment.equipment_code }}</p>
|
||||||
|
<p class="info-row">{{ formatDate(equipment.created_at) }}</p>
|
||||||
|
<p class="info-row">{{ equipment.equipment_model || '-' }}</p>
|
||||||
|
</div>
|
||||||
|
<!-- 右侧二维码 -->
|
||||||
|
<div class="qrcode-canvas">
|
||||||
|
<canvas :id="`qrcode-${equipment.id}`"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button @click="handlePrint" class="btn-apple-primary flex-1" :disabled="loading">
|
||||||
|
{{ loading ? '生成中...' : '打印二维码' }}
|
||||||
|
</button>
|
||||||
|
<button @click="handleDownload" class="btn-apple-secondary flex-1" :disabled="loading">
|
||||||
|
{{ loading ? '生成中...' : '下载图片' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, nextTick } from 'vue'
|
||||||
|
import { getEquipmentBatchForBarcode } from '@/api/barcode'
|
||||||
|
import { useSiteStore } from '@/stores/site'
|
||||||
|
import QRCode from 'qrcode'
|
||||||
|
import html2canvas from 'html2canvas'
|
||||||
|
|
||||||
|
interface EquipmentBarcode {
|
||||||
|
id: number
|
||||||
|
equipment_code: string
|
||||||
|
equipment_name: string
|
||||||
|
equipment_model?: string
|
||||||
|
equipment_type: string
|
||||||
|
created_at?: string
|
||||||
|
storage_location_id?: number | null
|
||||||
|
storageLocation?: {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
code?: string | null
|
||||||
|
} | null
|
||||||
|
assetType?: {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
equipmentIds: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const siteStore = useSiteStore()
|
||||||
|
const siteName = ref('固定资产管理系统')
|
||||||
|
const equipmentList = ref<EquipmentBarcode[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
// 格式化日期
|
||||||
|
const formatDate = (dateStr?: string) => {
|
||||||
|
if (!dateStr) return '-'
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
return `${year}-${month}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchEquipment = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
// 获取站点名称
|
||||||
|
await siteStore.fetchSiteConfig()
|
||||||
|
siteName.value = siteStore.siteName || '固定资产管理系统'
|
||||||
|
|
||||||
|
const response = await getEquipmentBatchForBarcode(props.equipmentIds)
|
||||||
|
const data = response.data || response
|
||||||
|
|
||||||
|
if (data && Array.isArray(data)) {
|
||||||
|
equipmentList.value = data
|
||||||
|
await nextTick()
|
||||||
|
generateQRCodes()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取资产信息失败:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateQRCodes = async () => {
|
||||||
|
for (const equipment of equipmentList.value) {
|
||||||
|
const canvasElement = document.getElementById(`qrcode-${equipment.id}`) as HTMLCanvasElement
|
||||||
|
if (canvasElement) {
|
||||||
|
try {
|
||||||
|
await QRCode.toCanvas(canvasElement, equipment.equipment_code, {
|
||||||
|
width: 160,
|
||||||
|
margin: 1,
|
||||||
|
color: {
|
||||||
|
dark: '#000000',
|
||||||
|
light: '#ffffff'
|
||||||
|
},
|
||||||
|
errorCorrectionLevel: 'M'
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('二维码生成失败:', equipment.equipment_code, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成高质量图片
|
||||||
|
const generateImages = async (): Promise<string[]> => {
|
||||||
|
const images: string[] = []
|
||||||
|
const cardElements = document.querySelectorAll('.qrcode-card')
|
||||||
|
|
||||||
|
for (let i = 0; i < cardElements.length; i++) {
|
||||||
|
const cardElement = cardElements[i] as HTMLElement
|
||||||
|
if (cardElement) {
|
||||||
|
const canvas = await html2canvas(cardElement, {
|
||||||
|
scale: 4,
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
useCORS: true,
|
||||||
|
logging: false,
|
||||||
|
allowTaint: false
|
||||||
|
})
|
||||||
|
images.push(canvas.toDataURL('image/png', 1.0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return images
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePrint = async () => {
|
||||||
|
if (equipmentList.value.length === 0) {
|
||||||
|
alert('没有可打印的二维码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const images = await generateImages()
|
||||||
|
|
||||||
|
const printWindow = window.open('', '_blank')
|
||||||
|
if (!printWindow) {
|
||||||
|
alert('无法打开打印窗口,请允许弹出窗口')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const printHTML = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>打印二维码</title>
|
||||||
|
<style>
|
||||||
|
@page {
|
||||||
|
size: 5cm 3cm;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
.qrcode-image {
|
||||||
|
width: 5cm;
|
||||||
|
height: 3cm;
|
||||||
|
display: block;
|
||||||
|
page-break-after: always;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
.qrcode-image:last-child {
|
||||||
|
page-break-after: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
${images.map(img => `<img src="${img}" class="qrcode-image" />`).join('')}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`
|
||||||
|
|
||||||
|
printWindow.document.write(printHTML)
|
||||||
|
printWindow.document.close()
|
||||||
|
|
||||||
|
printWindow.onload = () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
printWindow.print()
|
||||||
|
printWindow.close()
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('打印失败:', error)
|
||||||
|
alert('打印失败,请重试')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDownload = async () => {
|
||||||
|
if (equipmentList.value.length === 0) {
|
||||||
|
alert('没有可下载的二维码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const images = await generateImages()
|
||||||
|
|
||||||
|
for (let i = 0; i < images.length; i++) {
|
||||||
|
if (i > 0) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300))
|
||||||
|
}
|
||||||
|
|
||||||
|
const equipment = equipmentList.value[i]
|
||||||
|
const fileName = `${equipment.equipment_code || `qrcode-${i + 1}`}.png`
|
||||||
|
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = images[i]
|
||||||
|
link.download = fileName
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('下载失败:', error)
|
||||||
|
alert('下载失败,请重试')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchEquipment()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.qrcode-card {
|
||||||
|
width: 5cm;
|
||||||
|
height: 3cm;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 0.12cm;
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: relative;
|
||||||
|
margin: 0 auto;
|
||||||
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.05);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-family: "Microsoft YaHei", "PingFang SC", "Helvetica Neue", Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 顶部公司名称 */
|
||||||
|
.qrcode-header {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.08cm 0.05cm;
|
||||||
|
border-bottom: 1px solid #000;
|
||||||
|
margin-bottom: 0.08cm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
font-size: 0.3cm;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #000;
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 下方内容区 */
|
||||||
|
.qrcode-body {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 0.15cm;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 左侧信息区 */
|
||||||
|
.qrcode-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-around;
|
||||||
|
padding: 0.03cm 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.22cm;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #000;
|
||||||
|
line-height: 1.25;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 右侧二维码 */
|
||||||
|
.qrcode-canvas {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 2cm;
|
||||||
|
height: 2cm;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrcode-canvas canvas {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
image-rendering: -webkit-optimize-contrast;
|
||||||
|
image-rendering: crisp-edges;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
106
frontend/src/components/GlobalSearch.vue
Normal file
106
frontend/src/components/GlobalSearch.vue
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative">
|
||||||
|
<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"
|
||||||
|
@focus="showResults = true"
|
||||||
|
@blur="handleBlur"
|
||||||
|
@input="handleSearch"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 搜索结果下拉 -->
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<div v-if="loading" class="p-4 text-center text-system-label-secondary">
|
||||||
|
搜索中...
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<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"
|
||||||
|
@mousedown.prevent="goToDetail(item)"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<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>
|
||||||
|
<span
|
||||||
|
class="px-2 py-0.5 text-xs rounded-full"
|
||||||
|
:class="getStatusClass(item.status)"
|
||||||
|
>
|
||||||
|
{{ item.status }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { MagnifyingGlassIcon } from '@heroicons/vue/24/outline'
|
||||||
|
import { globalSearch, type SearchResult } from '@/api/search'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const keyword = ref('')
|
||||||
|
const results = ref<SearchResult[]>([])
|
||||||
|
const showResults = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
if (searchTimer) clearTimeout(searchTimer)
|
||||||
|
|
||||||
|
if (!keyword.value.trim()) {
|
||||||
|
results.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
searchTimer = setTimeout(async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await globalSearch(keyword.value)
|
||||||
|
results.value = res.data || []
|
||||||
|
} catch (e) {
|
||||||
|
results.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBlur = () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
showResults.value = false
|
||||||
|
}, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
const goToDetail = (item: SearchResult) => {
|
||||||
|
showResults.value = false
|
||||||
|
keyword.value = ''
|
||||||
|
results.value = []
|
||||||
|
router.push(`/equipment?id=${item.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusClass = (status: string) => {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
'可用': 'bg-system-green/10 text-system-green',
|
||||||
|
'已借出': 'bg-system-orange/10 text-system-orange',
|
||||||
|
'维护中': 'bg-system-blue/10 text-system-blue',
|
||||||
|
'待报废': 'bg-system-yellow/10 text-system-yellow',
|
||||||
|
'已报废': 'bg-system-red/10 text-system-red'
|
||||||
|
}
|
||||||
|
return map[status] || 'bg-system-gray-5 text-system-label-secondary'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
41
frontend/src/components/HelloWorld.vue
Normal file
41
frontend/src/components/HelloWorld.vue
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
defineProps<{ msg: string }>()
|
||||||
|
|
||||||
|
const count = ref(0)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<h1>{{ msg }}</h1>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<button type="button" @click="count++">count is {{ count }}</button>
|
||||||
|
<p>
|
||||||
|
Edit
|
||||||
|
<code>components/HelloWorld.vue</code> to test HMR
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Check out
|
||||||
|
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
|
||||||
|
>create-vue</a
|
||||||
|
>, the official Vue + Vite starter
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Learn more about IDE Support for Vue in the
|
||||||
|
<a
|
||||||
|
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
|
||||||
|
target="_blank"
|
||||||
|
>Vue Docs Scaling up Guide</a
|
||||||
|
>.
|
||||||
|
</p>
|
||||||
|
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.read-the-docs {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
122
frontend/src/components/MountHistory.vue
Normal file
122
frontend/src/components/MountHistory.vue
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h3 class="text-apple-headline">挂载历史</h3>
|
||||||
|
|
||||||
|
<div v-if="loading" class="text-center py-8 text-system-label-tertiary">
|
||||||
|
加载中...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="history.length === 0" class="text-center py-8 text-system-label-tertiary">
|
||||||
|
暂无挂载历史记录
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="record in history"
|
||||||
|
:key="record.id"
|
||||||
|
class="p-3 bg-system-gray-6 rounded-apple"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="px-2 py-0.5 rounded text-apple-footnote font-medium"
|
||||||
|
:class="getOperationClass(record.operation_type)"
|
||||||
|
>
|
||||||
|
{{ getOperationLabel(record.operation_type) }}
|
||||||
|
</span>
|
||||||
|
<span class="text-apple-footnote text-system-label-secondary">
|
||||||
|
{{ formatDateTime(record.operated_at) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-apple-body">
|
||||||
|
<template v-if="record.operation_type === 'mount'">
|
||||||
|
<span class="font-medium">{{ record.child?.equipment_name }}</span>
|
||||||
|
挂载到
|
||||||
|
<span class="font-medium">{{ record.parent?.equipment_name || '父资产' }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="record.operation_type === 'unmount'">
|
||||||
|
<span class="font-medium">{{ record.child?.equipment_name }}</span>
|
||||||
|
从父资产卸载
|
||||||
|
</template>
|
||||||
|
<template v-else-if="record.operation_type === 'replace'">
|
||||||
|
配件更换:<span class="font-medium">{{ record.child?.equipment_name }}</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div v-if="record.notes" class="mt-1 text-apple-footnote text-system-label-secondary">
|
||||||
|
备注:{{ record.notes }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-apple-footnote text-system-label-secondary">
|
||||||
|
{{ record.operator?.name || record.operator?.username || '-' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, watch } from 'vue'
|
||||||
|
import { getMountHistory } from '@/api/mount'
|
||||||
|
import type { MountHistory } from '@/api/mount'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
equipmentId: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const history = ref<MountHistory[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const formatDateTime = (dateStr: string) => {
|
||||||
|
if (!dateStr) return '-'
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
return date.toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getOperationLabel = (type: string) => {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
mount: '挂载',
|
||||||
|
unmount: '卸载',
|
||||||
|
replace: '更换'
|
||||||
|
}
|
||||||
|
return labels[type] || type
|
||||||
|
}
|
||||||
|
|
||||||
|
const getOperationClass = (type: string) => {
|
||||||
|
const classes: Record<string, string> = {
|
||||||
|
mount: 'bg-apple-green/15 text-apple-green',
|
||||||
|
unmount: 'bg-apple-orange/15 text-apple-orange',
|
||||||
|
replace: 'bg-apple-blue/15 text-apple-blue'
|
||||||
|
}
|
||||||
|
return classes[type] || 'bg-system-gray-5 text-system-label-secondary'
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchHistory = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const response = await getMountHistory(props.equipmentId)
|
||||||
|
if (response.data) {
|
||||||
|
history.value = response.data
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取挂载历史失败:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.equipmentId, () => {
|
||||||
|
fetchHistory()
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchHistory()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
208
frontend/src/components/MountManager.vue
Normal file
208
frontend/src/components/MountManager.vue
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-apple-headline">已挂载配件</h3>
|
||||||
|
<button class="btn-apple-primary text-sm" @click="showMountModal = true">+ 挂载配件</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="children.length === 0" class="text-center py-8 text-system-label-tertiary">
|
||||||
|
暂无挂载的配件
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="child in children"
|
||||||
|
:key="child.id"
|
||||||
|
class="flex items-center justify-between p-3 bg-system-gray-6 rounded-apple"
|
||||||
|
>
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="font-medium">{{ child.equipment_name }}</div>
|
||||||
|
<div class="text-apple-footnote text-system-label-secondary">
|
||||||
|
{{ child.equipment_code }} · {{ child.equipment_type }} · ¥{{ child.unit_price }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button class="text-apple-orange text-sm hover:underline" @click="handleReplace(child)">更换</button>
|
||||||
|
<button class="text-apple-red text-sm hover:underline" @click="handleUnmount(child)">卸载</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="children.length > 0" class="pt-2 border-t border-system-gray-5 text-apple-footnote text-system-label-secondary">
|
||||||
|
配件总价值:¥{{ totalChildrenValue.toFixed(2) }} ·
|
||||||
|
含配件总价值:¥{{ totalValueWithChildren.toFixed(2) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 挂载配件弹窗 -->
|
||||||
|
<div v-if="showMountModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm px-4">
|
||||||
|
<div class="w-full max-w-2xl rounded-apple bg-system-background-primary p-6 shadow-apple-xl max-h-[80vh] overflow-y-auto">
|
||||||
|
<h3 class="text-apple-title mb-4">选择要挂载的配件</h3>
|
||||||
|
<input
|
||||||
|
v-model="searchKeyword"
|
||||||
|
class="input-apple mb-4"
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索配件名称或编码"
|
||||||
|
@input="fetchAvailableChildren"
|
||||||
|
/>
|
||||||
|
<div v-if="availableChildren.length === 0" class="text-center py-8 text-system-label-tertiary">
|
||||||
|
没有可挂载的配件
|
||||||
|
</div>
|
||||||
|
<div v-else class="space-y-2 max-h-60 overflow-y-auto">
|
||||||
|
<div
|
||||||
|
v-for="item in availableChildren"
|
||||||
|
:key="item.id"
|
||||||
|
class="flex items-center justify-between p-3 bg-system-gray-6 rounded-apple cursor-pointer hover:bg-system-gray-5"
|
||||||
|
@click="selectChild(item)"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">{{ item.equipment_name }}</div>
|
||||||
|
<div class="text-apple-footnote text-system-label-secondary">
|
||||||
|
{{ item.equipment_code }} · {{ item.equipment_type }} · ¥{{ item.unit_price }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex justify-end gap-3">
|
||||||
|
<button class="btn-apple-secondary" @click="showMountModal = false">取消</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 更换配件弹窗 -->
|
||||||
|
<div v-if="showReplaceModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm px-4">
|
||||||
|
<div class="w-full max-w-2xl rounded-apple bg-system-background-primary p-6 shadow-apple-xl max-h-[80vh] overflow-y-auto">
|
||||||
|
<h3 class="text-apple-title mb-4">更换配件:{{ replacingChild?.equipment_name }}</h3>
|
||||||
|
<input
|
||||||
|
v-model="searchKeyword"
|
||||||
|
class="input-apple mb-4"
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索新配件"
|
||||||
|
@input="fetchAvailableChildren"
|
||||||
|
/>
|
||||||
|
<div v-if="availableChildren.length === 0" class="text-center py-8 text-system-label-tertiary">
|
||||||
|
没有可用的配件
|
||||||
|
</div>
|
||||||
|
<div v-else class="space-y-2 max-h-60 overflow-y-auto">
|
||||||
|
<div
|
||||||
|
v-for="item in availableChildren"
|
||||||
|
:key="item.id"
|
||||||
|
class="flex items-center justify-between p-3 bg-system-gray-6 rounded-apple cursor-pointer hover:bg-system-gray-5"
|
||||||
|
@click="confirmReplace(item)"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">{{ item.equipment_name }}</div>
|
||||||
|
<div class="text-apple-footnote text-system-label-secondary">
|
||||||
|
{{ item.equipment_code }} · {{ item.equipment_type }} · ¥{{ item.unit_price }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex justify-end gap-3">
|
||||||
|
<button class="btn-apple-secondary" @click="showReplaceModal = false">取消</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { getChildren, getAvailableChildren, mountChild, unmountChild, replaceChild } from '@/api/mount'
|
||||||
|
import type { ChildEquipment } from '@/api/mount'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
parentId: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'updated'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const children = ref<ChildEquipment[]>([])
|
||||||
|
const totalChildrenValue = ref(0)
|
||||||
|
const totalValueWithChildren = ref(0)
|
||||||
|
const availableChildren = ref<ChildEquipment[]>([])
|
||||||
|
const showMountModal = ref(false)
|
||||||
|
const showReplaceModal = ref(false)
|
||||||
|
const searchKeyword = ref('')
|
||||||
|
const replacingChild = ref<ChildEquipment | null>(null)
|
||||||
|
|
||||||
|
const fetchChildren = async () => {
|
||||||
|
try {
|
||||||
|
console.log('fetchChildren called, parentId:', props.parentId)
|
||||||
|
const response = await getChildren(props.parentId)
|
||||||
|
console.log('getChildren response:', response)
|
||||||
|
if (response.data) {
|
||||||
|
children.value = response.data.children || []
|
||||||
|
totalChildrenValue.value = Number(response.data.total_children_value) || 0
|
||||||
|
totalValueWithChildren.value = Number(response.data.total_value_with_children) || 0
|
||||||
|
console.log('children updated:', children.value)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取子资产失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchAvailableChildren = async () => {
|
||||||
|
try {
|
||||||
|
const response = await getAvailableChildren({ keyword: searchKeyword.value || undefined })
|
||||||
|
if (response.data) {
|
||||||
|
availableChildren.value = response.data
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取可挂载配件失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectChild = async (child: ChildEquipment) => {
|
||||||
|
if (!confirm(`确定要挂载 "${child.equipment_name}" 吗?`)) return
|
||||||
|
try {
|
||||||
|
await mountChild(props.parentId, { child_id: child.id })
|
||||||
|
showMountModal.value = false
|
||||||
|
await fetchChildren()
|
||||||
|
emit('updated')
|
||||||
|
} catch (error: any) {
|
||||||
|
alert(error.message || '挂载失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUnmount = async (child: ChildEquipment) => {
|
||||||
|
if (!confirm(`确定要卸载 "${child.equipment_name}" 吗?`)) return
|
||||||
|
try {
|
||||||
|
await unmountChild(props.parentId, { child_id: child.id })
|
||||||
|
await fetchChildren()
|
||||||
|
emit('updated')
|
||||||
|
} catch (error: any) {
|
||||||
|
alert(error.message || '卸载失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReplace = (child: ChildEquipment) => {
|
||||||
|
replacingChild.value = child
|
||||||
|
searchKeyword.value = ''
|
||||||
|
fetchAvailableChildren()
|
||||||
|
showReplaceModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmReplace = async (newChild: ChildEquipment) => {
|
||||||
|
if (!replacingChild.value) return
|
||||||
|
if (!confirm(`确定要用 "${newChild.equipment_name}" 替换 "${replacingChild.value.equipment_name}" 吗?`)) return
|
||||||
|
try {
|
||||||
|
await replaceChild(props.parentId, {
|
||||||
|
old_child_id: replacingChild.value.id,
|
||||||
|
new_child_id: newChild.id
|
||||||
|
})
|
||||||
|
showReplaceModal.value = false
|
||||||
|
replacingChild.value = null
|
||||||
|
await fetchChildren()
|
||||||
|
emit('updated')
|
||||||
|
} catch (error: any) {
|
||||||
|
alert(error.message || '更换失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchChildren()
|
||||||
|
fetchAvailableChildren()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
68
frontend/src/components/StatCard.vue
Normal file
68
frontend/src/components/StatCard.vue
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
<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"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-if="trend"
|
||||||
|
class="text-xs px-2 py-1 rounded-apple"
|
||||||
|
: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>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { Component } from 'vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string
|
||||||
|
value: string | number
|
||||||
|
icon?: string | Component
|
||||||
|
color?: string
|
||||||
|
trend?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
color: 'apple-blue',
|
||||||
|
trend: null
|
||||||
|
})
|
||||||
|
|
||||||
|
const iconString = computed(() =>
|
||||||
|
typeof props.icon === 'string' ? props.icon : ''
|
||||||
|
)
|
||||||
|
|
||||||
|
const iconComponent = computed<Component | null>(() =>
|
||||||
|
props.icon && typeof props.icon !== 'string' ? props.icon : null
|
||||||
|
)
|
||||||
|
|
||||||
|
const getColorClass = () => {
|
||||||
|
const colorMap: 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'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.stat-value {
|
||||||
|
font-size: clamp(1.75rem, 1.5rem + 1vw, 2.375rem);
|
||||||
|
line-height: 1.1;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
96
frontend/src/layouts/MainLayout.vue
Normal file
96
frontend/src/layouts/MainLayout.vue
Normal file
@ -0,0 +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>
|
||||||
|
|
||||||
|
<!-- 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">
|
||||||
|
<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 }"
|
||||||
|
>
|
||||||
|
<component :is="item.icon" class="w-5 h-5" />
|
||||||
|
<span class="text-apple-body">{{ item.title }}</span>
|
||||||
|
</router-link>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="flex-1 p-6 min-w-0 overflow-hidden">
|
||||||
|
<router-view />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted } from 'vue'
|
||||||
|
import { 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 authStore = useAuthStore()
|
||||||
|
const siteStore = useSiteStore()
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ path: '/dashboard', title: '首页', icon: HomeIcon },
|
||||||
|
{ path: '/equipment', title: '资产管理', icon: CubeIcon, requiresAdmin: true },
|
||||||
|
{ path: '/borrow', title: '使用管理', icon: ArrowRightIcon },
|
||||||
|
{ path: '/statistics', title: '统计分析', icon: ChartBarIcon },
|
||||||
|
{ path: '/settings', title: '系统设置', icon: Cog6ToothIcon, requiresAdmin: true }
|
||||||
|
]
|
||||||
|
|
||||||
|
const visibleNavItems = computed(() => {
|
||||||
|
const isAdmin = authStore.user?.role === 'admin'
|
||||||
|
return navItems.filter((item) => !item.requiresAdmin || isAdmin)
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await authStore.logout()
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
92
frontend/src/main.ts
Normal file
92
frontend/src/main.ts
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
import './style.css'
|
||||||
|
import keycloak from './auth/keycloak'
|
||||||
|
import { useAuthStore } from './stores/auth'
|
||||||
|
import api from './api'
|
||||||
|
import type { User } from './types'
|
||||||
|
|
||||||
|
const bootstrap = async () => {
|
||||||
|
const app = createApp(App)
|
||||||
|
const pinia = createPinia()
|
||||||
|
app.use(pinia)
|
||||||
|
setActivePinia(pinia)
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const authMode = import.meta.env.VITE_AUTH_MODE || 'keycloak'
|
||||||
|
|
||||||
|
if (authMode === 'local') {
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
authStore.$patch({
|
||||||
|
token: null,
|
||||||
|
user: null,
|
||||||
|
isAuthenticated: false
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
const resp = await api.get('/auth/user')
|
||||||
|
if (resp?.data?.data) {
|
||||||
|
const user = resp.data.data
|
||||||
|
const normalizedUser: User = {
|
||||||
|
...user,
|
||||||
|
id: String(user.id ?? user.username ?? ''),
|
||||||
|
username: user.username || '',
|
||||||
|
name: user.name || user.username || '',
|
||||||
|
role: user.role || (user.roles?.includes('admin') ? 'admin' : 'user'),
|
||||||
|
roles: user.roles || []
|
||||||
|
}
|
||||||
|
authStore.setUser(normalizedUser)
|
||||||
|
authStore.setToken('session')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore, user not logged in
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use(router)
|
||||||
|
app.mount('#app')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const authenticated = await keycloak.init({
|
||||||
|
onLoad: 'login-required',
|
||||||
|
checkLoginIframe: false,
|
||||||
|
pkceMethod: 'S256'
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!authenticated) {
|
||||||
|
await keycloak.login()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keycloak.token) {
|
||||||
|
authStore.setToken(keycloak.token)
|
||||||
|
}
|
||||||
|
|
||||||
|
const roles = (keycloak.tokenParsed?.realm_access?.roles as string[]) || []
|
||||||
|
authStore.setUser({
|
||||||
|
id: (keycloak.tokenParsed?.sub as string) || '',
|
||||||
|
username: (keycloak.tokenParsed?.preferred_username as string) || '',
|
||||||
|
name: (keycloak.tokenParsed?.name as string) || (keycloak.tokenParsed?.preferred_username as string) || '',
|
||||||
|
role: roles.includes('admin') ? 'admin' : 'user',
|
||||||
|
email: (keycloak.tokenParsed?.email as string) || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// 定期刷新token
|
||||||
|
setInterval(() => {
|
||||||
|
keycloak.updateToken(60).catch(() => {
|
||||||
|
keycloak.login()
|
||||||
|
})
|
||||||
|
}, 60000)
|
||||||
|
|
||||||
|
app.use(router)
|
||||||
|
app.mount('#app')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Keycloak 初始化失败:', error)
|
||||||
|
await keycloak.login()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap()
|
||||||
109
frontend/src/router/index.ts
Normal file
109
frontend/src/router/index.ts
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import type { RouteRecordRaw } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import keycloak from '@/auth/keycloak'
|
||||||
|
|
||||||
|
const routes: RouteRecordRaw[] = [
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'Login',
|
||||||
|
component: () => import('../views/Login.vue'),
|
||||||
|
meta: { requiresAuth: false }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
component: () => import('../layouts/MainLayout.vue'),
|
||||||
|
redirect: '/dashboard',
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: 'dashboard',
|
||||||
|
name: 'Dashboard',
|
||||||
|
component: () => import('../views/dashboard/Dashboard.vue'),
|
||||||
|
meta: { title: '首页' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'equipment',
|
||||||
|
name: 'Equipment',
|
||||||
|
component: () => import('../views/equipment/EquipmentList.vue'),
|
||||||
|
meta: { title: '设备管理', requiresAdmin: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'equipment/add',
|
||||||
|
name: 'EquipmentAdd',
|
||||||
|
component: () => import('../views/equipment/EquipmentForm.vue'),
|
||||||
|
meta: { title: '新增设备', requiresAdmin: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'equipment/edit/:id',
|
||||||
|
name: 'EquipmentEdit',
|
||||||
|
component: () => import('../views/equipment/EquipmentForm.vue'),
|
||||||
|
meta: { title: '编辑设备', requiresAdmin: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'borrow',
|
||||||
|
name: 'Borrow',
|
||||||
|
component: () => import('../views/borrow/BorrowList.vue'),
|
||||||
|
meta: { title: '借用管理' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'borrow/scan',
|
||||||
|
name: 'BorrowScan',
|
||||||
|
component: () => import('../views/borrow/BorrowScan.vue'),
|
||||||
|
meta: { title: '扫码出库' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'return/scan',
|
||||||
|
name: 'ReturnScan',
|
||||||
|
component: () => import('../views/borrow/ReturnScan.vue'),
|
||||||
|
meta: { title: '扫码入库' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'statistics',
|
||||||
|
name: 'Statistics',
|
||||||
|
component: () => import('../views/statistics/Statistics.vue'),
|
||||||
|
meta: { title: '统计分析' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'settings',
|
||||||
|
name: 'Settings',
|
||||||
|
component: () => import('../views/settings/Settings.vue'),
|
||||||
|
meta: { title: '系统设置', requiresAdmin: true }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes
|
||||||
|
})
|
||||||
|
|
||||||
|
router.beforeEach((to, _from, next) => {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const authMode = import.meta.env.VITE_AUTH_MODE || 'keycloak'
|
||||||
|
|
||||||
|
if (to.path === '/login' && authStore.isAuthenticated) {
|
||||||
|
next('/dashboard')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
||||||
|
if (authMode === 'local') {
|
||||||
|
next('/login')
|
||||||
|
} else {
|
||||||
|
keycloak.login()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to.meta.requiresAdmin && authStore.user?.role !== 'admin') {
|
||||||
|
next('/dashboard')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
|
|
||||||
5
frontend/src/shims-vue.d.ts
vendored
Normal file
5
frontend/src/shims-vue.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
declare module '*.vue' {
|
||||||
|
import type { DefineComponent } from 'vue'
|
||||||
|
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, any>
|
||||||
|
export default component
|
||||||
|
}
|
||||||
49
frontend/src/stores/auth.ts
Normal file
49
frontend/src/stores/auth.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import type { User } from '@/types'
|
||||||
|
import keycloak from '@/auth/keycloak'
|
||||||
|
import api from '@/api'
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
user: User | null
|
||||||
|
token: string | null
|
||||||
|
isAuthenticated: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore('auth', {
|
||||||
|
state: (): AuthState => ({
|
||||||
|
user: null,
|
||||||
|
token: localStorage.getItem('token'),
|
||||||
|
isAuthenticated: !!localStorage.getItem('token')
|
||||||
|
}),
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
setUser(user: User) {
|
||||||
|
this.user = user
|
||||||
|
this.isAuthenticated = true
|
||||||
|
},
|
||||||
|
|
||||||
|
setToken(token: string) {
|
||||||
|
this.token = token
|
||||||
|
this.isAuthenticated = true
|
||||||
|
localStorage.setItem('token', token)
|
||||||
|
},
|
||||||
|
|
||||||
|
async logout() {
|
||||||
|
this.user = null
|
||||||
|
this.token = null
|
||||||
|
this.isAuthenticated = false
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
try {
|
||||||
|
const authMode = import.meta.env.VITE_AUTH_MODE || 'keycloak'
|
||||||
|
if (authMode === 'local') {
|
||||||
|
await api.post('/auth/logout')
|
||||||
|
} else if (keycloak.authenticated) {
|
||||||
|
await keycloak.logout()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Keycloak 登出失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
42
frontend/src/stores/equipment.ts
Normal file
42
frontend/src/stores/equipment.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import type { Equipment } from '@/types'
|
||||||
|
import { getEquipmentList } from '@/api/equipment'
|
||||||
|
|
||||||
|
interface EquipmentState {
|
||||||
|
equipmentList: Equipment[]
|
||||||
|
total: number
|
||||||
|
loading: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useEquipmentStore = defineStore('equipment', {
|
||||||
|
state: (): EquipmentState => ({
|
||||||
|
equipmentList: [],
|
||||||
|
total: 0,
|
||||||
|
loading: false
|
||||||
|
}),
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
async fetchEquipmentList(params?: {
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
equipmentType?: string
|
||||||
|
status?: string
|
||||||
|
keyword?: string
|
||||||
|
storageLocationId?: number
|
||||||
|
}) {
|
||||||
|
this.loading = true
|
||||||
|
try {
|
||||||
|
const response = await getEquipmentList(params || {})
|
||||||
|
if (response.data) {
|
||||||
|
this.equipmentList = response.data.list
|
||||||
|
this.total = response.data.total
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取设备列表失败:', error)
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
142
frontend/src/stores/site.ts
Normal file
142
frontend/src/stores/site.ts
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { getSiteConfig } from '@/api/settings'
|
||||||
|
import type { SiteConfigResponse } from '@/api/settings'
|
||||||
|
|
||||||
|
const DEFAULT_SITE_NAME = '固定资产管理系统'
|
||||||
|
const DEFAULT_THEME_COLOR = '#409EFF'
|
||||||
|
const DEFAULT_LOGO_URL = 'https://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png'
|
||||||
|
|
||||||
|
const normalizeHex = (color: string) => {
|
||||||
|
if (!color) return null
|
||||||
|
const hex = color.trim()
|
||||||
|
const shortHexMatch = /^#?[0-9A-Fa-f]{3}$/
|
||||||
|
const longHexMatch = /^#?[0-9A-Fa-f]{6}$/
|
||||||
|
if (!shortHexMatch.test(hex) && !longHexMatch.test(hex)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const value = hex.startsWith('#') ? hex.slice(1) : hex
|
||||||
|
const expanded =
|
||||||
|
value.length === 3
|
||||||
|
? value
|
||||||
|
.split('')
|
||||||
|
.map((char) => char + char)
|
||||||
|
.join('')
|
||||||
|
: value
|
||||||
|
return `#${expanded.toUpperCase()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const hexChannel = (hex: string, index: number) => parseInt(hex.slice(index, index + 2), 16)
|
||||||
|
|
||||||
|
const mixChannel = (channel: number, amount: number, target: number) =>
|
||||||
|
Math.round(channel + (target - channel) * amount)
|
||||||
|
|
||||||
|
const lightenHex = (hex: string, amount: number) => {
|
||||||
|
const normalized = normalizeHex(hex)
|
||||||
|
if (!normalized) return hex
|
||||||
|
const r = hexChannel(normalized, 1)
|
||||||
|
const g = hexChannel(normalized, 3)
|
||||||
|
const b = hexChannel(normalized, 5)
|
||||||
|
const nr = mixChannel(r, amount, 255)
|
||||||
|
const ng = mixChannel(g, amount, 255)
|
||||||
|
const nb = mixChannel(b, amount, 255)
|
||||||
|
return `#${nr.toString(16).padStart(2, '0')}${ng.toString(16).padStart(2, '0')}${nb
|
||||||
|
.toString(16)
|
||||||
|
.padStart(2, '0')}`.toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
const darkenHex = (hex: string, amount: number) => {
|
||||||
|
const normalized = normalizeHex(hex)
|
||||||
|
if (!normalized) return hex
|
||||||
|
const r = hexChannel(normalized, 1)
|
||||||
|
const g = hexChannel(normalized, 3)
|
||||||
|
const b = hexChannel(normalized, 5)
|
||||||
|
const nr = mixChannel(r, amount, 0)
|
||||||
|
const ng = mixChannel(g, amount, 0)
|
||||||
|
const nb = mixChannel(b, amount, 0)
|
||||||
|
return `#${nr.toString(16).padStart(2, '0')}${ng.toString(16).padStart(2, '0')}${nb
|
||||||
|
.toString(16)
|
||||||
|
.padStart(2, '0')}`.toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
const hexToRgba = (hex: string, alpha: number) => {
|
||||||
|
const normalized = normalizeHex(hex)
|
||||||
|
if (!normalized) return `rgba(0, 122, 255, ${alpha})`
|
||||||
|
const r = hexChannel(normalized, 1)
|
||||||
|
const g = hexChannel(normalized, 3)
|
||||||
|
const b = hexChannel(normalized, 5)
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyThemeColor = (color: string) => {
|
||||||
|
if (typeof document === 'undefined') return
|
||||||
|
const normalized = normalizeHex(color) || DEFAULT_THEME_COLOR
|
||||||
|
const root = document.documentElement
|
||||||
|
root.style.setProperty('--theme-primary', normalized)
|
||||||
|
root.style.setProperty('--theme-primary-hover', lightenHex(normalized, 0.1))
|
||||||
|
root.style.setProperty('--theme-primary-active', darkenHex(normalized, 0.15))
|
||||||
|
root.style.setProperty('--theme-primary-ring', hexToRgba(normalized, 0.35))
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SiteState {
|
||||||
|
siteName: string
|
||||||
|
logoUrl: string
|
||||||
|
faviconUrl: string
|
||||||
|
themeColor: string
|
||||||
|
loaded: boolean
|
||||||
|
updatedAt: string | null
|
||||||
|
updatedBy: SiteConfigResponse['updated_by']
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSiteStore = defineStore('site', {
|
||||||
|
state: (): SiteState => ({
|
||||||
|
siteName: DEFAULT_SITE_NAME,
|
||||||
|
logoUrl: DEFAULT_LOGO_URL,
|
||||||
|
faviconUrl: '',
|
||||||
|
themeColor: DEFAULT_THEME_COLOR,
|
||||||
|
loaded: false,
|
||||||
|
updatedAt: null,
|
||||||
|
updatedBy: null
|
||||||
|
}),
|
||||||
|
actions: {
|
||||||
|
setSiteConfig(config: SiteConfigResponse) {
|
||||||
|
this.siteName = config.site_name || DEFAULT_SITE_NAME
|
||||||
|
this.logoUrl = config.logo_url || DEFAULT_LOGO_URL
|
||||||
|
this.faviconUrl = config.favicon_url || ''
|
||||||
|
this.themeColor = config.theme_color || DEFAULT_THEME_COLOR
|
||||||
|
this.updatedAt = config.updated_at
|
||||||
|
this.updatedBy = config.updated_by
|
||||||
|
this.loaded = true
|
||||||
|
applyThemeColor(this.themeColor)
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.title = this.siteName
|
||||||
|
if (this.faviconUrl) {
|
||||||
|
this.updateFavicon(this.faviconUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updateFavicon(iconUrl: string) {
|
||||||
|
if (typeof document === 'undefined') return
|
||||||
|
const link =
|
||||||
|
(document.querySelector("link[rel*='icon']") as HTMLLinkElement | null) ||
|
||||||
|
document.createElement('link')
|
||||||
|
link.type = 'image/x-icon'
|
||||||
|
link.rel = 'shortcut icon'
|
||||||
|
link.href = iconUrl
|
||||||
|
if (!link.parentNode) {
|
||||||
|
document.getElementsByTagName('head')[0].appendChild(link)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async fetchSiteConfig(force = false) {
|
||||||
|
if (this.loaded && !force) return
|
||||||
|
try {
|
||||||
|
const response = await getSiteConfig()
|
||||||
|
if (response.data) {
|
||||||
|
this.setSiteConfig(response.data)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[site] 获取站点配置失败', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
190
frontend/src/style.css
Normal file
190
frontend/src/style.css
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@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);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
@apply antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
@apply font-semibold text-system-label-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
@apply text-3xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
@apply text-2xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
@apply text-xl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-apple-primary {
|
||||||
|
@apply btn-apple text-white;
|
||||||
|
background-color: var(--theme-primary);
|
||||||
|
}
|
||||||
|
.btn-apple-primary:hover {
|
||||||
|
background-color: var(--theme-primary-hover);
|
||||||
|
}
|
||||||
|
.btn-apple-primary:active {
|
||||||
|
background-color: var(--theme-primary-active);
|
||||||
|
}
|
||||||
|
.btn-apple-primary:disabled {
|
||||||
|
@apply opacity-60 cursor-not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-apple-danger {
|
||||||
|
@apply btn-apple bg-apple-red text-white;
|
||||||
|
@apply hover:bg-opacity-90;
|
||||||
|
@apply active:bg-opacity-80;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apple Card Styles */
|
||||||
|
.card-apple {
|
||||||
|
@apply bg-system-background-primary rounded-apple-lg shadow-apple p-6;
|
||||||
|
@apply border border-system-gray-5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-apple-elevated {
|
||||||
|
@apply card-apple shadow-apple-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-apple:focus {
|
||||||
|
border-color: transparent;
|
||||||
|
box-shadow: 0 0 0 2px var(--theme-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apple Tab Bar */
|
||||||
|
.tab-bar-apple {
|
||||||
|
@apply bg-system-background-primary border-t border-system-gray-5;
|
||||||
|
@apply backdrop-blur-apple bg-opacity-80;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apple Design System Utilities */
|
||||||
|
@layer utilities {
|
||||||
|
.text-apple-large-title {
|
||||||
|
@apply text-4xl font-bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-apple-title {
|
||||||
|
@apply text-2xl font-semibold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-apple-title-2 {
|
||||||
|
@apply text-xl font-semibold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-apple-title-3 {
|
||||||
|
@apply text-lg font-semibold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-apple-headline {
|
||||||
|
@apply text-base font-semibold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-apple-body {
|
||||||
|
@apply text-base font-normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-apple-callout {
|
||||||
|
@apply text-base font-medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-apple-subhead {
|
||||||
|
@apply text-sm font-normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-apple-footnote {
|
||||||
|
@apply text-xs font-normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-apple-caption {
|
||||||
|
@apply text-xs font-normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Scrollbar - Apple Style */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
@apply bg-system-background-secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
@apply bg-system-gray-4 rounded-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
@apply bg-system-gray-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus Ring - Apple Style */
|
||||||
|
*:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 2px var(--theme-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selection - Apple Style */
|
||||||
|
::selection {
|
||||||
|
background-color: var(--theme-primary);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
231
frontend/src/types/index.ts
Normal file
231
frontend/src/types/index.ts
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
// 资产类型(动态加载,不再硬编码)
|
||||||
|
export type EquipmentType = string
|
||||||
|
|
||||||
|
// 资产状态
|
||||||
|
export type EquipmentStatus = '可用' | '已借出' | '维护中' | '已损坏' | '待报废' | '已报废'
|
||||||
|
|
||||||
|
// 编码生成方式
|
||||||
|
export type CodeGenerationMode = 'auto' | 'manual'
|
||||||
|
|
||||||
|
export interface StorageLocation {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
code?: string | null
|
||||||
|
status: 'active' | 'inactive'
|
||||||
|
description?: string | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
_count?: {
|
||||||
|
equipments: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 资产类型接口
|
||||||
|
export interface AssetType {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
short_name?: string | null // 简称,用于标签编码
|
||||||
|
status: 'active' | 'inactive'
|
||||||
|
can_be_parent?: boolean
|
||||||
|
can_be_child?: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
_count?: {
|
||||||
|
equipments: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用部门接口
|
||||||
|
export interface Department {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
status: 'active' | 'inactive'
|
||||||
|
parent_id?: number | null
|
||||||
|
parent_name?: string | null
|
||||||
|
level?: number // 0=根部门, 1=子部门
|
||||||
|
parent?: {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
} | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
_count?: {
|
||||||
|
equipments: number
|
||||||
|
children?: number
|
||||||
|
}
|
||||||
|
equipment_count?: number
|
||||||
|
children_count?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// 部门树形结构节点
|
||||||
|
export interface DepartmentTreeNode {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
status: 'active' | 'inactive'
|
||||||
|
parent_id: number | null
|
||||||
|
equipment_count: number
|
||||||
|
children_count: number
|
||||||
|
children: DepartmentTreeNode[]
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设备接口
|
||||||
|
export interface Equipment {
|
||||||
|
id: number
|
||||||
|
equipment_code: string
|
||||||
|
equipment_name: string
|
||||||
|
equipment_model?: string
|
||||||
|
equipment_type: EquipmentType
|
||||||
|
code_generation_mode: CodeGenerationMode
|
||||||
|
status: EquipmentStatus
|
||||||
|
total_quantity: number
|
||||||
|
remaining_quantity: number
|
||||||
|
unit_price: number
|
||||||
|
total_value: number
|
||||||
|
borrowed_value: number
|
||||||
|
available_value: number
|
||||||
|
description?: string
|
||||||
|
purchase_date?: string
|
||||||
|
supplier?: string
|
||||||
|
storage_location_id?: number | null
|
||||||
|
storageLocation?: StorageLocation | null
|
||||||
|
department_id?: number | null
|
||||||
|
department?: Department | null
|
||||||
|
asset_type_id?: number | null
|
||||||
|
assetType?: AssetType | null
|
||||||
|
// 父子资产关联字段
|
||||||
|
parent_id?: number | null
|
||||||
|
mount_status?: 'none' | 'mounted' | 'unmounted'
|
||||||
|
total_value_with_children?: number
|
||||||
|
parent?: {
|
||||||
|
id: number
|
||||||
|
equipment_code: string
|
||||||
|
equipment_name: string
|
||||||
|
equipment_model?: string
|
||||||
|
unit_price?: number
|
||||||
|
} | null
|
||||||
|
children?: Array<{
|
||||||
|
id: number
|
||||||
|
equipment_code: string
|
||||||
|
equipment_name: string
|
||||||
|
equipment_model?: string
|
||||||
|
equipment_type?: string
|
||||||
|
unit_price?: number
|
||||||
|
status?: string
|
||||||
|
mount_status?: string
|
||||||
|
}>
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 借用记录接口
|
||||||
|
export interface Person {
|
||||||
|
id: number
|
||||||
|
nickname: string
|
||||||
|
real_name: string
|
||||||
|
id_number?: string
|
||||||
|
department?: string
|
||||||
|
position?: string
|
||||||
|
phone?: string
|
||||||
|
email?: string
|
||||||
|
status: 'active' | 'inactive'
|
||||||
|
remarks?: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BorrowRecord {
|
||||||
|
id: number
|
||||||
|
equipment_id: number
|
||||||
|
equipment_code: string
|
||||||
|
equipment_name?: string
|
||||||
|
storage_location_id?: number | null
|
||||||
|
storageLocation?: StorageLocation | null
|
||||||
|
person_id: number
|
||||||
|
person_name: string
|
||||||
|
person_nickname: string
|
||||||
|
person_identifier?: string
|
||||||
|
person?: Person
|
||||||
|
borrow_time: string
|
||||||
|
expected_return_time?: string
|
||||||
|
is_long_term?: boolean
|
||||||
|
return_time?: string
|
||||||
|
status: '已借出' | '已归还' | '逾期'
|
||||||
|
return_condition?: '正常' | '损坏' | '丢失'
|
||||||
|
notes?: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户接口
|
||||||
|
export interface User {
|
||||||
|
id: number | string
|
||||||
|
username: string
|
||||||
|
name: string
|
||||||
|
role: 'admin' | 'user'
|
||||||
|
email?: string
|
||||||
|
phone?: string
|
||||||
|
roles?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// API响应接口
|
||||||
|
export interface ApiResponse<T = any> {
|
||||||
|
code: number
|
||||||
|
message: string
|
||||||
|
data: T
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页响应接口
|
||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
list: T[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统计数据接口
|
||||||
|
export interface DashboardStats {
|
||||||
|
total_equipment: number
|
||||||
|
available_equipment: number
|
||||||
|
borrowed_equipment: number
|
||||||
|
maintenance_equipment: number
|
||||||
|
usage_rate: number
|
||||||
|
today_borrow: number
|
||||||
|
today_return: number
|
||||||
|
total_value: number
|
||||||
|
borrowed_value: number
|
||||||
|
available_value: number
|
||||||
|
maintenance_value: number
|
||||||
|
value_usage_rate: number
|
||||||
|
today_borrow_value: number
|
||||||
|
today_return_value: number
|
||||||
|
inventory_by_type: Array<{
|
||||||
|
equipment_type: string
|
||||||
|
total_quantity: number
|
||||||
|
available_quantity: number
|
||||||
|
borrowed_quantity: number
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
// 资产价值统计接口
|
||||||
|
export interface ValueStatistics {
|
||||||
|
total_value: number
|
||||||
|
borrowed_value: number
|
||||||
|
available_value: number
|
||||||
|
maintenance_value: number
|
||||||
|
value_by_type: Array<{
|
||||||
|
equipment_type: string
|
||||||
|
total_value: number
|
||||||
|
borrowed_value: number
|
||||||
|
available_value: number
|
||||||
|
}>
|
||||||
|
top_equipment: Array<{
|
||||||
|
equipment_name: string
|
||||||
|
unit_price: number
|
||||||
|
total_value: number
|
||||||
|
borrowed_value: number
|
||||||
|
available_value: number
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
86
frontend/src/utils/format.ts
Normal file
86
frontend/src/utils/format.ts
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
// 格式化金额
|
||||||
|
export const formatCurrency = (amount: number, options?: { fractionDigits?: number }): string => {
|
||||||
|
const fractionDigits = options?.fractionDigits ?? 2
|
||||||
|
return new Intl.NumberFormat('zh-CN', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'CNY',
|
||||||
|
minimumFractionDigits: fractionDigits,
|
||||||
|
maximumFractionDigits: fractionDigits
|
||||||
|
}).format(amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatCurrencyNoFraction = (amount: number): string => {
|
||||||
|
return formatCurrency(amount, { fractionDigits: 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化数字(千分位)
|
||||||
|
export const formatNumber = (num: number): string => {
|
||||||
|
return new Intl.NumberFormat('zh-CN').format(num)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化日期
|
||||||
|
export const formatDate = (date: string | Date): string => {
|
||||||
|
const d = typeof date === 'string' ? new Date(date) : date
|
||||||
|
return new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit'
|
||||||
|
}).format(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化日期时间
|
||||||
|
export const formatDateTime = (date: string | Date): string => {
|
||||||
|
const d = typeof date === 'string' ? new Date(date) : date
|
||||||
|
return new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
}).format(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化百分比
|
||||||
|
export const formatPercent = (value: number): string => {
|
||||||
|
return `${value.toFixed(1)}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 播放提示音
|
||||||
|
export const playBeepSound = () => {
|
||||||
|
// 使用Web Audio API生成提示音
|
||||||
|
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
|
||||||
|
const oscillator = audioContext.createOscillator()
|
||||||
|
const gainNode = audioContext.createGain()
|
||||||
|
|
||||||
|
oscillator.connect(gainNode)
|
||||||
|
gainNode.connect(audioContext.destination)
|
||||||
|
|
||||||
|
oscillator.frequency.value = 800
|
||||||
|
oscillator.type = 'sine'
|
||||||
|
|
||||||
|
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime)
|
||||||
|
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.1)
|
||||||
|
|
||||||
|
oscillator.start(audioContext.currentTime)
|
||||||
|
oscillator.stop(audioContext.currentTime + 0.1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 播放错误提示音
|
||||||
|
export const playErrorSound = () => {
|
||||||
|
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
|
||||||
|
const oscillator = audioContext.createOscillator()
|
||||||
|
const gainNode = audioContext.createGain()
|
||||||
|
|
||||||
|
oscillator.connect(gainNode)
|
||||||
|
gainNode.connect(audioContext.destination)
|
||||||
|
|
||||||
|
oscillator.frequency.value = 400
|
||||||
|
oscillator.type = 'sawtooth'
|
||||||
|
|
||||||
|
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime)
|
||||||
|
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.2)
|
||||||
|
|
||||||
|
oscillator.start(audioContext.currentTime)
|
||||||
|
oscillator.stop(audioContext.currentTime + 0.2)
|
||||||
|
}
|
||||||
|
|
||||||
170
frontend/src/views/Login.vue
Normal file
170
frontend/src/views/Login.vue
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex min-h-screen items-center justify-center bg-system-background-secondary px-4">
|
||||||
|
<div class="w-full max-w-md space-y-8 rounded-apple-xl bg-system-background-primary p-8 shadow-apple-xl">
|
||||||
|
<div class="space-y-4 text-center">
|
||||||
|
<div class="mx-auto flex h-16 w-16 items-center justify-center overflow-hidden rounded-apple bg-apple-blue/10 text-apple-title text-apple-blue">
|
||||||
|
<img
|
||||||
|
v-if="siteStore.logoUrl"
|
||||||
|
:src="siteStore.logoUrl"
|
||||||
|
:alt="siteStore.siteName"
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
<span v-else></span>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-apple-title-2 text-system-label-primary">{{ siteStore.siteName }}</h1>
|
||||||
|
<p class="text-apple-footnote text-system-label-tertiary">
|
||||||
|
{{ isLocalAuth ? '请输入账号密码登录系统' : `使用 ${keycloakHostname} 完成身份认证` }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form v-if="isLocalAuth" class="space-y-5" @submit.prevent="handleLocalLogin">
|
||||||
|
<div class="space-y-2 text-left">
|
||||||
|
<label class="text-apple-footnote text-system-label-secondary">用户名</label>
|
||||||
|
<input
|
||||||
|
v-model.trim="username"
|
||||||
|
class="input-apple"
|
||||||
|
type="text"
|
||||||
|
autocomplete="username"
|
||||||
|
placeholder="请输入用户名"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2 text-left">
|
||||||
|
<label class="text-apple-footnote text-system-label-secondary">密码</label>
|
||||||
|
<input
|
||||||
|
v-model="password"
|
||||||
|
class="input-apple"
|
||||||
|
type="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
placeholder="请输入密码"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn-apple-primary w-full py-3 text-base"
|
||||||
|
:aria-busy="loading"
|
||||||
|
:disabled="loading || !canSubmit"
|
||||||
|
>
|
||||||
|
{{ loading ? '正在登录…' : '登录' }}
|
||||||
|
</button>
|
||||||
|
<p v-if="errorText" class="text-center text-apple-footnote text-red-500">{{ errorText }}</p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div v-else class="space-y-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-apple-primary w-full py-3 text-base"
|
||||||
|
:aria-busy="loading"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="handleLogin"
|
||||||
|
>
|
||||||
|
{{ loginButtonLabel }}
|
||||||
|
</button>
|
||||||
|
<p class="text-center text-apple-footnote text-system-label-tertiary">
|
||||||
|
认证由 {{ keycloakHostname }} 提供。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-center text-apple-footnote text-system-label-tertiary">© {{ currentYear }} {{ siteStore.siteName }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import keycloak from '@/auth/keycloak'
|
||||||
|
import api from '@/api'
|
||||||
|
import type { User } from '@/types'
|
||||||
|
import { useSiteStore } from '@/stores/site'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const loading = ref(false)
|
||||||
|
const username = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const errorText = ref('')
|
||||||
|
const siteStore = useSiteStore()
|
||||||
|
|
||||||
|
const keycloakBaseUrl = import.meta.env.VITE_KEYCLOAK_URL ?? ''
|
||||||
|
|
||||||
|
const keycloakHostname = computed(() => {
|
||||||
|
if (!keycloakBaseUrl) return '认证中心'
|
||||||
|
try {
|
||||||
|
return new URL(keycloakBaseUrl).host
|
||||||
|
} catch {
|
||||||
|
return keycloakBaseUrl
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const authMode = (import.meta.env as any).VITE_AUTH_MODE || 'keycloak'
|
||||||
|
const isLocalAuth = computed(() => authMode === 'local')
|
||||||
|
|
||||||
|
const currentYear = new Date().getFullYear()
|
||||||
|
|
||||||
|
const loginButtonLabel = computed(() => (loading.value ? '正在安全跳转…' : '使用企业账号登录'))
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await keycloak.login()
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSubmit = computed(() => username.value.length > 0 && password.value.length > 0)
|
||||||
|
|
||||||
|
const handleLocalLogin = async () => {
|
||||||
|
if (!canSubmit.value) return
|
||||||
|
loading.value = true
|
||||||
|
errorText.value = ''
|
||||||
|
try {
|
||||||
|
const resp = await api.post('/auth/local/login', {
|
||||||
|
username: username.value,
|
||||||
|
password: password.value
|
||||||
|
})
|
||||||
|
const payload = resp?.data
|
||||||
|
authStore.setToken(payload?.token || 'session')
|
||||||
|
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: []
|
||||||
|
}
|
||||||
|
authStore.setUser(normalizedUser)
|
||||||
|
window.alert('登录成功')
|
||||||
|
await router.push('/dashboard')
|
||||||
|
} catch (e: any) {
|
||||||
|
errorText.value = e?.message || '登录失败,请重试'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => authStore.isAuthenticated,
|
||||||
|
(isAuthenticated) => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
router.push('/dashboard')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
siteStore.fetchSiteConfig()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
152
frontend/src/views/borrow/BorrowList.vue
Normal file
152
frontend/src/views/borrow/BorrowList.vue
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { getBorrowRecordList } from '@/api/borrow'
|
||||||
|
import type { BorrowRecord } from '@/types'
|
||||||
|
import { formatDateTime } from '@/utils/format'
|
||||||
|
|
||||||
|
const borrowRecords = ref<BorrowRecord[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const exporting = ref(false)
|
||||||
|
|
||||||
|
const fetchBorrowRecords = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const response = await getBorrowRecordList({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 100
|
||||||
|
})
|
||||||
|
if (response.data) {
|
||||||
|
borrowRecords.value = response.data.list
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取借用记录失败:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
exporting.value = true
|
||||||
|
try {
|
||||||
|
const baseUrl = import.meta.env.VITE_API_BASE_URL || '/api'
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
|
||||||
|
const response = await fetch(`${baseUrl}/export/borrow-records`, {
|
||||||
|
headers: {
|
||||||
|
'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')
|
||||||
|
a.href = url
|
||||||
|
a.download = `借用记录_${new Date().toISOString().slice(0, 10)}.xlsx`
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
window.URL.revokeObjectURL(url)
|
||||||
|
document.body.removeChild(a)
|
||||||
|
} catch (error: any) {
|
||||||
|
alert(error.message || '导出失败')
|
||||||
|
} finally {
|
||||||
|
exporting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusClass = (status: string) => {
|
||||||
|
const classes: Record<string, string> = {
|
||||||
|
'已借出': 'bg-apple-orange text-white',
|
||||||
|
'已归还': 'bg-apple-green text-white',
|
||||||
|
'逾期': 'bg-apple-red text-white'
|
||||||
|
}
|
||||||
|
return classes[status] || 'bg-system-gray-5 text-system-label-secondary'
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchBorrowRecords()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user