机场设备管理系统 - 新手入门详解版

写给第一次做全栈项目的你

本文档用最通俗的语言,从零开始讲解每个概念。如果你是第一次接触后端开发、数据库、API这些概念,这份文档就是为你准备的。


目录

  1. 什么是全栈开发?
  2. 为什么要升级到生产版?
  3. 核心概念通俗讲解
  4. 从零开始搭建后端
  5. 数据库是怎么工作的?
  6. 前端和后端是怎么连接的?
  7. 一步一步实现功能
  8. 常见问题解答

一、什么是全栈开发?

1.1 简单类比

想象你开了一家餐厅:

  • 前端 = 餐厅的装修和菜单(用户能看到和操作的东西)
  • 后端 = 餐厅的厨房(处理订单、做饭的地方)
  • 数据库 = 餐厅的仓库(存放食材和食材记录的地方)

全栈开发 = 既懂装修(前端),又懂做饭(后端),还懂管理仓库(数据库)。

1.2 演示版 vs 生产版

演示版

复制代码
就像一个模型餐厅
- 有菜单(前端页面)
- 但没有真正的厨房(没有后端服务器)
- 数据记在纸上(localStorage)
- 关门后记录就丢了(浏览器关闭数据丢失)

生产版

复制代码
就像一个真实运营的餐厅
- 有菜单(前端页面)
- 有真正的厨房(后端服务器)
- 有仓库管理系统(数据库)
- 数据永久保存(数据库持久化)

二、为什么要升级到生产版?

2.1 演示版的局限性

问题1:数据不安全

复制代码
用户浏览器关闭 → localStorage清空 → 所有数据丢失
就像把账本记在纸上,关门就扔掉

问题2:无法多人使用

复制代码
管理员A登录 → 看到自己的数据
管理员B登录 → 看到B的数据(A的数据看不到)
就像每个人有自己的账本,信息不通

问题3:没有真正的安全

复制代码
"登录"只是假装 → 没有验证密码
任何人都能假装是管理员
就像餐厅没有门锁,谁都能进来

2.2 生产版的优势

优势1:数据永久保存

复制代码
数据库存储 → 数据保存在硬盘 → 永久不丢失
就像账本记在电脑里,随时可以查看

优势2:多人同时使用

复制代码
管理员A添加设备 → 管理员B立即看到
数据实时同步,所有人看到相同数据
就像共享账本,所有人都能看到最新记录

优势3:真正的安全

复制代码
密码加密存储 → Token验证身份
只有正确的用户名密码才能登录
就像餐厅有门禁系统,只有员工能进入

三、核心概念通俗讲解

3.1 什么是数据库?

通俗解释

数据库就像一个超级Excel表格,但比Excel强大得多:

特性 Excel 数据库
存储位置 一个文件 专门的数据库软件
多人使用 只能一个人打开 可以多人同时使用
数据关联 手动关联 自动关联(外键)
数据安全 容易丢失 有备份机制
查询速度 快(索引)

本项目用的SQLite

复制代码
SQLite是最简单的数据库
- 不需要安装专门的数据库软件
- 数据保存在一个文件里(dev.db)
- 适合小型项目和学习
- 可以升级到PostgreSQL/MySQL(大型项目)

3.2 什么是Prisma?

通俗解释

Prisma就像一个"翻译官",帮你和数据库对话:

复制代码
你用JavaScript写代码 → Prisma翻译成数据库语言 → 数据库执行操作

例如:
你写:prisma.user.findMany()
Prisma翻译成:SELECT * FROM User
数据库执行并返回结果

为什么要用Prisma?

不用Prisma 用Prisma
需要学习SQL语法 只会用JavaScript
手写复杂SQL语句 一行代码搞定
数据库不同语法不同 统一的代码风格
容易出错 自动检查错误

Prisma代码示例

typescript 复制代码
// 传统方式(需要写SQL)
const result = await db.query('SELECT * FROM User WHERE username = "admin"')

// Prisma方式(更像写JavaScript)
const user = await prisma.user.findUnique({
  where: { username: 'admin' }
})

// 哪个更简单?明显是Prisma!

3.3 什么是Express?

通俗解释

Express就像一个"服务员",接收顾客的请求并返回结果:

复制代码
顾客(前端) → 点菜(发送请求) → 服务员(Express) → 厨房(数据库) → 返回菜品(数据)

例如:
前端:我要查看所有设备
Express:收到,我去数据库查询
Express:查到了,返回给你10台设备
前端:收到,显示在页面上

Express的工作流程

typescript 复制代码
// 1. 创建"服务员"
const app = express()

// 2. 定义"菜单"(路由)
app.get('/api/devices', async (req, res) => {
  // 前端请求这个地址时,执行这里的代码
  const devices = await prisma.device.findMany()
  res.json(devices)  // 返回给前端
})

// 3. 开始营业(启动服务器)
app.listen(5000, () => {
  console.log('服务员已就位,等待顾客点菜')
})

3.4 什么是JWT Token?

通俗解释

JWT就像一个"通行证"或"门禁卡":

复制代码
1. 用户登录成功 → 发放通行证(Token)
2. 后续每次请求 → 携带通行证
3. 后端检查通行证 → 放行或拒绝

具体流程

复制代码
┌─────────────┐
│  用户登录    │ → 用户名密码正确
└─────────────┘
      ↓
┌─────────────┐
│  发放Token  │ → 生成一个加密字符串:eyJhbGciOiJI...
└─────────────┘
      ↓
┌─────────────┐
│  前端保存   │ → localStorage.setItem('token', Token)
└─────────────┘
      ↓
┌─────────────┐
│  后续请求   │ → 每次请求都携带Token
│             │   Authorization: Bearer eyJhbGciOiJI...
└─────────────┘
      ↓
┌─────────────┐
│  后端验证   │ → 解密Token,提取用户信息
│             │   Token有效 → 放行
│             │   Token无效 → 拒绝(401错误)
└─────────────┘

Token里包含什么?

json 复制代码
{
  "id": "user-123",           // 用户ID
  "username": "admin",        // 用户名
  "role": "admin",            // 用户角色
  "exp": 1712345678           // 过期时间(7天后)
}

为什么用Token而不是直接存储用户信息?

复制代码
直接存储用户信息 → 不安全,任何人都能伪造
Token加密存储 → 无法伪造,只有服务器能生成
就像身份证 → 有防伪标记,无法伪造

3.5 什么是bcrypt?

通俗解释

bcrypt就像一个"加密机器",把密码变成乱码:

复制代码
原始密码:admin123
加密后:$2a$10$N9qo8uLOickgx2ZMRZoMy...

即使知道加密后的乱码,也无法还原原始密码
就像把纸烧成灰,无法还原成纸

为什么不用明文存储密码?

复制代码
明文存储 → 数据库泄露 → 所有密码被看到 → 账号被盗
加密存储 → 数据库泄露 → 看到乱码 → 无法破解 → 安全

bcrypt验证原理

typescript 复制代码
// 用户注册时
const hashedPassword = await bcrypt.hash('admin123', 10)  // 加密

// 用户登录时
const isValid = await bcrypt.compare('admin123', hashedPassword)  // 比对

// bcrypt.compare()的神奇之处:
// 它能把你输入的密码再次加密,然后和数据库里的加密密码比对
// 如果结果一致,说明密码正确

3.6 什么是RESTful API?

通俗解释

RESTful API就像一个"标准菜单",规定了怎么点菜:

复制代码
传统方式(不标准):
- 查看设备:/getDevices
- 添加设备:/addDevice
- 删除设备:/deleteDevice

RESTful方式(标准):
- 查看设备:GET    /api/devices
- 添加设备:POST   /api/devices
- 删除设备:DELETE /api/devices/:id

标准的好处:
- 地址统一,容易记忆
- 动作明确(GET/POST/DELETE)
- 符合国际规范

RESTful API的5种动作

动作 说明 类比
GET 获取数据 查看菜单
POST 创建数据 添加新菜品
PUT 更新数据 修改菜品
DELETE 删除数据 删除菜品
PATCH 部分更新 修改菜品价格

实际例子

typescript 复制代码
// 查看所有设备(GET)
GET /api/devices
返回:[{ id: 1, name: 'CUSS-C01', ... }, ...]

// 查看单个设备(GET)
GET /api/devices/123
返回:{ id: 123, name: 'CUSS-C01', ... }

// 添加设备(POST)
POST /api/devices
请求体:{ name: '新设备', typeId: '...' }
返回:{ id: 456, name: '新设备', ... }

// 更新设备(PUT)
PUT /api/devices/456
请求体:{ name: '修改后的设备' }
返回:{ id: 456, name: '修改后的设备', ... }

// 删除设备(DELETE)
DELETE /api/devices/456
返回:{ message: '删除成功' }

四、从零开始搭建后端

4.1 创建后端文件夹

bash 复制代码
# 在项目根目录创建server文件夹
mkdir server

# 进入server文件夹
cd server

4.2 创建核心文件

文件1:index.ts(服务器入口)

typescript 复制代码
// server/index.ts

// 1. 导入需要的工具
import express from 'express'       // Express框架(服务员)
import cors from 'cors'             // CORS(允许前端跨域访问)
import prisma from './prisma'       // Prisma(数据库翻译官)

// 2. 导入路由(菜单)
import authRoutes from './routes/auth'         // 认证路由
import userRoutes from './routes/users'        // 用户路由
import deviceRoutes from './routes/devices'    // 设备路由
// ... 其他路由

// 3. 创建Express应用(雇佣服务员)
const app = express()

// 4. 配置服务员
app.use(cors())              // 允许跨域(允许任何餐厅的顾客点菜)
app.use(express.json())      // 能读懂JSON格式(能看懂订单)

// 5. 注册路由(定义菜单)
app.use('/api/auth', authRoutes)       // 认证菜单
app.use('/api/users', userRoutes)      // 用户菜单
app.use('/api/devices', deviceRoutes)  // 设备菜单
// ... 其他菜单

// 6. 启动服务器(开门营业)
const PORT = 5000  // 端口号(餐厅地址)

async function main() {
  // 先连接数据库(先准备好厨房)
  await prisma.$connect()
  console.log('数据库连接成功!')
  
  // 再启动服务器(开门营业)
  app.listen(PORT, () => {
    console.log(`服务器已启动,地址是 http://localhost:${PORT}`)
  })
}

main()

文件2:prisma.ts(数据库连接)

typescript 复制代码
// server/prisma.ts

// 导入Prisma客户端
import { PrismaClient } from '@prisma/client'

// 创建Prisma实例(雇佣翻译官)
const prisma = new PrismaClient()

// 导出,让其他文件都能用
export default prisma

为什么每个文件都用同一个prisma实例?

复制代码
就像餐厅只有一个厨房
- 所有服务员都去同一个厨房
- 如果有多个厨房,会浪费资源
- Prisma也一样,只有一个实例,效率最高

4.3 创建认证中间件

文件:middleware/auth.ts

typescript 复制代码
// server/middleware/auth.ts

import jwt from 'jsonwebtoken'  // JWT工具(检查通行证)
import prisma from '../prisma'  // 数据库

// 认证函数(检查通行证的函数)
export async function authenticateToken(req, res, next) {
  // 1. 从请求头获取Token
  const authHeader = req.headers['authorization']
  // Authorization格式:Bearer eyJhbGciOiJI...
  // 所以要split(' ')[1]提取Token部分
  const token = authHeader && authHeader.split(' ')[1]
  
  // 2. 如果没有Token,拒绝访问
  if (!token) {
    return res.status(401).json({ error: '未授权访问' })
    // 401 = 未授权(没有通行证)
  }
  
  try {
    // 3. 验证Token(检查通行证真假)
    const decoded = jwt.verify(token, process.env.JWT_SECRET)
    // decoded = { id: 'user-123', username: 'admin', role: 'admin' }
    
    // 4. 查询用户(确认用户还在职)
    const user = await prisma.user.findUnique({
      where: { id: decoded.id },
      select: { id: true, username: true, role: true, isActive: true }
    })
    
    // 5. 如果用户不存在或已禁用,拒绝访问
    if (!user || !user.isActive) {
      return res.status(401).json({ error: '用户不存在或已禁用' })
    }
    
    // 6. 将用户信息注入请求对象(记录是谁点的菜)
    req.user = { id: user.id, username: user.username, role: user.role }
    
    // 7. 继续执行后续代码(放行)
    next()
    
  } catch {
    // Token验证失败(通行证过期或伪造)
    return res.status(401).json({ error: '无效的令牌' })
  }
}

中间件是怎么工作的?

复制代码
前端请求 → 中间件检查 → 路由处理 → 返回响应

就像餐厅的流程:
顾客点菜 → 门卫检查通行证 → 服务员记录订单 → 厨房做饭 → 返回菜品

如果没有通行证 → 门卫拒绝 → 顾客不能点菜 → 返回401错误

4.4 创建路由文件

文件:routes/devices.ts(设备管理路由)

typescript 复制代码
// server/routes/devices.ts

import express from 'express'
import prisma from '../prisma'
import { authenticateToken } from '../middleware/auth'

const router = express.Router()

// 1. 查看所有设备(GET /api/devices)
router.get('/', authenticateToken, async (req, res) => {
  try {
    // authenticateToken是中间件,会先检查Token
    // 检查通过后,req.user就有用户信息了
    
    // 从数据库查询设备
    const devices = await prisma.device.findMany({
      where: { isActive: true },  // 只查未删除的
      orderBy: { updatedAt: 'desc' }  // 按更新时间排序
    })
    
    // 返回给前端
    res.json(devices)
    
  } catch (error) {
    // 出错了返回500错误
    res.status(500).json({ error: '获取设备列表失败' })
  }
})

// 2. 添加设备(POST /api/devices)
router.post('/', authenticateToken, async (req, res) => {
  try {
    // req.body是前端发送的数据
    const { name, typeId, serialNumber, status, stationId, counterId } = req.body
    
    // 验证必填字段
    if (!name || !typeId || !serialNumber) {
      return res.status(400).json({ error: '名称、类型和序列号不能为空' })
    }
    
    // 创建设备
    const device = await prisma.device.create({
      data: {
        name,
        typeId,
        serialNumber,
        status: status || 'standby',
        stationId: stationId || null,  // 空值设为null
        counterId: counterId || null,
        customData: JSON.stringify({}),  // 自定义数据初始化为空对象
      }
    })
    
    // 记录审计日志(谁在什么时候添加了什么)
    await prisma.auditLog.create({
      data: {
        userId: req.user.id,
        action: 'create',
        resourceType: 'device',
        resourceId: device.id,
        details: JSON.stringify({ name, typeId, serialNumber }),
      }
    })
    
    // 返回创建的设备(201 = 创建成功)
    res.status(201).json(device)
    
  } catch (error) {
    res.status(500).json({ error: '创建设备失败' })
  }
})

// 3. 更新设备(PUT /api/devices/:id)
router.put('/:id', authenticateToken, async (req, res) => {
  try {
    const { id } = req.params  // 从URL获取设备ID
    const { name, status, stationId } = req.body
    
    // 更新设备
    const device = await prisma.device.update({
      where: { id },
      data: {
        name,
        status,
        stationId: stationId || null,
      }
    })
    
    res.json(device)
    
  } catch (error) {
    res.status(500).json({ error: '更新设备失败' })
  }
})

// 4. 删除设备(DELETE /api/devices/:id)
router.delete('/:id', authenticateToken, async (req, res) => {
  try {
    const { id } = req.params
    
    // 软删除(不真的删除,只是标记为删除)
    await prisma.device.update({
      where: { id },
      data: { isActive: false }
    })
    
    res.json({ message: '设备已删除' })
    
  } catch (error) {
    res.status(500).json({ error: '删除设备失败' })
  }
})

// 导出路由,让index.ts能使用
export default router

路由是怎么注册的?

typescript 复制代码
// 在index.ts中
app.use('/api/devices', deviceRoutes)

// 这样前端的请求就会这样映射:
GET  http://localhost:5000/api/devices      → router.get('/')
POST http://localhost:5000/api/devices      → router.post('/')
GET  http://localhost:5000/api/devices/123  → router.get('/:id')
PUT  http://localhost:5000/api/devices/123  → router.put('/:id')
DELETE http://localhost:5000/api/devices/123 → router.delete('/:id')

五、数据库是怎么工作的?

5.1 数据库模型定义

文件:prisma/schema.prisma

prisma 复制代码
// 数据库配置
datasource db {
  provider = "sqlite"        // 使用SQLite数据库
  url      = env("DATABASE_URL")  // 数据库地址从环境变量读取
}

// 用户表
model User {
  id        String   @id @default(uuid())  // 主键,自动生成UUID
  username  String   @unique               // 用户名,唯一
  password  String                         // 密码(加密存储)
  name      String                         // 姓名
  role      String   @default("user")      // 角色,默认是普通用户
  isActive  Boolean  @default(true)        // 是否启用,默认启用
  createdAt DateTime @default(now())       // 创建时间,自动记录
  updatedAt DateTime @updatedAt            // 更新时间,自动更新
}

Prisma字段解释

符号 含义 例子
@id 主键(唯一标识) id String @id
@default(uuid()) 自动生成UUID id String @id @default(uuid())
@unique 唯一约束 username String @unique
@default(value) 默认值 role String @default("user")
@updatedAt 自动更新时间 updatedAt DateTime @updatedAt

5.2 数据库关联关系

一对多关系

prisma 复制代码
// 站点表(一方)
model Station {
  id        String    @id
  name      String
  counters  Counter[]  // 一个站点有多个柜台
}

// 柜台表(多方)
model Counter {
  id        String   @id
  name      String
  stationId String              // 所属站点ID
  station   Station @relation(fields: [stationId], references: [id])
  // fields: [stationId] - 本表用stationId字段关联
  // references: [id] - 关联到Station表的id字段
}

通俗解释

复制代码
一个站点(A值机岛) → 有多个柜台(A01, A02, A03...)
就像一个家庭 → 有多个孩子

Station.id = "station-001"
Counter.stationId = "station-001"  → 说明这个柜台属于station-001站点

外键是什么?

复制代码
外键 = 关联表的一个字段,指向另一个表的主键

例如:
Counter表的stationId字段 → 指向Station表的id字段

就像孩子身上有父母的身份证号 → 通过这个号找到父母

5.3 数据库操作示例

查询数据

typescript 复制代码
// 查询所有用户
const users = await prisma.user.findMany()

// 查询单个用户(按用户名)
const user = await prisma.user.findUnique({
  where: { username: 'admin' }
})

// 条件查询(只查管理员)
const admins = await prisma.user.findMany({
  where: { role: 'admin' }
})

// 排序查询(按创建时间倒序)
const users = await prisma.user.findMany({
  orderBy: { createdAt: 'desc' }
})

// 分页查询(每页10条,第1页)
const users = await prisma.user.findMany({
  skip: 0,    // 跳过0条
  take: 10,   // 取10条
})

创建数据

typescript 复制代码
// 创建用户
const user = await prisma.user.create({
  data: {
    username: 'newuser',
    password: hashedPassword,
    name: '新用户',
    role: 'user',
  }
})

更新数据

typescript 复制代码
// 更新用户姓名
const user = await prisma.user.update({
  where: { id: 'user-123' },
  data: { name: '新姓名' }
})

删除数据

typescript 复制代码
// 真删除(物理删除)
await prisma.user.delete({
  where: { id: 'user-123' }
})

// 软删除(只标记为删除)
await prisma.user.update({
  where: { id: 'user-123' },
  data: { isActive: false }
})

5.4 数据库迁移

什么是迁移?

复制代码
迁移 = 把schema.prisma的定义同步到数据库

就像把设计图纸变成实际建筑
- schema.prisma = 设计图纸
- 数据库 = 实际建筑
- 迁移 = 施工过程

迁移命令

bash 复制代码
# 1. 创建迁移文件
npx prisma migrate dev --name init
# 会生成 prisma/migrations/xxx_init/migration.sql

# 2. 查看迁移SQL
# migration.sql内容:
CREATE TABLE "User" (
  "id" TEXT PRIMARY KEY,
  "username" TEXT NOT NULL UNIQUE,
  "password" TEXT NOT NULL,
  ...
);

# 3. 执行迁移
# Prisma会自动执行这个SQL,创建表

初始数据(seed)

typescript 复制代码
// server/seed.ts

async function main() {
  // 1. 清空现有数据
  await prisma.user.deleteMany()
  await prisma.device.deleteMany()
  // ...
  
  // 2. 创建管理员
  const hashedPassword = await bcrypt.hash('admin123', 10)
  const admin = await prisma.user.create({
    data: {
      username: 'admin',
      password: hashedPassword,
      name: '系统管理员',
      role: 'admin',
    }
  })
  
  // 3. 创建站点
  const stationA = await prisma.station.create({
    data: {
      name: 'A值机岛',
      code: 'A',
      type: 'checkin',
      description: '国内航班值机区',
    }
  })
  
  // ...
  
  console.log('数据初始化完成!')
}

main()

执行seed

bash 复制代码
npx prisma db seed

六、前端和后端是怎么连接的?

6.1 API请求封装

文件:lib/api.ts

typescript 复制代码
// API基础地址
const API_BASE_URL = 'http://localhost:5000/api'

// 请求函数
async function request(url, options = {}) {
  // 1. 获取Token
  const token = localStorage.getItem('token')
  
  // 2. 构造请求头
  const headers = {
    'Content-Type': 'application/json',  // 发送JSON格式数据
    ...(token && { Authorization: `Bearer ${token}` }),  // 如果有Token,添加到请求头
    ...options.headers,
  }
  
  // 3. 发送请求
  const response = await fetch(`${API_BASE_URL}${url}`, {
    ...options,
    headers,
  })
  
  // 4. 处理响应
  if (!response.ok) {
    const errorData = await response.json()
    throw new Error(errorData.error || '请求失败')
  }
  
  // 5. 返回数据
  return response.json()
}

通俗解释

复制代码
fetch()就像打电话:
1. 拨号(构造URL)
2. 说话(发送请求体)
3. 听对方回应(接收响应)
4. 如果对方说"好的"(response.ok)→ 记下内容
5. 如果对方说"不行"(!response.ok)→ 报错

6.2 API模块导出

typescript 复制代码
// 认证API
export const authApi = {
  // 登录
  login: async (username, password) => {
    const result = await request('/auth/login', {
      method: 'POST',
      body: JSON.stringify({ username, password }),
      skipAuth: true,  // 登录不需要Token
    })
    
    // 登录成功,保存Token
    localStorage.setItem('token', result.token)
    
    return result  // { token, user }
  },
  
  // 登出
  logout: async () => {
    await request('/auth/logout', { method: 'POST' })
    localStorage.removeItem('token')  // 删除Token
  },
}

// 设备API
export const deviceApi = {
  // 获取所有设备
  getAll: async () => {
    return request('/devices')
  },
  
  // 创建设备
  create: async (data) => {
    return request('/devices', {
      method: 'POST',
      body: JSON.stringify(data),
    })
  },
  
  // 更新设备
  update: async (id, data) => {
    return request(`/devices/${id}`, {
      method: 'PUT',
      body: JSON.stringify(data),
    })
  },
  
  // 删除设备
  delete: async (id) => {
    return request(`/devices/${id}`, { method: 'DELETE' })
  },
}

6.3 全局状态管理

文件:lib/store.ts

typescript 复制代码
export function useStore() {
  // 状态定义(仓库里的所有东西)
  const [state, setState] = useState({
    devices: [],          // 设备列表
    stations: [],         // 站点列表
    currentUser: null,    // 当前用户
    isLoading: true,      // 是否正在加载
  })
  
  // 加载所有数据(进货)
  const loadData = async () => {
    try {
      // 并行加载(同时进货多种商品)
      const [devices, stations, counters] = await Promise.all([
        deviceApi.getAll(),
        stationApi.getAll(),
        counterApi.getAll(),
      ])
      
      // 更新状态(把货放进仓库)
      setState({
        devices,
        stations,
        counters,
        isLoading: false,
      })
      
    } catch (error) {
      console.error('加载失败', error)
    }
  }
  
  // 初始化时加载(开门营业时先进货)
  useEffect(() => {
    loadData()
  }, [])
  
  // 添加设备(上架新商品)
  const addDevice = async (device) => {
    try {
      // 1. 调用API添加
      const newDevice = await deviceApi.create(device)
      
      // 2. 更新本地状态
      setState(prev => ({
        ...prev,
        devices: [...prev.devices, newDevice]
      }))
      
      // 3. 刷新数据(重新进货)
      await loadData()
      
    } catch (error) {
      console.error('添加失败', error)
    }
  }
  
  // 返回所有状态和操作函数
  return {
    ...state,
    addDevice,
    loadData,
  }
}

为什么要用Context?

复制代码
Context就像一个共享仓库:
- 所有页面都能访问同一个仓库
- 不需要在每个页面重复定义状态
- 数据实时同步

就像餐厅的共享账本:
- 所有服务员都能看同一个账本
- 不需要每个人记自己的账
- 信息实时同步

6.4 完整请求流程图

复制代码
┌──────────────────────────────────────┐
│ 前端页面(用户点击"添加设备"按钮)      │
└──────────────────────────────────────┘
            ↓
┌──────────────────────────────────────┐
│ 调用 store.addDevice(device)          │
│ 例如:{ name: '新设备', typeId: '...' }│
└──────────────────────────────────────┘
            ↓
┌──────────────────────────────────────┐
│ deviceApi.create(device)              │
│ 构造请求:                             │
│ - URL: http://localhost:5000/api/devices│
│ - Method: POST                        │
│ - Headers: Authorization: Bearer xxx  │
│ - Body: { name, typeId, ... }         │
└──────────────────────────────────────┘
            ↓ HTTP请求
┌──────────────────────────────────────┐
│ Express服务器                          │
│ 1. authenticateToken验证Token          │
│ 2. router.post('/')处理请求            │
│ 3. prisma.device.create()创建设备      │
│ 4. prisma.auditLog.create()记录日志    │
│ 5. res.status(201).json(device)返回    │
└──────────────────────────────────────┘
            ↓ HTTP响应
┌──────────────────────────────────────┐
│ 前端接收响应                           │
│ 1. response.json()解析数据             │
│ 2. setState更新本地状态                │
│ 3. loadData刷新所有数据                │
└──────────────────────────────────────┘
            ↓ React重新渲染
┌──────────────────────────────────────┐
│ UI更新(显示新设备)                    │
└──────────────────────────────────────┘

七、一步一步实现功能

7.1 实现登录功能

步骤1:后端登录接口

typescript 复制代码
// server/routes/auth.ts
router.post('/login', async (req, res) => {
  try {
    // 1. 接收用户名密码
    const { username, password } = req.body
    
    // 2. 查询用户
    const user = await prisma.user.findUnique({
      where: { username }
    })
    
    // 3. 验证密码
    const isValid = await bcrypt.compare(password, user.password)
    
    if (!isValid) {
      return res.status(401).json({ error: '用户名或密码错误' })
    }
    
    // 4. 生成Token
    const token = jwt.sign(
      { id: user.id, username: user.username, role: user.role },
      process.env.JWT_SECRET,
      { expiresIn: '7d' }
    )
    
    // 5. 返回Token和用户信息
    res.json({
      token,
      user: { id: user.id, username: user.username, name: user.name }
    })
    
  } catch (error) {
    res.status(500).json({ error: '登录失败' })
  }
})

步骤2:前端登录表单

typescript 复制代码
// components/auth/login-form.tsx
export function LoginForm() {
  const { login } = useStoreContext()
  const [username, setUsername] = useState('')
  const [password, setPassword] = useState('')
  
  const handleSubmit = async (e) => {
    e.preventDefault()
    
    try {
      // 调用登录函数
      const user = await login(username, password)
      
      if (user) {
        toast.success('登录成功')
        // 跳转到仪表盘
        router.push('/dashboard')
      }
      
    } catch (error) {
      toast.error(error.message)
    }
  }
  
  return (
    <form onSubmit={handleSubmit}>
      <input 
        value={username} 
        onChange={e => setUsername(e.target.value)}
        placeholder="用户名"
      />
      <input 
        type="password"
        value={password}
        onChange={e => setPassword(e.target.value)}
        placeholder="密码"
      />
      <button type="submit">登录</button>
    </form>
  )
}

7.2 实现设备管理

步骤1:设备列表页面

typescript 复制代码
// app/devices/page.tsx
export default function DevicesPage() {
  const { devices, deviceTypes, addDevice } = useStoreContext()
  
  // 筛选状态
  const [statusFilter, setStatusFilter] = useState('all')
  
  // 筛选后的设备
  const filteredDevices = devices.filter(device => {
    if (statusFilter !== 'all' && device.status !== statusFilter) return false
    return true
  })
  
  return (
    <div>
      {/* 筛选下拉框 */}
      <select value={statusFilter} onChange={e => setStatusFilter(e.target.value)}>
        <option value="all">全部状态</option>
        <option value="active">使用中</option>
        <option value="standby">备机</option>
      </select>
      
      {/* 设备列表 */}
      {filteredDevices.map(device => (
        <div key={device.id}>
          <h3>{device.name}</h3>
          <p>状态:{device.status}</p>
          <p>类型:{deviceTypes.find(t => t.id === device.typeId)?.name}</p>
        </div>
      ))}
      
      {/* 添加设备按钮 */}
      <button onClick={() => setShowAddDialog(true)}>添加设备</button>
    </div>
  )
}

步骤2:添加设备对话框

typescript 复制代码
const AddDeviceDialog = ({ onClose }) => {
  const { addDevice, deviceTypes, stations } = useStoreContext()
  const [formData, setFormData] = useState({
    name: '',
    typeId: '',
    serialNumber: '',
    stationId: '',
  })
  
  const handleSubmit = async () => {
    try {
      await addDevice({
        name: formData.name,
        typeId: formData.typeId,
        serialNumber: formData.serialNumber,
        stationId: formData.stationId || null,
        status: 'standby',
      })
      
      toast.success('设备添加成功')
      onClose()
      
    } catch (error) {
      toast.error(error.message)
    }
  }
  
  return (
    <dialog>
      <input 
        value={formData.name}
        onChange={e => setFormData({ ...formData, name: e.target.value })}
        placeholder="设备名称"
      />
      
      <select 
        value={formData.typeId}
        onChange={e => setFormData({ ...formData, typeId: e.target.value })}
      >
        <option value="">选择设备类型</option>
        {deviceTypes.map(type => (
          <option key={type.id} value={type.id}>{type.name}</option>
        ))}
      </select>
      
      <input 
        value={formData.serialNumber}
        onChange={e => setFormData({ ...formData, serialNumber: e.target.value })}
        placeholder="序列号"
      />
      
      <button onClick={handleSubmit}>提交</button>
      <button onClick={onClose}>取消</button>
    </dialog>
  )
}

7.3 实现导入功能

完整流程

typescript 复制代码
// app/devices/page.tsx
const handleImportDevices = async (e) => {
  // 1. 获取文件
  const file = e.target.files[0]
  if (!file) return
  
  // 2. 检查文件类型
  if (!file.name.endsWith('.csv')) {
    toast.error('请选择CSV文件')
    return
  }
  
  // 3. 读取文件(使用ArrayBuffer处理编码)
  const reader = new FileReader()
  
  reader.onload = async (e) => {
    // 4. 自动检测编码
    const arrayBuffer = e.target.result
    let content = ''
    
    // 尝试不同编码(Excel默认GBK,也可能UTF-8)
    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  // 继续尝试下一个编码
      }
    }
    
    // 5. 解析CSV内容
    const rows = content.split('\n')
    const headers = rows[0].split(',')
    // headers = ['设备名称', '设备类型', '序列号', '状态']
    
    // 6. 解析每一行数据
    const devices = []
    for (let i = 1; i < rows.length; i++) {
      const row = rows[i].split(',')
      if (row.length < 3) continue  // 跳过空行
      
      const [name, typeName, serialNumber, status] = row
      
      // 查找设备类型
      const type = deviceTypes.find(t => t.name === typeName)
      if (!type) {
        toast.warning(`未找到设备类型:${typeName}`)
        continue
      }
      
      devices.push({
        name,
        typeId: type.id,
        serialNumber,
        status: status || 'standby',
      })
    }
    
    // 7. 批量导入
    let successCount = 0
    let skipCount = 0
    
    for (const device of devices) {
      // 检查序列号是否已存在
      if (devices.some(d => d.serialNumber === device.serialNumber)) {
        skipCount++
        continue
      }
      
      try {
        await addDevice(device)
        successCount++
      } catch (error) {
        console.error('导入失败', error)
      }
    }
    
    // 8. 显示结果
    toast.success(`成功导入 ${successCount} 台设备`)
    if (skipCount > 0) {
      toast.info(`${skipCount} 台设备已存在,已跳过`)
    }
  }
  
  // 使用ArrayBuffer而不是Text,以便处理不同编码
  reader.readAsArrayBuffer(file)
}

八、常见问题解答

Q1:为什么有些字段要设为null而不是空字符串?

复制代码
数据库外键规则:
- 外键要么指向有效记录
- 要么是null(表示没有关联)
- 不能是空字符串""(数据库会报错)

例如:
设备.stationId = ""   → 错误!找不到站点ID为空字符串的站点
设备.stationId = null → 正确!表示设备不在任何站点

Q2:为什么JSON字段要先JSON.stringify再存储?

复制代码
数据库只认识字符串,不认识对象

错误方式:
customData: { 纸卷类型: "热敏纸" }  → 数据库报错

正确方式:
customData: JSON.stringify({ 纸卷类型: "热敏纸" })
→ 转成字符串 '{"纸卷类型":"热敏纸"}'
→ 数据库能存储
→ 前端读取时再JSON.parse转回对象

Q3:为什么删除时不真删除,只标记isActive=false?

复制代码
软删除的好处:
1. 数据可恢复(误删后还能恢复)
2. 保留历史记录(查询历史时还能看到)
3. 符合审计要求(谁在什么时候删除了什么)

就像档案室:
- 不把文件撕掉
- 只是在文件上标记"已作废"
- 需要时还能找到

Q4:为什么每次操作都要记录审计日志?

复制代码
审计日志的作用:
1. 安全审计(谁在什么时候做了什么)
2. 问题排查(出问题时能回溯)
3. 数据溯源(数据是怎么来的)

就像银行账本:
- 每一笔交易都要记录
- 出问题时能追溯
- 不能偷偷改账

Q5:为什么Token要放在请求头Authorization里?

复制代码
HTTP协议规定:
- Authorization字段专门用于认证
- 格式:Bearer <token>
- Bearer表示"持有者",即持有这个Token的人

就像门禁系统:
- 你刷卡进门
- 卡片信息自动传输到门禁系统
- 不需要每次手动输入卡片号

Q6:为什么用bcrypt加密密码,不能解密吗?

复制代码
bcrypt是单向加密:
- 只能加密,不能解密
- 验证方式:把输入密码加密,对比加密结果

就像把纸烧成灰:
- 灰不能还原成纸
- 但可以把新纸烧成灰,对比两堆灰是否一样

好处:
- 即使数据库泄露,黑客也看不到密码
- 只能暴力破解(一个一个试),很慢

Q7:为什么Promise.all并行加载而不是顺序加载?

复制代码
顺序加载:
await deviceApi.getAll()    // 等待3秒
await stationApi.getAll()   // 再等待2秒
await counterApi.getAll()   // 再等待2秒
总时间:3 + 2 + 2 = 7秒

并行加载:
Promise.all([
  deviceApi.getAll(),   // 同时开始
  stationApi.getAll(),  // 同时开始
  counterApi.getAll(),  // 同时开始
])
总时间:等待最慢的那个,约3秒

就像进货:
- 顺序进货:先买设备,再买站点,最后买柜台
- 并行进货:三个人同时去买,快得多

总结:你学到了什么

  1. 全栈开发概念

    • 前端、后端、数据库各是什么
    • 它们怎么协同工作
  2. 数据库基础

    • SQLite是什么
    • Prisma怎么用
    • 表、字段、关系
  3. 后端开发

    • Express怎么搭建服务器
    • 路由怎么定义
    • 中间件是什么
  4. 安全认证

    • JWT Token怎么工作
    • bcrypt怎么加密密码
    • 怎么保护API
  5. 前后端连接

    • fetch怎么发送请求
    • API怎么封装
    • 状态怎么管理
  6. 实际功能实现

    • 登录怎么做
    • 设备管理怎么做
    • 导入功能怎么做

下一步学习建议

  1. 把本文档的代码复制到项目中,理解每一行
  2. 尝试修改代码,看看会发生什么
  3. 尝试添加新功能(比如设备维修记录)
  4. 部署到服务器,让其他人能用

记住

  • 编程就像做饭,要亲自动手
  • 理论重要,实践更重要
  • 出错不可怕,怕的是不敢尝试

祝你学习愉快!🎉