写给第一次做全栈项目的你
本文档用最通俗的语言,从零开始讲解每个概念。如果你是第一次接触后端开发、数据库、API这些概念,这份文档就是为你准备的。
目录
一、什么是全栈开发?
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秒
就像进货:
- 顺序进货:先买设备,再买站点,最后买柜台
- 并行进货:三个人同时去买,快得多
总结:你学到了什么
-
全栈开发概念
- 前端、后端、数据库各是什么
- 它们怎么协同工作
-
数据库基础
- SQLite是什么
- Prisma怎么用
- 表、字段、关系
-
后端开发
- Express怎么搭建服务器
- 路由怎么定义
- 中间件是什么
-
安全认证
- JWT Token怎么工作
- bcrypt怎么加密密码
- 怎么保护API
-
前后端连接
- fetch怎么发送请求
- API怎么封装
- 状态怎么管理
-
实际功能实现
- 登录怎么做
- 设备管理怎么做
- 导入功能怎么做
下一步学习建议:
- 把本文档的代码复制到项目中,理解每一行
- 尝试修改代码,看看会发生什么
- 尝试添加新功能(比如设备维修记录)
- 部署到服务器,让其他人能用
记住:
- 编程就像做饭,要亲自动手
- 理论重要,实践更重要
- 出错不可怕,怕的是不敢尝试
祝你学习愉快!🎉