机场设备管理系统 - 从演示版到生产版完整详解

目录

  1. 项目背景与升级概述
  2. 完整数据库表结构
  3. 后端架构详解
  4. 前端架构详解
  5. 前后端连接流程
  6. 核心功能逻辑详解
  7. API接口完整清单
  8. 关键技术实现细节
  9. 开发环境搭建指南
  10. 部署与运维指南

一、项目背景与升级概述

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[]
}

关键设计点

  1. 密码加密:使用 bcrypt 加密,10轮加盐
  2. 唯一约束:username 字段唯一,防止重复注册
  3. 软删除:isActive 字段标记用户状态,删除时不物理删除
  4. 角色分离: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")
}

关键设计点

  1. 代码唯一:code 字段唯一,用于快速标识站点
  2. 坐标系统:positionX 和 positionY 用于可视化布局
  3. 级联删除:删除站点时,关联的柜台会被级联删除
  4. 变更追踪:通过两个关联追踪设备来源和目标站点

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

关键设计点

  1. 级联删除 :删除站点时,关联柜台自动删除(onDelete: Cascade
  2. 位置排序:position 字段用于柜台显示顺序
  3. 外键约束: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[]
}

关键设计点

  1. 名称唯一:name 字段唯一,防止重复类型
  2. 自定义属性:attributes 字段以 JSON 格式存储动态属性
  3. 删除限制 :删除设备类型时,若有关联设备则阻止删除(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[]
}

关键设计点

  1. 序列号唯一:serialNumber 字段唯一,防止重复设备
  2. 空值处理:stationId 和 counterId 可为空,表示设备在库房(备机)
  3. 删除行为
    • 删除设备类型时阻止(onDelete: Restrict
    • 删除站点/柜台时设为空(onDelete: SetNull
  4. 状态追踪:每次状态变更自动创建 DeviceChangeRecord
  5. 自定义数据: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)
}

关键设计点

  1. 变更追踪:记录设备每次位置和状态变更
  2. 操作溯源:记录操作人和操作原因
  3. 级联删除 :删除设备时,关联记录也会删除(onDelete: Cascade
  4. 双向关联:站点和柜台有"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)
}

关键设计点

  1. 全面审计:记录所有关键操作
  2. 详情存储:details 字段以 JSON 格式存储操作详情
  3. IP追踪:记录操作来源IP和浏览器信息
  4. 软删除关联:删除用户时,日志保留但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)
})

关键配置说明

  1. 环境变量 :使用 dotenv 加载 .env 配置
  2. 跨域支持:cors 中间件允许前端跨域访问
  3. JSON解析:express.json() 解析请求体
  4. 路由分离:每个资源独立的路由文件
  5. 错误处理:统一错误处理中间件
  6. 优雅关闭:监听 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)

加密流程

  1. bcrypt.hash() 自动生成随机盐值
  2. 盐值 + 密码 → 哈希函数 → 加密密码
  3. 10轮加盐增加安全性,防止暴力破解
  4. 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: '创建设备失败' })
    }
  }
})

关键实现细节

  1. 空值处理:stationId/counterId 为空或 'none' 时设为 null
  2. 变更记录:创建设备时自动记录初始状态
  3. 审计日志:所有操作记录到 AuditLog
  4. 错误处理:捕获唯一约束错误,返回明确提示
  5. 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等)

关键设计点

  1. 自动Token管理:登录成功后自动存储,登出后自动清除
  2. 错误分类:不同状态码返回不同错误消息
  3. JSON解析:自动解析数据库中的JSON字段
  4. 网络错误:区分服务器错误和网络错误

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

关键设计点

  1. 自动刷新:操作后自动重新加载相关数据
  2. 错误处理:所有操作包含 try-catch,错误自动显示5秒后消失
  3. 并行加载:使用 Promise.all 并行加载所有数据
  4. 状态同步:操作后立即更新本地状态

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

优点

  1. 数据可恢复
  2. 保留历史记录
  3. 符合审计要求

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 - 删除资源

用途

  1. 安全审计
  2. 操作溯源
  3. 问题排查

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,              // 操作人姓名
  },
})

用途

  1. 设备移动历史
  2. 状态变更追踪
  3. 问题溯源

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 本次升级的核心收获

  1. 前后端分离架构

    • 前端:Next.js + React
    • 后端:Express + Prisma
    • 通信:RESTful API + JWT认证
  2. 数据持久化

    • localStorage → SQLite数据库
    • Prisma ORM简化数据库操作
    • 数据迁移支持生产环境
  3. 安全认证机制

    • bcrypt密码加密
    • JWT Token认证
    • 中间件权限控制
  4. 数据完整性保障

    • 数据库约束(唯一、外键)
    • 变更记录追踪
    • 审计日志
  5. 用户体验优化

    • Toast通知提示
    • 确认对话框
    • 导入导出功能

11.2 适合学习的知识点

  1. 全栈开发流程

    • 前端架构设计
    • 后端API设计
    • 数据库模型设计
  2. Prisma ORM使用

    • Schema定义
    • 关系映射
    • 数据迁移
  3. JWT认证实现

    • Token生成
    • Token验证
    • 权限控制
  4. React状态管理

    • Context API
    • Hooks使用
    • 数据同步
  5. 导入导出处理

    • CSV解析
    • 编码处理
    • 批量操作

11.3 项目亮点

  1. 完整的全栈架构:涵盖前端、后端、数据库、认证等全链路
  2. 生产级安全机制:bcrypt加密、JWT认证、审计日志
  3. 数据追踪体系:变更记录、操作日志、状态历史
  4. 用户体验优化:Toast提示、确认对话框、导入导出
  5. 可扩展架构:SQLite可迁移至PostgreSQL/MySQL

文档版本 :v1.0

最后更新 :2026年

作者:Gary Yuan