目录
一、项目背景与升级概述
1.1 项目介绍
机场设备管理系统是一个用于实时监控和管理机场值机岛、登机口、自助设备等状态的全栈Web应用。系统支持设备位置管理、状态追踪、更换记录、耗材管理等功能。
1.2 升级前的演示版架构
演示版特点:
- 纯前端应用,无后端服务器
- 数据存储在浏览器 localStorage 中
- 模拟登录,无真实认证机制
- 数据无法持久化,关闭浏览器后数据丢失
- 不支持多用户并发访问
1.3 升级后的生产版架构
生产版特点:
- 前后端分离架构
- 后端使用 Express 5.x + Prisma ORM
- 数据存储在 SQLite 数据库(可迁移至 PostgreSQL/MySQL)
- JWT + bcrypt 认证机制
- 数据持久化,支持多用户并发
- 完整的审计日志和变更记录
1.4 技术栈对比
| 维度 | 演示版 | 生产版 |
|---|---|---|
| 前端框架 | Next.js 16 | Next.js 16 + React 19 |
| 后端框架 | 无 | Express 5.x |
| 数据库 | localStorage | SQLite(Prisma ORM) |
| 认证方式 | 模拟登录 | JWT + bcrypt 密码加密 |
| API架构 | 无 | RESTful API |
| 状态管理 | React Context + localStorage | React Context + API调用 |
| 数据持久化 | 浏览器本地 | 服务器数据库 |
二、完整数据库表结构
2.1 数据库架构概览
┌─────────────┐
│ User │──────┐
└─────────────┘ │
│
┌─────────────┐ │ ┌─────────────┐
│ Station │──┐ │ │ AuditLog │
└─────────────┘ │ │ └─────────────┘
│ │
┌─────────────┐ │ │ ┌──────────────────┐
│ Counter │──┘ │ │ DeviceChangeRec │
└─────────────┘ │ └──────────────────┘
│
┌─────────────┐ │ ┌─────────────────┐
│ DeviceType │──────┼────│ Device │
└─────────────┘ │ └─────────────────┘
│
│ ┌─────────────────┐
└────│ PaperChangeRec │
└─────────────────┘
┌─────────────────┐
│ ConsumableRec │
└─────────────────┘
2.2 User(用户表)
表名 :User
用途:存储系统用户信息,支持管理员和普通用户两种角色。
| 字段名 | 类型 | 约束 | 说明 | 示例值 |
|---|---|---|---|---|
id |
String (UUID) | PRIMARY KEY | 用户唯一标识 | "abc123-def456" |
username |
String | UNIQUE, NOT NULL | 登录用户名 | "admin" |
password |
String | NOT NULL | bcrypt加密密码 | "$2a$10$..." |
name |
String | NOT NULL | 用户姓名 | "系统管理员" |
role |
String | DEFAULT "user" | 用户角色(admin/user) | "admin" |
isActive |
Boolean | DEFAULT true | 用户状态(软删除) | true |
createdAt |
DateTime | DEFAULT now() | 创建时间 | 2024-01-01T00:00:00Z |
updatedAt |
DateTime | @updatedAt | 更新时间 | 2024-01-01T00:00:00Z |
关联关系:
- 一对多 → DeviceChangeRecord(操作记录)
- 一对多 → PaperChangeRecord(换纸记录)
- 一对多 → ConsumableRecord(耗材记录)
- 一对多 → AuditLog(审计日志)
Prisma Schema 定义:
prisma
model User {
id String @id @default(uuid())
username String @unique
password String
name String
role String @default("user")
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
changeRecords DeviceChangeRecord[]
paperRecords PaperChangeRecord[]
consumableRecords ConsumableRecord[]
auditLogs AuditLog[]
}
关键设计点:
- 密码加密:使用 bcrypt 加密,10轮加盐
- 唯一约束:username 字段唯一,防止重复注册
- 软删除:isActive 字段标记用户状态,删除时不物理删除
- 角色分离:role 字段区分管理员和普通用户权限
2.3 Station(站点表)
表名 :Station
用途:存储机场站点信息,如值机岛、登机口、自助服务区等。
| 字段名 | 类型 | 约束 | 说明 | 示例值 |
|---|---|---|---|---|
id |
String (UUID) | PRIMARY KEY | 站点唯一标识 | "abc123" |
name |
String | NOT NULL | 站点名称 | "A值机岛" |
code |
String | UNIQUE, NOT NULL | 站点代码 | "A" |
type |
String | NOT NULL | 站点类型 | "checkin" |
description |
String? | 可空 | 站点描述 | "国内航班值机区" |
positionX |
Int | DEFAULT 0 | X坐标位置 | 0 |
positionY |
Int | DEFAULT 0 | Y坐标位置 | 1 |
isActive |
Boolean | DEFAULT true | 站点状态 | true |
createdAt |
DateTime | DEFAULT now() | 创建时间 | 2024-01-01 |
updatedAt |
DateTime | @updatedAt | 更新时间 | 2024-01-01 |
站点类型枚举:
checkin- 值机岛gate- 登机口selfservice- 自助服务区
关联关系:
- 一对多 → Counter(柜台)
- 一对多 → Device(设备)
- 一对多 → DeviceChangeRecord(变更记录来源/目标)
Prisma Schema 定义:
prisma
model Station {
id String @id @default(uuid())
name String
code String @unique
type String
description String?
positionX Int @default(0)
positionY Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
counters Counter[]
devices Device[]
changeRecordsFrom DeviceChangeRecord[] @relation("fromStation")
changeRecordsTo DeviceChangeRecord[] @relation("toStation")
}
关键设计点:
- 代码唯一:code 字段唯一,用于快速标识站点
- 坐标系统:positionX 和 positionY 用于可视化布局
- 级联删除:删除站点时,关联的柜台会被级联删除
- 变更追踪:通过两个关联追踪设备来源和目标站点
2.4 Counter(柜台表)
表名 :Counter
用途:存储站点下的柜台信息,如值机柜台、登机口等。
| 字段名 | 类型 | 约束 | 说明 | 礅例值 |
|---|---|---|---|---|
id |
String (UUID) | PRIMARY KEY | 柜台唯一标识 | "abc123" |
stationId |
String | FOREIGN KEY, NOT NULL | 所属站点ID | "station-id" |
name |
String | NOT NULL | 柜台名称 | "A01柜台" |
position |
Int | NOT NULL | 柜台位置排序 | 1 |
isActive |
Boolean | DEFAULT true | 柜台状态 | true |
createdAt |
DateTime | DEFAULT now() | 创建时间 | 2024-01-01 |
updatedAt |
DateTime | @updatedAt | 更新时间 | 2024-01-01 |
关联关系:
- 多对一 ← Station(所属站点)
- 一对多 → Device(设备)
- 一对多 → DeviceChangeRecord(变更记录来源/目标)
Prisma Schema 定义:
prisma
model Counter {
id String @id @default(uuid())
stationId String
name String
position Int
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
station Station @relation(fields: [stationId], references: [id], onDelete: Cascade)
devices Device[]
changeRecordsFrom DeviceChangeRecord[] @relation("fromCounter")
changeRecordsTo DeviceChangeRecord[] @relation("toCounter")
}
关键设计点:
- 级联删除 :删除站点时,关联柜台自动删除(
onDelete: Cascade) - 位置排序:position 字段用于柜台显示顺序
- 外键约束:stationId 必须关联有效的 Station 记录
2.5 DeviceType(设备类型表)
表名 :DeviceType
用途:存储设备类型定义,如自助值机机、柜台电脑、打印机等。
| 字段名 | 类型 | 约束 | 说明 | 示例值 |
|---|---|---|---|---|
id |
String (UUID) | PRIMARY KEY | 类型唯一标识 | "abc123" |
name |
String | UNIQUE, NOT NULL | 类型名称 | "CUSS自助值机机" |
category |
String | NOT NULL | 类型分类 | "自助设备" |
description |
String? | 可空 | 类型描述 | "自助打印登机牌设备" |
icon |
String? | 可空 | 图标名称 | "Monitor" |
attributes |
String | DEFAULT "{}" | 自定义属性(JSON) | "[{\"name\":\"纸卷类型\"}]" |
isActive |
Boolean | DEFAULT true | 类型状态 | true |
createdAt |
DateTime | DEFAULT now() | 创建时间 | 2024-01-01 |
updatedAt |
DateTime | @updatedAt | 更新时间 | 2024-01-01 |
设备分类枚举:
自助设备- CUSS自助值机机等电脑- 值机柜台电脑打印机- 登机牌打印机、行李条打印机扫描设备- 扫描枪称重设备- 行李秤
自定义属性结构(JSON格式):
json
[
{
"id": "attr-1",
"name": "纸卷类型",
"type": "select",
"options": ["热敏纸", "普通纸"],
"required": true
},
{
"id": "attr-2",
"name": "最大承重(kg)",
"type": "number",
"required": true
}
]
属性类型支持:
text- 文本输入number- 数字输入date- 日期选择select- 下拉选择
关联关系:
- 一对多 → Device(设备)
Prisma Schema 定义:
prisma
model DeviceType {
id String @id @default(uuid())
name String @unique
category String
description String?
icon String?
attributes String @default("{}")
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
devices Device[]
}
关键设计点:
- 名称唯一:name 字段唯一,防止重复类型
- 自定义属性:attributes 字段以 JSON 格式存储动态属性
- 删除限制 :删除设备类型时,若有关联设备则阻止删除(
onDelete: Restrict)
2.6 Device(设备表)
表名 :Device
用途:存储具体设备信息,包括位置、状态、自定义数据等。
| 字段名 | 类型 | 约束 | 说明 | 示例值 |
|---|---|---|---|---|
id |
String (UUID) | PRIMARY KEY | 设备唯一标识 | "abc123" |
name |
String | NOT NULL | 设备名称 | "CUSS-C01" |
typeId |
String | FOREIGN KEY, NOT NULL | 设备类型ID | "type-id" |
serialNumber |
String | UNIQUE, NOT NULL | 设备序列号 | "CUSS2024001" |
status |
String | DEFAULT "standby" | 设备状态 | "active" |
stationId |
String? | FOREIGN KEY, 可空 | 所属站点ID | "station-id" |
counterId |
String? | FOREIGN KEY, 可空 | 所属柜台ID | "counter-id" |
position |
Int | DEFAULT 0 | 设备位置排序 | 1 |
customData |
String | DEFAULT "{}" | 自定义数据(JSON) | "{\"纸卷类型\":\"热敏纸\"}" |
notes |
String? | 可空 | 设备备注 | "正常使用" |
isActive |
Boolean | DEFAULT true | 设备状态(软删除) | true |
createdAt |
DateTime | DEFAULT now() | 创建时间 | 2024-01-01 |
updatedAt |
DateTime | @updatedAt | 更新时间 | 2024-01-01 |
设备状态枚举:
active- 使用中(正常运行)standby- 备机(库存备用)damaged- 损坏(故障待修)repair- 送修(维修中)
关联关系:
- 多对一 ← DeviceType(设备类型)
- 多对一 ← Station(所属站点,可为空)
- 多对一 ← Counter(所属柜台,可为空)
- 一对多 → DeviceChangeRecord(变更记录)
- 一对多 → PaperChangeRecord(换纸记录)
- 一对多 → ConsumableRecord(耗材记录)
Prisma Schema 定义:
prisma
model Device {
id String @id @default(uuid())
name String
typeId String
serialNumber String @unique
status String @default("standby")
stationId String?
counterId String?
position Int @default(0)
customData String @default("{}")
notes String?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
type DeviceType @relation(fields: [typeId], references: [id], onDelete: Restrict)
station Station? @relation(fields: [stationId], references: [id], onDelete: SetNull)
counter Counter? @relation(fields: [counterId], references: [id], onDelete: SetNull)
changeRecords DeviceChangeRecord[]
paperRecords PaperChangeRecord[]
consumableRecords ConsumableRecord[]
}
关键设计点:
- 序列号唯一:serialNumber 字段唯一,防止重复设备
- 空值处理:stationId 和 counterId 可为空,表示设备在库房(备机)
- 删除行为 :
- 删除设备类型时阻止(
onDelete: Restrict) - 删除站点/柜台时设为空(
onDelete: SetNull)
- 删除设备类型时阻止(
- 状态追踪:每次状态变更自动创建 DeviceChangeRecord
- 自定义数据:customData 字段以 JSON 格式存储设备特定数据
2.7 DeviceChangeRecord(设备变更记录表)
表名 :DeviceChangeRecord
用途:记录设备位置、状态的变更历史,包括操作人和原因。
| 字段名 | 类型 | 约束 | 说明 | 示例值 |
|---|---|---|---|---|
id |
String (UUID) | PRIMARY KEY | 记录唯一标识 | "abc123" |
deviceId |
String | FOREIGN KEY, NOT NULL | 设备ID | "device-id" |
fromStationId |
String? | FOREIGN KEY, 可空 | 原站点ID | "station-id" |
toStationId |
String? | FOREIGN KEY, 可空 | 目标站点ID | "station-id" |
fromCounterId |
String? | FOREIGN KEY, 可空 | 原柜台ID | "counter-id" |
toCounterId |
String? | FOREIGN KEY, 可空 | 目标柜台ID | "counter-id" |
fromStatus |
String? | 可空 | 原状态 | "standby" |
toStatus |
String? | 可空 | 目标状态 | "active" |
reason |
String | NOT NULL | 变更原因 | "设备移动到A值机岛" |
operatorId |
String | FOREIGN KEY, NOT NULL | 操作人ID | "user-id" |
operatorName |
String | NOT NULL | 操作人姓名 | "值机员小王" |
notes |
String? | 可空 | 备注 | "紧急替换" |
createdAt |
DateTime | DEFAULT now() | 创建时间 | 2024-01-01 |
关联关系:
- 多对一 ← Device(设备)
- 多对一 ← User(操作人)
- 多对一 ← Station(原站点,可为空)
- 多对一 ← Station(目标站点,可为空)
- 多对一 ← Counter(原柜台,可为空)
- 多对一 ← Counter(目标柜台,可为空)
Prisma Schema 定义:
prisma
model DeviceChangeRecord {
id String @id @default(uuid())
deviceId String
fromStationId String?
toStationId String?
fromCounterId String?
toCounterId String?
fromStatus String?
toStatus String?
reason String
operatorId String
operatorName String
notes String?
createdAt DateTime @default(now())
device Device @relation(fields: [deviceId], references: [id], onDelete: Cascade)
operator User @relation(fields: [operatorId], references: [id], onDelete: Restrict)
fromStation Station? @relation("fromStation", fields: [fromStationId], references: [id], onDelete: SetNull)
toStation Station? @relation("toStation", fields: [toStationId], references: [id], onDelete: SetNull)
fromCounter Counter? @relation("fromCounter", fields: [fromCounterId], references: [id], onDelete: SetNull)
toCounter Counter? @relation("toCounter", fields: [toCounterId], references: [id], onDelete: SetNull)
}
关键设计点:
- 变更追踪:记录设备每次位置和状态变更
- 操作溯源:记录操作人和操作原因
- 级联删除 :删除设备时,关联记录也会删除(
onDelete: Cascade) - 双向关联:站点和柜台有"from"和"to"两个关联
2.8 PaperChangeRecord(换纸记录表)
表名 :PaperChangeRecord
用途:记录CUSS自助设备的换纸记录。
| 字段名 | 类型 | 约束 | 说明 | 示例值 |
|---|---|---|---|---|
id |
String (UUID) | PRIMARY KEY | 记录唯一标识 | "abc123" |
deviceId |
String | FOREIGN KEY, NOT NULL | 设备ID | "device-id" |
paperType |
String | NOT NULL | 纸张类型 | "热敏纸" |
quantity |
Int | DEFAULT 1 | 更换数量 | 2 |
notes |
String? | 可空 | 备注 | "纸卷用尽" |
operatorId |
String | FOREIGN KEY, NOT NULL | 操作人ID | "user-id" |
operatorName |
String | NOT NULL | 操作人姓名 | "值机员小王" |
createdAt |
DateTime | DEFAULT now() | 创建时间 | 2024-01-01 |
关联关系:
- 多对一 ← Device(设备)
- 多对一 ← User(操作人)
Prisma Schema 定义:
prisma
model PaperChangeRecord {
id String @id @default(uuid())
deviceId String
paperType String
quantity Int @default(1)
notes String?
operatorId String
operatorName String
createdAt DateTime @default(now())
device Device @relation(fields: [deviceId], references: [id], onDelete: Cascade)
operator User @relation(fields: [operatorId], references: [id], onDelete: Restrict)
}
2.9 ConsumableRecord(耗材记录表)
表名 :ConsumableRecord
用途:记录设备耗材更换记录(通用)。
| 字段名 | 类型 | 约束 | 说明 | 示例值 |
|---|---|---|---|---|
id |
String (UUID) | PRIMARY KEY | 记录唯一标识 | "abc123" |
deviceId |
String | FOREIGN KEY, NOT NULL | 设备ID | "device-id" |
consumableType |
String | NOT NULL | 耗材类型 | "碳带" |
quantity |
Int | DEFAULT 1 | 更换数量 | 1 |
notes |
String? | 可空 | 备注 | "正常更换" |
operatorId |
String | FOREIGN KEY, NOT NULL | 操作人ID | "user-id" |
operatorName |
String | NOT NULL | 操作人姓名 | "值机员小王" |
createdAt |
DateTime | DEFAULT now() | 创建时间 | 2024-01-01 |
关联关系:
- 多对一 ← Device(设备)
- 多对一 ← User(操作人)
Prisma Schema 定义:
prisma
model ConsumableRecord {
id String @id @default(uuid())
deviceId String
consumableType String
operatorId String
operatorName String
quantity Int @default(1)
notes String?
createdAt DateTime @default(now())
device Device @relation(fields: [deviceId], references: [id], onDelete: Cascade)
operator User @relation(fields: [operatorId], references: [id], onDelete: Restrict)
}
2.10 AuditLog(审计日志表)
表名 :AuditLog
用途:记录系统所有操作的审计日志,包括登录、创建、更新、删除等。
| 字段名 | 类型 | 约束 | 说明 | 示例值 |
|---|---|---|---|---|
id |
String (UUID) | PRIMARY KEY | 日志唯一标识 | "abc123" |
userId |
String? | FOREIGN KEY, 可空 | 操作用户ID | "user-id" |
action |
String | NOT NULL | 操作类型 | "create" |
resourceType |
String | NOT NULL | 资源类型 | "device" |
resourceId |
String? | 可空 | 资源ID | "device-id" |
details |
String | DEFAULT "{}" | 操作详情(JSON) | "{\"name\":\"CUSS-C01\"}" |
ipAddress |
String? | 可空 | 操作IP地址 | "192.168.1.100" |
userAgent |
String? | 可空 | 浏览器信息 | "Mozilla/5.0..." |
createdAt |
DateTime | DEFAULT now() | 创建时间 | 2024-01-01 |
操作类型枚举:
login- 登录logout- 登出create- 创建update- 更新delete- 删除
关联关系:
- 多对一 ← User(操作用户,可为空)
Prisma Schema 定义:
prisma
model AuditLog {
id String @id @default(uuid())
userId String?
action String
resourceType String
resourceId String?
details String @default("{}")
ipAddress String?
userAgent String?
createdAt DateTime @default(now())
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
}
关键设计点:
- 全面审计:记录所有关键操作
- 详情存储:details 字段以 JSON 格式存储操作详情
- IP追踪:记录操作来源IP和浏览器信息
- 软删除关联:删除用户时,日志保留但userId设为空
三、后端架构详解
3.1 后端整体架构
server/
├── index.ts # Express服务器入口
├── prisma.ts # Prisma客户端实例
├── seed.ts # 数据库初始化脚本
├── middleware/
│ └── auth.ts # JWT认证中间件
└── routes/
├── auth.ts # 认证路由(登录/登出)
├── users.ts # 用户管理路由
├── deviceTypes.ts # 设备类型路由
├── stations.ts # 站点管理路由
├── counters.ts # 柜台管理路由
├── devices.ts # 设备管理路由
├── changeRecords.ts # 变更记录路由
├── paperRecords.ts # 换纸记录路由
└── consumableRecords.ts # 耗材记录路由
3.2 Express服务器配置
文件 :server/index.ts
核心代码:
typescript
import dotenv from 'dotenv'
dotenv.config()
import express from 'express'
import cors from 'cors'
import prisma from './prisma'
import authRoutes from './routes/auth'
import userRoutes from './routes/users'
import deviceTypeRoutes from './routes/deviceTypes'
import stationRoutes from './routes/stations'
import counterRoutes from './routes/counters'
import deviceRoutes from './routes/devices'
import changeRecordRoutes from './routes/changeRecords'
import paperRecordRoutes from './routes/paperRecords'
import consumableRecordRoutes from './routes/consumableRecords'
const app = express()
const PORT = process.env.PORT || 5000
// 跨域支持
app.use(cors())
// JSON解析
app.use(express.json())
// 路由注册
app.use('/api/auth', authRoutes)
app.use('/api/users', userRoutes)
app.use('/api/device-types', deviceTypeRoutes)
app.use('/api/stations', stationRoutes)
app.use('/api/counters', counterRoutes)
app.use('/api/devices', deviceRoutes)
app.use('/api/change-records', changeRecordRoutes)
app.use('/api/paper-records', paperRecordRoutes)
app.use('/api/consumable-records', consumableRecordRoutes)
// 健康检查
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() })
})
// 错误处理
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
console.error(err.stack)
res.status(500).json({ error: '服务器内部错误' })
})
// 数据库连接
async function main() {
try {
await prisma.$connect()
console.log('数据库连接成功')
app.listen(PORT, () => {
console.log(`服务器运行在 http://localhost:${PORT}`)
})
} catch (error) {
console.error('数据库连接失败:', error)
process.exit(1)
}
}
main()
// 优雅关闭
process.on('SIGINT', async () => {
await prisma.$disconnect()
process.exit(0)
})
关键配置说明:
- 环境变量 :使用 dotenv 加载
.env配置 - 跨域支持:cors 中间件允许前端跨域访问
- JSON解析:express.json() 解析请求体
- 路由分离:每个资源独立的路由文件
- 错误处理:统一错误处理中间件
- 优雅关闭:监听 SIGINT 信号,关闭数据库连接
3.3 Prisma ORM配置
文件 :server/prisma.ts
核心代码:
typescript
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
export default prisma
使用说明:
- 创建 PrismaClient 实例并导出
- 所有路由文件通过
import prisma from '../prisma'使用 - 单例模式,避免多个实例导致连接池耗尽
Prisma配置文件 :prisma/schema.prisma
prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
// ... 模型定义见第二章
环境变量配置 :.env
env
DATABASE_URL="file:./prisma/dev.db"
JWT_SECRET="your-secret-key-here"
JWT_EXPIRES_IN="7d"
PORT=5000
3.4 JWT认证机制详解
3.4.1 认证中间件
文件 :server/middleware/auth.ts
功能:验证JWT Token,提取用户信息。
核心代码:
typescript
import { Request, Response, NextFunction } from 'express'
import jwt from 'jsonwebtoken'
import prisma from '../prisma'
interface UserPayload {
id: string
username: string
role: string
}
// 扩展 Express Request 类型
declare global {
namespace Express {
interface Request {
user?: UserPayload
}
}
}
export async function authenticateToken(req: Request, res: Response, next: NextFunction) {
// 1. 从请求头获取 Token
const authHeader = req.headers['authorization']
const token = authHeader && authHeader.split(' ')[1] // Bearer TOKEN
if (!token) {
return res.status(401).json({ error: '未授权访问' })
}
try {
// 2. 验证 Token
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as UserPayload
// 3. 查询用户信息(确保用户仍然有效)
const user = await prisma.user.findUnique({
where: { id: decoded.id },
select: { id: true, username: true, role: true, isActive: true },
})
if (!user || !user.isActive) {
return res.status(401).json({ error: '用户不存在或已禁用' })
}
// 4. 将用户信息注入请求对象
req.user = { id: user.id, username: user.username, role: user.role }
// 5. 继续执行后续中间件
next()
} catch {
return res.status(401).json({ error: '无效的令牌' })
}
}
export function requireAdmin(req: Request, res: Response, next: NextFunction) {
if (!req.user || req.user.role !== 'admin') {
return res.status(403).json({ error: '无权限访问' })
}
next()
}
认证流程:
1. 前端发送请求 → 请求头携带 Authorization: Bearer <token>
2. 中间件提取 Token → 从 Authorization 头获取
3. JWT验证 → jwt.verify() 解析 Token
4. 用户验证 → 查询数据库确保用户有效
5. 注入用户信息 → req.user = { id, username, role }
6. 后续处理 → 路由处理器可以通过 req.user 获取用户信息
3.4.2 登录接口实现
文件 :server/routes/auth.ts
核心代码:
typescript
import express from 'express'
import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'
import prisma from '../prisma'
const router = express.Router()
router.post('/login', async (req, res) => {
try {
const { username, password } = req.body
// 1. 参数验证
if (!username || !password) {
return res.status(400).json({ error: '用户名和密码不能为空' })
}
// 2. 查询用户
const user = await prisma.user.findUnique({
where: { username },
})
if (!user || !user.isActive) {
return res.status(401).json({ error: '用户名或密码错误' })
}
// 3. 验证密码(bcrypt比对)
const isValidPassword = await bcrypt.compare(password, user.password)
if (!isValidPassword) {
return res.status(401).json({ error: '用户名或密码错误' })
}
// 4. 生成 JWT Token
const token = jwt.sign(
{ id: user.id, username: user.username, role: user.role },
process.env.JWT_SECRET!,
{ expiresIn: process.env.JWT_EXPIRES_IN }
)
// 5. 记录审计日志
await prisma.auditLog.create({
data: {
userId: user.id,
action: 'login',
resourceType: 'user',
resourceId: user.id,
ipAddress: req.ip,
userAgent: req.get('User-Agent'),
},
})
// 6. 返回 Token 和用户信息
res.json({
token,
user: {
id: user.id,
username: user.username,
name: user.name,
role: user.role,
},
})
} catch (error) {
res.status(500).json({ error: '登录失败' })
}
})
登录流程图:
前端发送用户名密码
↓
后端查询用户记录
↓
bcrypt.compare() 比对密码
↓
验证成功 → jwt.sign() 生成Token
↓
记录审计日志(login操作)
↓
返回 { token, user }
↓
前端存储Token到localStorage
3.4.3 密码加密实现
bcrypt加密原理:
typescript
// 注册时加密密码
import bcrypt from 'bcryptjs'
const hashedPassword = await bcrypt.hash(password, 10) // 10轮加盐
// 登录时验证密码
const isValid = await bcrypt.compare(inputPassword, hashedPassword)
加密流程:
- bcrypt.hash() 自动生成随机盐值
- 盐值 + 密码 → 哈希函数 → 加密密码
- 10轮加盐增加安全性,防止暴力破解
- bcrypt.compare() 自动提取盐值并比对
3.5 RESTful API路由实现示例
3.5.1 设备管理路由
文件 :server/routes/devices.ts
核心功能:
- 获取设备列表(支持筛选)
- 创建设备(自动创建变更记录)
- 更新设备(自动记录变更)
- 删除设备(软删除)
- 移动设备位置
- 更新设备状态
关键代码片段:
typescript
// 创建设备(POST /api/devices)
router.post('/', authenticateToken, async (req, res) => {
try {
const { name, typeId, serialNumber, status, stationId, counterId, position, customData, notes } = req.body
// 1. 参数验证
if (!name || !typeId || !serialNumber) {
return res.status(400).json({ error: '名称、类型和序列号不能为空' })
}
// 2. 获取操作人信息
const operator = await prisma.user.findUnique({ where: { id: req.user!.id } })
// 3. 创建设备
const device = await prisma.device.create({
data: {
name,
typeId,
serialNumber,
status: status || 'standby',
stationId: stationId && stationId !== 'none' ? stationId : null,
counterId: counterId && counterId !== 'none' ? counterId : null,
position: position || 0,
customData: JSON.stringify(customData || {}),
notes,
},
})
// 4. 创建变更记录(记录新增操作)
if (operator) {
await prisma.deviceChangeRecord.create({
data: {
deviceId: device.id,
fromStationId: undefined,
toStationId: stationId && stationId !== 'none' ? stationId : null,
fromCounterId: undefined,
toCounterId: counterId && counterId !== 'none' ? counterId : null,
fromStatus: undefined,
toStatus: device.status,
reason: '新增设备',
operatorId: operator.id,
operatorName: operator.name,
},
})
}
// 5. 记录审计日志
await prisma.auditLog.create({
data: {
userId: req.user!.id,
action: 'create',
resourceType: 'device',
resourceId: device.id,
details: JSON.stringify({ name, typeId, serialNumber, status }),
},
})
// 6. 返回结果(解析JSON字段)
const result = {
...device,
customData: device.customData ? JSON.parse(device.customData) : {},
}
res.status(201).json(result)
} catch (error) {
console.error('创建设备错误:', error)
if (error instanceof Error && error.message.includes('Unique constraint failed')) {
res.status(409).json({ error: '序列号已存在', code: 'DUPLICATE_SERIAL' })
} else {
res.status(500).json({ error: '创建设备失败' })
}
}
})
关键实现细节:
- 空值处理:stationId/counterId 为空或 'none' 时设为 null
- 变更记录:创建设备时自动记录初始状态
- 审计日志:所有操作记录到 AuditLog
- 错误处理:捕获唯一约束错误,返回明确提示
- JSON解析:customData 字段解析后返回前端
四、前端架构详解
4.1 前端整体架构
app/
├── layout.tsx # 全局布局(包含Toaster)
├── page.tsx # 首页(重定向到仪表盘)
├── dashboard/
│ └── page.tsx # 仪表盘页面
├── devices/
│ └── page.tsx # 设备管理页面
├── stations/
│ └── page.tsx # 站点管理页面
├── admin/
│ ├── device-types/
│ │ └── page.tsx # 设备类型管理
│ └── users/
│ └── page.tsx # 用户管理
├── change-records/
│ └── page.tsx # 设备更换记录
└── paper-records/
│ └── page.tsx # 换纸记录
lib/
├── api.ts # API请求封装
├── store.ts # 全局状态管理
├── store-context.tsx # React Context Provider
├── types.ts # TypeScript类型定义
├── export.ts # 导入导出功能
└── utils.ts # 工具函数
components/
├── auth/
│ └── login-form.tsx # 登录表单
├── dashboard/
│ ├── station-view.tsx # 站点可视化
│ ├── stats-cards.tsx # 统计卡片
│ └── device-card.tsx # 设备卡片
├── layout/
│ ├── header.tsx # 顶部导航
│ └── sidebar.tsx # 侧边栏
└── ui/ # UI组件库(Radix UI + Tailwind)
4.2 API请求封装
文件 :lib/api.ts
4.2.1 请求函数封装
typescript
import type { User, DeviceType, Device, Station, Counter, DeviceChangeRecord, PaperChangeRecord, ConsumableRecord } from './types'
// API基础地址(开发环境直接访问后端)
const API_BASE_URL = process.env.NODE_ENV === 'production' ? '/api' : 'http://localhost:5000/api'
// 自定义错误类(包含状态码)
export class ApiError extends Error {
status: number
code?: string
constructor(message: string, status: number, code?: string) {
super(message)
this.name = 'ApiError'
this.status = status
this.code = code
}
}
// Token管理函数
function getToken(): string | null {
if (typeof window === 'undefined') return null
return localStorage.getItem('token')
}
function setToken(token: string | null) {
if (typeof window === 'undefined') return
if (token) {
localStorage.setItem('token', token)
} else {
localStorage.removeItem('token')
}
}
// 错误消息映射
function getErrorMessage(status: number, errorData: any): string {
switch (status) {
case 400: return errorData.message || '请求参数错误'
case 401: return errorData.message || '登录已过期,请重新登录'
case 403: return errorData.message || '没有权限执行此操作'
case 404: return errorData.message || '请求的资源不存在'
case 409: return errorData.message || '数据冲突,请刷新后重试'
case 500: return errorData.message || '服务器内部错误,请稍后重试'
default: return errorData.message || errorData.error || '请求失败'
}
}
// 核心请求函数
async function request<T>(url: string, options: RequestOptions = {}): Promise<T> {
const { skipAuth, ...restOptions } = options
const token = skipAuth ? null : getToken()
// 构造请求头
const headers: HeadersInit = {
'Content-Type': 'application/json',
...restOptions.headers,
}
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
try {
// 发送请求
const response = await fetch(`${API_BASE_URL}${url}`, {
...restOptions,
headers,
})
// 处理错误响应
if (!response.ok) {
let errorData: any = {}
try {
errorData = await response.json()
} catch {
// 忽略 JSON 解析错误
}
const message = getErrorMessage(response.status, errorData)
throw new ApiError(message, response.status, errorData.code)
}
// 返回JSON数据
return response.json()
} catch (err) {
if (err instanceof ApiError) {
throw err
}
// 网络错误处理
if (err instanceof TypeError && err.message.includes('fetch')) {
throw new ApiError('网络连接失败,请检查网络设置', 0, 'NETWORK_ERROR')
}
throw new ApiError(err instanceof Error ? err.message : '请求失败', 0)
}
}
4.2.2 API模块导出
typescript
// 认证API
export const authApi = {
login: async (username: string, password: string) => {
const result = await request<{ token: string; user: User }>('/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
skipAuth: true, // 登录不需要Token
})
setToken(result.token) // 自动存储Token
return result
},
logout: async () => {
await request('/auth/logout', { method: 'POST' })
setToken(null) // 清除Token
},
getMe: async () => {
return request<User>('/auth/me')
},
}
// 设备API
export const deviceApi = {
getAll: async () => {
const devices = await request<Device[]>('/devices')
// 解析JSON字段
return devices.map(d => ({
...d,
customData: typeof d.customData === 'string' ? JSON.parse(d.customData) : d.customData,
}))
},
getById: async (id: string) => {
const device = await request<Device>(`/devices/${id}`)
return {
...device,
customData: typeof device.customData === 'string' ? JSON.parse(device.customData) : device.customData,
}
},
create: async (data: Omit<Device, 'id' | 'createdAt' | 'updatedAt'>) =>
request<Device>('/devices', {
method: 'POST',
body: JSON.stringify(data),
}),
update: async (id: string, data: Partial<Device>) => {
return request<Device>(`/devices/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
},
delete: async (id: string) =>
request<void>(`/devices/${id}`, { method: 'DELETE' }),
move: async (id: string, toStationId: string, toCounterId?: string, reason?: string) =>
request<Device>(`/devices/${id}/move`, {
method: 'POST',
body: JSON.stringify({ toStationId, toCounterId, reason }),
}),
changeStatus: async (id: string, status: Device['status'], reason?: string) =>
request<Device>(`/devices/${id}/status`, {
method: 'POST',
body: JSON.stringify({ status, reason }),
}),
}
// ... 其他API模块(userApi, stationApi, counterApi等)
关键设计点:
- 自动Token管理:登录成功后自动存储,登出后自动清除
- 错误分类:不同状态码返回不同错误消息
- JSON解析:自动解析数据库中的JSON字段
- 网络错误:区分服务器错误和网络错误
4.3 全局状态管理
文件 :lib/store.ts
4.3.1 状态定义
typescript
'use client'
import { useState, useEffect, useCallback } from 'react'
import type { User, DeviceType, Device, Station, Counter, DeviceChangeRecord, PaperChangeRecord, ConsumableRecord } from './types'
import { authApi, userApi, deviceTypeApi, stationApi, counterApi, deviceApi, changeRecordApi, paperRecordApi, consumableRecordApi } from './api'
interface StoreState {
users: User[]
deviceTypes: DeviceType[]
devices: Device[]
stations: Station[]
counters: Counter[]
changeRecords: DeviceChangeRecord[]
paperRecords: PaperChangeRecord[]
consumableRecords: ConsumableRecord[]
currentUser: User | null
isInitialized: boolean
isLoading: boolean
error: string | null
}
4.3.2 数据加载函数
typescript
export function useStore() {
const [state, setState] = useState<StoreState>({
users: [],
deviceTypes: [],
devices: [],
stations: [],
counters: [],
changeRecords: [],
paperRecords: [],
consumableRecords: [],
currentUser: null,
isInitialized: false,
isLoading: true,
error: null,
})
// 数据加载函数
const loadData = useCallback(async () => {
setState(prev => ({ ...prev, isLoading: true }))
try {
// 1. 获取当前用户(如果Token有效)
let currentUser: User | null = null
try {
currentUser = await authApi.getMe()
} catch {
currentUser = null
}
// 2. 未登录则停止加载
if (!currentUser) {
setState(prev => ({
...prev,
currentUser: null,
isInitialized: true,
isLoading: false,
error: null,
}))
return
}
// 3. 并行加载所有数据
const [deviceTypes, devices, stations, counters, changeRecords, paperRecords, consumableRecords, users] = await Promise.all([
deviceTypeApi.getAll(),
deviceApi.getAll(),
stationApi.getAll(),
counterApi.getAll(),
changeRecordApi.getAll(),
paperRecordApi.getAll(),
consumableRecordApi.getAll(),
userApi.getAll(),
])
// 4. 更新状态
setState(prev => ({
...prev,
users,
deviceTypes,
devices,
stations,
counters,
changeRecords,
paperRecords,
consumableRecords,
currentUser,
isInitialized: true,
isLoading: false,
error: null,
}))
} catch (err) {
setError(err instanceof Error ? err.message : '加载数据失败')
setState(prev => ({ ...prev, isLoading: false, isInitialized: true }))
}
}, [setError])
// 初始化时加载
useEffect(() => {
loadData()
}, [loadData])
}
4.3.3 操作函数示例
typescript
// 登录
const login = useCallback(async (username: string, password: string): Promise<User | null> => {
try {
const result = await authApi.login(username, password)
setState(prev => ({ ...prev, currentUser: result.user }))
await loadData() // 登录后重新加载所有数据
return result.user
} catch (err) {
setError(err instanceof Error ? err.message : '登录失败')
return null
}
}, [loadData, setError])
// 添加设备
const addDevice = useCallback(async (device: Omit<Device, 'id' | 'createdAt' | 'updatedAt'>) => {
try {
const newDevice = await deviceApi.create(device)
setState(prev => ({ ...prev, devices: [...prev.devices, newDevice] }))
await loadData() // 刷新数据以获取变更记录
return newDevice
} catch (err) {
setError(err instanceof Error ? err.message : '添加设备失败')
throw err
}
}, [loadData, setError])
// 移动设备
const moveDevice = useCallback(async (deviceId: string, toStationId: string, toCounterId?: string, reason?: string) => {
try {
await deviceApi.move(deviceId, toStationId, toCounterId, reason)
await loadData() // 刷新数据以获取变更记录
} catch (err) {
setError(err instanceof Error ? err.message : '移动设备失败')
throw err
}
}, [loadData, setError])
// 返回所有状态和操作函数
return {
...state,
login,
logout,
refreshData,
addUser,
updateUser,
deleteUser,
addDevice,
updateDevice,
deleteDevice,
moveDevice,
changeDeviceStatus,
addStation,
updateStation,
deleteStation,
addCounter,
updateCounter,
deleteCounter,
addPaperRecord,
addConsumableRecord,
}
关键设计点:
- 自动刷新:操作后自动重新加载相关数据
- 错误处理:所有操作包含 try-catch,错误自动显示5秒后消失
- 并行加载:使用 Promise.all 并行加载所有数据
- 状态同步:操作后立即更新本地状态
4.4 React Context Provider
文件 :lib/store-context.tsx
typescript
'use client'
import { createContext, useContext, type ReactNode } from 'react'
import { useStore, type Store } from './store'
// 创建Context
const StoreContext = createContext<Store | null>(null)
// Provider组件
export function StoreProvider({ children }: { children: ReactNode }) {
const store = useStore()
return <StoreContext.Provider value={store}>{children}</StoreContext.Provider>
}
// 使用Context的Hook
export function useStoreContext() {
const context = useContext(StoreContext)
if (!context) {
throw new Error('useStoreContext must be used within a StoreProvider')
}
return context
}
在 layout.tsx 中使用:
typescript
// app/layout.tsx
import { StoreProvider } from '@/lib/store-context'
import { Toaster } from '@/components/ui/sonner'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh-CN">
<body>
<StoreProvider>
{children}
</StoreProvider>
<Toaster />
</body>
</html>
)
}
在页面中使用:
typescript
// app/devices/page.tsx
import { useStoreContext } from '@/lib/store-context'
export default function DevicesPage() {
const { devices, addDevice, updateDevice, deleteDevice, currentUser } = useStoreContext()
// 使用状态和操作函数...
}
五、前后端连接流程
5.1 登录认证完整流程
┌─────────────────────────────────────────────────────────┐
│ 前端(浏览器) │
└─────────────────────────────────────────────────────────┘
│
│ 1. 用户输入用户名密码
│
│ POST /api/auth/login
│ Body: { username, password }
│
↓
┌─────────────────────────────────────────────────────────┐
│ 后端(Express) │
│ │
│ 2. 查询用户 → prisma.user.findUnique({ username }) │
│ │
│ 3. 验证密码 → bcrypt.compare(password, user.password) │
│ │
│ 4. 生成Token → jwt.sign({ id, username, role }, secret)│
│ │
│ 5. 记录日志 → prisma.auditLog.create({ action: 'login' })│
│ │
│ 6. 返回 → { token, user: { id, name, role } } │
└─────────────────────────────────────────────────────────┘
│
│ 7. 返回响应
│
↓
┌─────────────────────────────────────────────────────────┐
│ 前端(浏览器) │
│ │
│ 8. authApi.login() → 存储Token到localStorage │
│ │
│ 9. 更新状态 → currentUser = user │
│ │
│ 10. 加载数据 → loadData() → 并行请求所有API │
└─────────────────────────────────────────────────────────┘
5.2 API请求完整流程
┌─────────────────────────────────────────────────────────┐
│ 前端(页面) │
│ │
│ const { addDevice } = useStoreContext() │
│ await addDevice({ name, typeId, serialNumber }) │
└─────────────────────────────────────────────────────────┘
│
│ 1. 调用 store.addDevice()
│
↓
┌─────────────────────────────────────────────────────────┐
│ lib/store.ts │
│ │
│ 2. 调用 API → deviceApi.create(device) │
└─────────────────────────────────────────────────────────┘
│
│ 3. 调用 request('/devices', { method: 'POST' })
│
↓
┌─────────────────────────────────────────────────────────┐
│ lib/api.ts │
│ │
│ 4. 获取Token → getToken() │
│ │
│ 5. 构造请求头 → headers: { Authorization: 'Bearer ...' }│
│ │
│ 6. 发送请求 → fetch('http://localhost:5000/api/devices')│
└─────────────────────────────────────────────────────────┘
│
│ 7. HTTP请求
│
↓
┌─────────────────────────────────────────────────────────┐
│ 后端(Express) │
│ │
│ 8. 认证中间件 → authenticateToken() │
│ - 提取Token │
│ - jwt.verify() │
│ - 查询用户 │
│ - req.user = { id, username, role } │
│ │
│ 9. 路由处理 → router.post('/', ...) │
│ - 参数验证 │
│ - prisma.device.create() │
│ - prisma.deviceChangeRecord.create() │
│ - prisma.auditLog.create() │
│ │
│ 10. 返回 → res.status(201).json(device) │
└─────────────────────────────────────────────────────────┘
│
│ 11. HTTP响应
│
↓
┌─────────────────────────────────────────────────────────┐
│ lib/api.ts │
│ │
│ 12. 解析响应 → response.json() │
│ │
│ 13. 解析JSON字段 → customData = JSON.parse(...) │
│ │
│ 14. 返回数据 │
└─────────────────────────────────────────────────────────┘
│
│ 15. 返回到 store.ts
│
↓
┌─────────────────────────────────────────────────────────┐
│ lib/store.ts │
│ │
│ 16. 更新状态 → setState({ devices: [...devices, new] }) │
│ │
│ 17. 刷新数据 → loadData() │
└─────────────────────────────────────────────────────────┘
│
│ 18. React重新渲染
│
↓
┌─────────────────────────────────────────────────────────┐
│ 前端(页面) │
│ │
│ 19. UI更新 → 显示新设备 │
└─────────────────────────────────────────────────────────┘
5.3 Token认证流程图
登录成功
↓
localStorage.setItem('token', token)
↓
后续请求
↓
request函数自动添加请求头
↓
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
↓
后端中间件验证Token
↓
jwt.verify(token, secret)
↓
提取用户信息 → req.user = { id, username, role }
↓
路由处理器访问 req.user
↓
执行业务逻辑
六、核心功能逻辑详解
6.1 设备管理功能
6.1.1 设备列表展示
前端实现:
typescript
// app/devices/page.tsx
export default function DevicesPage() {
const { devices, deviceTypes, stations, counters, currentUser } = useStoreContext()
// 状态筛选
const [statusFilter, setStatusFilter] = useState<DeviceStatus | 'all'>('all')
// 站点筛选
const [stationFilter, setStationFilter] = useState<string>('all')
// 搜索关键词
const [searchQuery, setSearchQuery] = useState('')
// 筛选后的设备列表
const filteredDevices = devices.filter(device => {
if (statusFilter !== 'all' && device.status !== statusFilter) return false
if (stationFilter !== 'all' && device.stationId !== stationFilter) return false
if (searchQuery && !device.name.includes(searchQuery) && !device.serialNumber.includes(searchQuery)) return false
return true
})
// 渲染设备列表...
}
6.1.2 设备状态变更
流程:
用户点击"标记为损坏"
↓
弹出确认对话框
↓
用户输入变更原因
↓
调用 store.changeDeviceStatus(id, 'damaged', reason)
↓
deviceApi.changeStatus(id, 'damaged', reason)
↓
POST /api/devices/:id/status
↓
后端验证权限
↓
更新设备状态
↓
创建变更记录(prisma.deviceChangeRecord.create)
↓
返回成功
↓
前端刷新数据
↓
UI更新
前端代码:
typescript
const handleChangeStatus = async (deviceId: string, newStatus: DeviceStatus) => {
// 弹出确认对话框
const confirmed = await confirmDialog({
title: '确认状态变更',
message: `确定将设备状态变更为"${statusColors[newStatus].label}"吗?`,
requireReason: newStatus === 'damaged' || newStatus === 'repair',
})
if (!confirmed) return
try {
await changeDeviceStatus(deviceId, newStatus, confirmed.reason)
toast.success('设备状态已更新')
} catch (err) {
toast.error(err instanceof Error ? err.message : '状态变更失败')
}
}
后端代码:
typescript
// server/routes/devices.ts
router.post('/:id/status', authenticateToken, async (req, res) => {
try {
const { status, reason } = req.body
const device = await prisma.device.findUnique({ where: { id: req.params.id } })
if (!device) {
return res.status(404).json({ error: '设备不存在' })
}
const operator = await prisma.user.findUnique({ where: { id: req.user!.id } })
// 状态变更逻辑:如果从"使用中"变为其他状态,清除位置信息
const shouldClearCounter = device.status === 'active' && status !== 'active'
const newCounterId = shouldClearCounter ? null : device.counterId
const newStationId = shouldClearCounter ? null : device.stationId
// 更新设备
await prisma.device.update({
where: { id: req.params.id },
data: {
status,
stationId: newStationId,
counterId: newCounterId,
},
})
// 创建变更记录
if (operator) {
await prisma.deviceChangeRecord.create({
data: {
deviceId: device.id,
fromStationId: device.stationId,
toStationId: newStationId,
fromCounterId: device.counterId,
toCounterId: newCounterId,
fromStatus: device.status,
toStatus: status,
reason: reason || '状态变更',
operatorId: operator.id,
operatorName: operator.name,
},
})
}
res.json({ message: '设备状态已更新' })
} catch (error) {
res.status(500).json({ error: '更新设备状态失败' })
}
})
6.2 站点管理功能
6.2.1 站点可视化布局
前端实现:
typescript
// components/dashboard/station-view.tsx
export function StationView({ station }: { station: Station }) {
const { devices, counters } = useStoreContext()
// 获取站点下的柜台
const stationCounters = counters.filter(c => c.stationId === station.id)
// 获取站点下的设备(分组)
const stationDevices = devices.filter(d => d.stationId === station.id)
// 按柜台分组
const groupedByCounter = stationDevices.reduce((acc, device) => {
const counterName = device.counterId
? counters.find(c => c.id === device.counterId)?.name || '-'
: '独立设备'
if (!acc[counterName]) acc[counterName] = []
acc[counterName].push(device)
return acc
}, {} as Record<string, Device[]>)
// 渲染可视化布局...
}
6.2.2 批量添加柜台
流程:
用户点击"批量添加柜台"
↓
弹出对话框(输入数量和命名规则)
↓
预览生成的柜台名称
↓
用户确认
↓
循环调用 counterApi.create()
↓
每次调用创建一条记录
↓
所有柜台创建完成
↓
前端刷新数据
↓
UI更新
前端代码:
typescript
const handleBatchAddCounters = async () => {
// 弹出对话框获取数量和命名规则
const result = await batchAddDialog({
title: '批量添加柜台',
stationName: station.name,
})
if (!result) return
// 预览生成的柜台名称
const counterNames = []
for (let i = result.startNumber; i < result.startNumber + result.count; i++) {
counterNames.push(`${result.prefix}${i}`)
}
// 确认对话框
const confirmed = await confirmDialog({
title: '确认批量添加',
message: `即将添加 ${result.count} 个柜台:${counterNames.join(', ')}`,
})
if (!confirmed) return
// 循环创建
let successCount = 0
for (let i = 0; i < result.count; i++) {
try {
await addCounter({
stationId: station.id,
name: counterNames[i],
position: counters.filter(c => c.stationId === station.id).length + i + 1,
})
successCount++
} catch (err) {
console.error(`添加柜台 ${counterNames[i]} 失败:`, err)
}
}
toast.success(`成功添加 ${successCount} 个柜台`)
}
6.3 导入导出功能
6.3.1 CSV导入实现
编码处理流程:
用户选择CSV文件
↓
FileReader.readAsArrayBuffer(file)
↓
自动检测编码(尝试GBK、GB2312、UTF-8)
↓
使用TextDecoder解码
↓
检查是否包含中文关键字("设备名称"、"站点名称")
↓
选择正确的编码解码内容
↓
解析CSV数据
↓
验证数据格式
↓
批量调用API创建记录
↓
返回导入结果(成功/跳过/失败)
前端代码:
typescript
// app/devices/page.tsx
const handleImportDevices = useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
// 检查文件类型
if (!file.name.endsWith('.csv')) {
toast.error('请选择 CSV 格式的文件')
return
}
const reader = new FileReader()
reader.onload = async (e) => {
try {
// 使用 ArrayBuffer + TextDecoder 处理编码
const arrayBuffer = e.target?.result as ArrayBuffer
let content = ''
const encodings = ['GBK', 'GB2312', 'UTF-8']
for (const encoding of encodings) {
try {
const decoder = new TextDecoder(encoding)
content = decoder.decode(arrayBuffer)
// 检查是否成功解码(包含中文关键字)
if (content.includes('设备名称')) {
break
}
} catch {
continue
}
}
// 解析CSV
const { devices: parsedDevices, errors } = parseDevicesCSV(content, deviceTypes, stations, counters)
if (errors.length > 0) {
toast.warning('导入警告', { description: errors.join('\n') })
}
// 批量导入
let successCount = 0
let skipCount = 0
let errorCount = 0
for (const device of parsedDevices) {
// 检查序列号是否已存在
if (devices.some(d => d.serialNumber === device.serialNumber)) {
skipCount++
continue
}
try {
await addDevice(device)
successCount++
} catch (err) {
errorCount++
}
}
// 显示导入结果
let message = ''
if (successCount > 0) message += `成功导入 ${successCount} 台设备`
if (skipCount > 0) message += message ? `,${skipCount} 台已存在跳过` : `${skipCount} 台设备已存在`
if (errorCount > 0) message += message ? `,${errorCount} 台导入失败` : `${errorCount} 台设备导入失败`
if (successCount > 0) {
toast.success(message || '导入完成')
} else if (skipCount > 0) {
toast.info(message)
} else if (errorCount > 0) {
toast.error(message)
}
} catch (err) {
toast.error(err instanceof Error ? err.message : '导入处理失败')
}
}
// 使用 ArrayBuffer 而不是 Text
reader.readAsArrayBuffer(file)
}, [deviceTypes, stations, counters, devices, addDevice])
6.3.2 CSV导出实现
导出流程:
用户点击"导出"
↓
构造CSV内容
↓
添加BOM标记(确保Excel正确识别UTF-8)
↓
创建Blob对象
↓
创建下载链接
↓
触发下载
↓
清理URL
导出函数:
typescript
// lib/export.ts
export function exportDevicesCSV(devices: Device[], deviceTypes: DeviceType[], stations: Station[]): boolean {
try {
// 构造CSV内容
const headers = ['设备名称', '设备类型', '序列号', '状态', '站点', '柜台', '备注']
const rows = devices.map(device => {
const type = deviceTypes.find(t => t.id === device.typeId)
const station = stations.find(s => s.id === device.stationId)
const statusLabel = statusColors[device.status]?.label || ''
return [
device.name,
type?.name || '',
device.serialNumber,
statusLabel,
station?.name || '',
'',
device.notes || '',
]
})
// 拼接CSV
const csv = [headers, ...rows].map(row => row.join(',')).join('\n')
// 下载文件
return downloadFile(csv, '设备列表.csv', 'text/csv')
} catch (err) {
console.error('导出设备失败:', err)
return false
}
}
function downloadFile(content: string, filename: string, type: string): boolean {
try {
// 添加BOM标记(Excel识别UTF-8)
const BOM = '\uFEFF'
const blob = new Blob([BOM + content], { type: `${type};charset=utf-8` })
// 创建下载链接
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
// 触发下载
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
// 清理URL
URL.revokeObjectURL(url)
return true
} catch (err) {
console.error('文件下载失败:', err)
return false
}
}
七、API接口完整清单
7.1 认证接口
| 方法 | 路径 | 认证 | 说明 |
|---|---|---|---|
| POST | /api/auth/login |
无 | 用户登录 |
| POST | /api/auth/logout |
需要 | 用户登出 |
| GET | /api/auth/me |
需要 | 获取当前用户信息 |
| POST | /api/auth/register |
无 | 用户注册 |
7.2 用户接口
| 方法 | 路径 | 认证 | 权限 | 说明 |
|---|---|---|---|---|
| GET | /api/users |
需要 | admin | 获取用户列表 |
| GET | /api/users/:id |
需要 | admin | 获取单个用户 |
| POST | /api/users |
需要 | admin | 创建用户 |
| PUT | /api/users/:id |
需要 | admin | 更新用户 |
| DELETE | /api/users/:id |
需要 | admin | 删除用户 |
7.3 设备类型接口
| 方法 | 路径 | 认证 | 说明 |
|---|---|---|---|
| GET | /api/device-types |
需要 | 获取设备类型列表 |
| GET | /api/device-types/:id |
需要 | 获取单个设备类型 |
| POST | /api/device-types |
需要 | 创建设备类型 |
| PUT | /api/device-types/:id |
需要 | 更新设备类型 |
| DELETE | /api/device-types/:id |
需要 | 删除设备类型 |
7.4 站点接口
| 方法 | 路径 | 认证 | 说明 |
|---|---|---|---|
| GET | /api/stations |
需要 | 获取站点列表 |
| GET | /api/stations/:id |
需要 | 获取单个站点 |
| POST | /api/stations |
需要 | 创建站点 |
| PUT | /api/stations/:id |
需要 | 更新站点 |
| DELETE | /api/stations/:id |
需要 | 删除站点 |
7.5 柜台接口
| 方法 | 路径 | 认证 | 说明 |
|---|---|---|---|
| GET | /api/counters |
需要 | 获取柜台列表 |
| GET | /api/counters/:id |
需要 | 获取单个柜台 |
| POST | /api/counters |
需要 | 创建柜台 |
| PUT | /api/counters/:id |
需要 | 更新柜台 |
| DELETE | /api/counters/:id |
需要 | 删除柜台 |
7.6 设备接口
| 方法 | 路径 | 认证 | 说明 |
|---|---|---|---|
| GET | /api/devices |
需要 | 获取设备列表(支持筛选) |
| GET | /api/devices/:id |
需要 | 获取单个设备详情 |
| POST | /api/devices |
需要 | 创建设备 |
| PUT | /api/devices/:id |
需要 | 更新设备 |
| DELETE | /api/devices/:id |
需要 | 删除设备(软删除) |
| POST | /api/devices/:id/move |
需要 | 移动设备位置 |
| POST | /api/devices/:id/status |
需要 | 更新设备状态 |
7.7 变更记录接口
| 方法 | 路径 | 认证 | 说明 |
|---|---|---|---|
| GET | /api/change-records |
需要 | 获取变更记录列表 |
| GET | /api/change-records/:id |
需要 | 获取单个变更记录 |
7.8 换纸记录接口
| 方法 | 路径 | 认证 | 说明 |
|---|---|---|---|
| GET | /api/paper-records |
需要 | 获取换纸记录列表 |
| POST | /api/paper-records |
需要 | 创建换纸记录 |
7.9 耗材记录接口
| 方法 | 路径 | 认证 | 说明 |
|---|---|---|---|
| GET | /api/consumable-records |
需要 | 获取耗材记录列表 |
| POST | /api/consumable-records |
需要 | 创建耗材记录 |
八、关键技术实现细节
8.1 软删除机制
实现原理:
所有资源(用户、设备、站点等)都有 isActive 字段,删除时设置为 false,而不是物理删除。
数据库查询时过滤:
typescript
// 获取设备列表时过滤已删除设备
const devices = await prisma.device.findMany({
where: { isActive: true },
orderBy: { updatedAt: 'desc' },
})
删除操作:
typescript
// 软删除设备
await prisma.device.update({
where: { id },
data: { isActive: false },
})
优点:
- 数据可恢复
- 保留历史记录
- 符合审计要求
8.2 审计日志机制
所有关键操作都记录审计日志:
typescript
await prisma.auditLog.create({
data: {
userId: req.user!.id,
action: 'create', // 操作类型
resourceType: 'device', // 资源类型
resourceId: device.id, // 资源ID
details: JSON.stringify({ name, typeId, serialNumber }), // 操作详情
ipAddress: req.ip, // 操作IP
userAgent: req.get('User-Agent'), // 浏览器信息
},
})
操作类型枚举:
login- 登录logout- 登出create- 创建资源update- 更新资源delete- 删除资源
用途:
- 安全审计
- 操作溯源
- 问题排查
8.3 状态变更追踪
每次设备状态变更自动创建变更记录:
typescript
// 更新设备状态时创建变更记录
await prisma.deviceChangeRecord.create({
data: {
deviceId: device.id,
fromStationId: existingDevice.stationId, // 原站点
toStationId: newStationId, // 目标站点
fromCounterId: existingDevice.counterId, // 原柜台
toCounterId: newCounterId, // 目标柜台
fromStatus: existingDevice.status, // 原状态
toStatus: newStatus, // 目标状态
reason: reason || '状态变更', // 变更原因
operatorId: operator.id, // 操作人ID
operatorName: operator.name, // 操作人姓名
},
})
用途:
- 设备移动历史
- 状态变更追踪
- 问题溯源
8.4 空值处理机制
外键字段空值处理:
typescript
// 创建/更新设备时处理空站点/柜台
stationId: stationId && stationId !== 'none' ? stationId : null,
counterId: counterId && counterId !== 'none' ? counterId : null,
原因:
- Prisma 要求外键必须关联有效记录或为
null - 空字符串会导致 Prisma P2003 错误(外键约束失败)
数据库关系配置:
prisma
model Device {
station Station? @relation(fields: [stationId], references: [id], onDelete: SetNull)
counter Counter? @relation(fields: [counterId], references: [id], onDelete: SetNull)
}
删除行为:
onDelete: SetNull- 删除站点/柜台时,设备的stationId/counterId自动设为null
九、开发环境搭建指南
9.1 环境要求
- Node.js 18+
- npm 或 pnpm
- Git
9.2 安装步骤
bash
# 1. 克隆项目
git clone <repository-url>
cd airport-equipment-management
# 2. 安装依赖
npm install
# 3. 配置环境变量
# 创建 .env 文件
DATABASE_URL="file:./prisma/dev.db"
JWT_SECRET="your-secret-key-here"
JWT_EXPIRES_IN="7d"
PORT=5000
9.3 数据库初始化
bash
# 1. 生成 Prisma 客户端
npm run prisma:generate
# 2. 运行数据库迁移
npm run prisma:migrate
# 3. 导入初始数据
npm run prisma:seed
9.4 启动开发服务器
bash
# 启动后端服务器(终端1)
npm run server
# 启动前端开发服务器(终端2)
npm run dev
9.5 访问地址
9.6 测试账号
| 用户名 | 密码 | 角色 |
|---|---|---|
| admin | admin123 | 管理员 |
| operator | operator123 | 普通用户 |
十、部署与运维指南
10.1 生产环境配置
环境变量 (.env):
env
# PostgreSQL 数据库
DATABASE_URL="postgresql://user:password@host:5432/dbname"
# JWT配置
JWT_SECRET="production-secret-key-min-32-characters"
JWT_EXPIRES_IN="24h"
# 服务器端口
PORT=5000
# Node环境
NODE_ENV="production"
10.2 数据库迁移
切换到 PostgreSQL/MySQL:
prisma
// prisma/schema.prisma
datasource db {
provider = "postgresql" // 或 "mysql"
url = env("DATABASE_URL")
}
运行迁移:
bash
# 重新生成迁移文件
npx prisma migrate dev --name init
# 生产环境应用迁移
npx prisma migrate deploy
10.3 构建与启动
bash
# 构建前端
npm run build
# 启动前端(生产环境)
npm run start
# 启动后端(生产环境)
npm run server
10.4 进程管理
使用 PM2 管理进程:
bash
# 安装 PM2
npm install -g pm2
# 启动后端
pm2 start npm --name "backend" -- run server
# 启动前端
pm2 start npm --name "frontend" -- run start
# 查看状态
pm2 status
# 日志查看
pm2 logs
10.5 Nginx反向代理配置
nginx
server {
listen 80;
server_name your-domain.com;
# 前端
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
# 后端API
location /api {
proxy_pass http://localhost:5000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}
十一、总结与学习要点
11.1 本次升级的核心收获
-
前后端分离架构
- 前端:Next.js + React
- 后端:Express + Prisma
- 通信:RESTful API + JWT认证
-
数据持久化
- localStorage → SQLite数据库
- Prisma ORM简化数据库操作
- 数据迁移支持生产环境
-
安全认证机制
- bcrypt密码加密
- JWT Token认证
- 中间件权限控制
-
数据完整性保障
- 数据库约束(唯一、外键)
- 变更记录追踪
- 审计日志
-
用户体验优化
- Toast通知提示
- 确认对话框
- 导入导出功能
11.2 适合学习的知识点
-
全栈开发流程
- 前端架构设计
- 后端API设计
- 数据库模型设计
-
Prisma ORM使用
- Schema定义
- 关系映射
- 数据迁移
-
JWT认证实现
- Token生成
- Token验证
- 权限控制
-
React状态管理
- Context API
- Hooks使用
- 数据同步
-
导入导出处理
- CSV解析
- 编码处理
- 批量操作
11.3 项目亮点
- 完整的全栈架构:涵盖前端、后端、数据库、认证等全链路
- 生产级安全机制:bcrypt加密、JWT认证、审计日志
- 数据追踪体系:变更记录、操作日志、状态历史
- 用户体验优化:Toast提示、确认对话框、导入导出
- 可扩展架构:SQLite可迁移至PostgreSQL/MySQL
文档版本 :v1.0
最后更新 :2026年
作者:Gary Yuan