first commit

This commit is contained in:
WuYongJie 2026-03-10 16:19:47 +08:00
commit dee0ca70f6
122 changed files with 21698 additions and 0 deletions

34
Dockerfile Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

41
backend/package.json Normal file
View 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
View 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"),
},
});

View 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")
}

View 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 || '删除资产类型失败')
}
}

View 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 || '批量获取资产信息失败')
}
}

View 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 || '批量转移失败')
}
}

View 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 || '批量导入失败')
}
}

View 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 || '获取借用记录列表失败')
}
}

View 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 || '删除部门失败')
}
}

View 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 || '获取报废历史失败')
}
}

View 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 || '查询资产失败')
}
}

View 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 })
}
}

View 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 || '获取维修历史失败')
}
}

View 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 || '获取可挂载配件列表失败')
}
}

View 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)
}
}

View 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 || '获取使用者选项失败')
}
}

View 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 || '搜索失败')
}
}

View 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 || '更新站点配置失败')
}
}

View 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 || '获取价值统计失败')
}
}

View 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 || '更新状态失败')
}
}

View 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
View 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}`)
})

View 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 })
}
}

View 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
View 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

View 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

View 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

View 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

View 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)

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View File

@ -0,0 +1,8 @@
import { Router } from 'express'
import { globalSearch } from '../controllers/searchController'
const router = Router()
router.get('/', globalSearch)
export default router

View 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

View 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

View 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

View 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
View 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
}

View 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)
}

View 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 }

View 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

View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

38
frontend/package.json Normal file
View 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"
}
}

View 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
View 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
View 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>

View 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
}

View 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
View 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)
}

View 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
View 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
}

View 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
}

View 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`)
}

View 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
}

View 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
View 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

View 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
View 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
}

View 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')
}

View 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
}

View 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 } })
}

View 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
}

View 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
}

View 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
View 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
}

View 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

View 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

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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()

View 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
View 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
}

View 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)
}
}
}
})

View 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
View 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
View 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
View 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
}>
}

View 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)
}

View 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>

View 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