173
社区成员
发帖
与我相关
我的任务
分享## 目录(Table of Contents)
1. [Git Repository Link and Code Standards Link](#1-git-repository-link-and-code-standards-link)
2. [PSP Table](#2-psp-table)
3. [Presentation of the Finished Product](#3-presentation-of-the-finished-product)
4. [Design and Implementation Process](#4-design-and-implementation-process)
5. [Code Explanation](#5-code-explanation)
6. [Personal Journey and Learnings](#6-personal-journey-and-learnings)
---
## 1. Git Repository Link and Code Standards Link
| 项目 | 链接 |
|---|---|
| Frontend Repository(前端仓库) | https://github.com/JorgeHUANG7/832401112_calculator_frontend |
| Backend Repository(后端仓库) | https://github.com/JorgeHUANG7/832401112_calculator_backend |
| Frontend Code Standard | https://github.com/JorgeHUANG7/832401112_calculator_frontend/blob/main/codestyle.md |
| Backend Code Standard | https://github.com/JorgeHUANG7/832401112_calculator_backend/blob/main/codestyle.md |
> 前后端分属两个独立仓库,分别包含 README.md(项目介绍/技术栈/安装启动/配置/数据库初始化/连接方式)与 codestyle.md(代码规范及来源)。
- 前端代码规范来源:Google JavaScript Style Guide、Airbnb JavaScript Style Guide、Google HTML/CSS Style Guide。
- 后端代码规范来源:PEP 8、Google Python Style Guide、PEP 257。
---
## 2. PSP Table
| PSP 阶段 | 内容说明 | 预估时间(小时) | 实际时间(小时) |
|---|---|---|---|
| 需求分析 | 阅读作业要求,拆解功能点(四则运算/复合表达式/历史/删除/前后端分离/扩展) | 1.5 | 1.5 |
| 系统设计 | 总体架构设计、API 设计、数据库表设计、模块划分 | 1.5 | 1.5 |
| 后端开发 | FastAPI 框架、路由、CORS、Pydantic 模型 | 2.0 | 2.0 |
| 表达式计算模块 | 词法分析、递归下降解析、优先级/括号/一元符号/小数、异常处理 | 4.0 | 3.5 |
| 数据库设计 | SQLite 表结构、初始化、增删查封装 | 1.0 | 1.0 |
| 计算历史模块 | 历史查询、指定删除、清空全部 | 1.5 | 1.0 |
| 前端开发 | HTML/CSS 计算器界面、按钮交互、历史面板 | 3.5 | 3.5 |
| 前后端联调 | API 对接、错误信息展示、历史刷新 | 1.5 | 1.5 |
| 扩展功能 | 键盘输入、主题切换、历史搜索、清空全部 | 2.0 | 1.5 |
| 测试 | 解析器单元测试、端到端验证(Playwright)、停后端验证分离 | 2.0 | 2.0 |
| 部署 | 本地部署、启动验证、README 部署文档 | 1.0 | 1.0 |
| 博客写作 | 本文撰写、截图整理、PSP 记录 | 3.0 | 3.0 |
| **合计** | | **24.5** | **23.0** |
---
## 3. Presentation of the Finished Product
### 3.1 主界面
计算器主界面:左侧为计算器面板(显示屏 + 数字/运算符/功能按键),右侧为计算历史面板(搜索框 + 清空全部 + 历史列表),右上角为主题切换按钮。
(https://raw.githubusercontent.com/JorgeHUANG7/832401112_calculator_frontend/main/docs/screenshots/01_main_ui.png)
### 3.2 加法 `12 + 8 = 20`
通过界面按钮输入 `12 + 8`,按 `=` 后将表达式发送到后端,后端返回结果 `20` 并写入数据库,历史面板立即出现该记录。
(https://raw.githubusercontent.com/JorgeHUANG7/832401112_calculator_frontend/main/docs/screenshots/02_basic_addition.png)
### 3.3 复合表达式与括号 `(1+2)×3 = 9`
支持括号与运算符优先级,后端按数学规则解析求值。

(https://raw.githubusercontent.com/JorgeHUANG7/832401112_calculator_frontend/main/docs/screenshots/03_compound_parentheses.png)
### 3.4 小数运算 `10÷4 = 2.5`
支持小数输入与输出,结果保留合理精度(`0.1+0.2` 输出 `0.3` 而非浮点噪声)。
(https://raw.githubusercontent.com/JorgeHUANG7/832401112_calculator_frontend/main/docs/screenshots/04_decimal.png)
### 3.5 除零处理 `1÷0`
点击 `=` 后,后端返回 `Division by zero` 错误,前端在显示屏中展示错误信息,该表达式不会写入历史。

(https://raw.githubusercontent.com/JorgeHUANG7/832401112_calculator_frontend/main/docs/screenshots/05_division_by_zero.png)
### 3.6 非法表达式处理 `1+`
不完整的表达式被后端拒绝,返回非法表达式错误信息。

(https://raw.githubusercontent.com/JorgeHUANG7/832401112_calculator_frontend/main/docs/screenshots/06_invalid_expression.png)
### 3.7 计算历史列表与搜索
多条计算记录按时间倒序展示(表达式 + 结果 + 时间),支持按表达式或结果关键词搜索过滤。
(https://raw.githubusercontent.com/JorgeHUANG7/832401112_calculator_frontend/main/docs/screenshots/07_history_list.png)
### 3.8 删除指定历史记录
每条记录右侧有删除按钮,点击后调用 `DELETE /api/history/{id}`,数据库中的该记录被真实删除,前端重新拉取最新历史。
(https://raw.githubusercontent.com/JorgeHUANG7/832401112_calculator_frontend/main/docs/screenshots/08_history_deleted.png)
### 3.9 暗色主题(扩展功能)
右上角一键切换亮/暗主题,主题偏好保存在本地,刷新后保持。
(https://raw.githubusercontent.com/JorgeHUANG7/832401112_calculator_frontend/main/docs/screenshots/09_dark_theme.png)
### 3.10 前后端分离验证:停掉后端
关闭后端服务后,前端仍可正常输入,但按 `=` 无法获得任何新结果,界面提示“无法连接后端服务”。这证明**核心计算完全由后端完成**。
(https://raw.githubusercontent.com/JorgeHUANG7/832401112_calculator_frontend/main/docs/screenshots/10_backend_stopped.png)
### 3.11 其他功能演示
- **键盘输入**:支持数字、`+ - * / ( )`、`Enter`(计算)、`Esc`(清空)、`Backspace`(退格)。
- **清空全部历史**:历史面板右上角“清空全部”按钮,二次确认后调用 `DELETE /api/history` 清空数据库。
---
## 4. Design and Implementation Process
### 4.1 需求分析
依据作业要求,本项目需要实现:
1. **基本计算**:`+ - × ÷` 四则运算,结果必须由后端计算并返回;
2. **复合表达式**:支持优先级、括号、一元正负号、小数,正确识别非法表达式与除零;**禁止使用 `eval`/`exec`**;
3. **计算历史**:每次成功计算写入后端数据库(表达式、结果、时间),前端从后端读取,刷新/重启不丢失;
4. **删除历史**:按 ID 删除指定记录,后端 API 完成,数据库真实删除;
5. **前后端分离**:前端只做展示与交互,核心计算与数据持久化全部在后端;
6. **扩展功能**:键盘输入、主题切换、历史搜索、清空全部。
### 4.2 总体系统架构
采用典型的前后端分离三层架构:
```
┌──────────────────────────────┐
│ Front End │
│ HTML / CSS / JavaScript │
│ (UI 展示 · 交互 · 请求发送) │
└──────────────┬───────────────┘
│ HTTP/HTTPS API(JSON)
▼
┌──────────────────────────────┐
│ Back End │
│ FastAPI (Python) │
│ 校验 → 解析 → 计算 → 存储 │
└──────────────┬───────────────┘
▼
┌──────────────────────────────┐
│ Database (SQLite) │
│ calculation_history 表 │
└──────────────────────────────┘
```
**职责划分**:
- 前端:计算器按钮交互、表达式输入、发送计算请求、展示结果/历史/错误、发送删除请求、展示后端错误信息;
- 后端:接收请求、输入校验、表达式解析、计算、异常处理、历史存储/读取/删除、统一 API 响应;
- 数据库:持久化计算历史(`id` / `expression` / `result` / `created_at`)。
### 4.3 功能结构图
```
Calculator System
│
├── Calculator(计算器)
│ ├── Basic Calculation(四则运算 + - × ÷)
│ ├── Compound Expression(优先级 · 括号 · 一元符号 · 小数)
│ └── Error Handling(非法表达式 · 除零 · 连接失败)
│
├── Calculation History(计算历史)
│ ├── Save History(每次成功计算写入数据库)
│ ├── Query History(GET /api/history)
│ ├── Delete History(DELETE /api/history/{id})
│ └── Clear History(扩展:DELETE /api/history)
│
├── Front End(前端)
│ ├── User Input(按钮 + 键盘)
│ ├── Result Display(结果 / 错误展示)
│ ├── History Display(历史列表 + 搜索)
│ └── Extra Features(主题切换 · 历史搜索 · 键盘 · 清空)
│
└── Back End(后端)
├── API(POST /api/calculate · GET /api/history · DELETE ...)
├── Expression Processing(词法分析 + 递归下降解析)
├── Calculation Service(求值 + 精度处理)
└── Database(SQLite 持久化)
```
### 4.4 前端设计
- 技术:原生 HTML5 + CSS3 + JavaScript(ES6+),无框架、零构建,任何静态服务器即可运行;
- 界面:`display` 区显示表达式/结果/错误;`keypad` 网格按钮;历史面板含搜索框、清空按钮、记录列表;
- 交互逻辑(`src/js/calculator.js`):
- 点击/键盘 → 拼接表达式字符串(界面显示 `×` `÷`,发送时转为 `*` `/`);
- 按 `=` → `fetch POST /api/calculate` → 展示后端返回的结果或错误 → 重新拉取历史;
- 历史操作 → `GET /api/history` / `DELETE /api/history/{id}` / `DELETE /api/history`;
- 主题切换 → 切换 `<html data-theme>`,偏好存 `localStorage`(仅 UI 偏好,历史数据始终来自后端数据库);
- 关键设计点:**前端任何位置都不计算表达式的值**,仅做字符串拼接与展示。
### 4.5 后端设计
- 技术:Python 3.12 + FastAPI + Uvicorn + SQLite(标准库 `sqlite3`);
- 分层:`main.py`(路由)→ `database.py`(仓储)→ `parser.py`(纯计算),`schemas.py`(Pydantic 模型);
- CORS:允许所有来源,便于前端任意地址访问;
- 表达式解析器(`app/parser.py`,自研,禁用 eval/exec):
```
词法分析(tokenize)
"12+8" → NUMBER(12) PLUS NUMBER(8) END
字符白名单预检 ^[0-9+\-*/().\s]+$,长度 ≤ 200
语法分析(递归下降,边解析边求值)
expression := term (('+' | '-') term)*
term := factor (('*' | '/') factor)*
factor := ('+' | '-') factor | NUMBER | '(' expression ')'
异常处理
InvalidExpressionError → 400 非法表达式
DivisionByZeroError → 400 除零
结果精度:round(value, 10),整数去小数尾零(3.0 → "3")
```
### 4.6 API 设计
| Method | Endpoint | 请求 | 成功响应(状态码) | 失败响应 |
|---|---|---|---|---|
| POST | `/api/calculate` | `{"expression": "(1+2)*3"}` | `200` `{"success":true,"expression":"(1+2)*3","result":"9","record_id":4,"created_at":"..."}` | `400` `{"detail":{"success":false,"message":"Division by zero"}}` |
| GET | `/api/history` | — | `200` `{"success":true,"total":3,"data":[{...}]}` | — |
| DELETE | `/api/history/{id}` | — | `200` `{"success":true,"message":"Deleted record 4"}` | `404` `{"detail":{"success":false,"message":"History record 999 not found"}}` |
| DELETE | `/api/history` | — | `200` `{"success":true,"message":"Removed 8 record(s)"}` | — |
| GET | `/api/health` | — | `200` `{"status":"ok","service":"calculator-backend"}` | — |
状态码语义:`200 OK` 成功、`400 Bad Request` 表达式错误(非法/除零)、`404 Not Found` 记录不存在。
### 4.7 数据库设计
SQLite 数据库 `calculator.db`,启动时自动建表:
```sql
CREATE TABLE IF NOT EXISTS calculation_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
expression TEXT NOT NULL,
result TEXT NOT NULL,
created_at TEXT NOT NULL
);
```
- `id`:自增主键,删除指定记录时使用;
- `expression`:后端收到并计算的原始表达式(如 `(1+2)*3`);
- `result`:计算结果(字符串存储,保留小数格式);
- `created_at`:本地时间 `YYYY-MM-DD HH:MM:SS`。
设计说明:结果以字符串存储,可无损保留 `2.5`、`0.3` 等格式;`created_at` 用文本便于直接展示。数据库文件路径可用环境变量 `CALCULATOR_DB` 覆盖;如需切换 MySQL/PostgreSQL,仅需替换 `app/database.py` 中仓储实现。
### 4.8 计算历史模块设计
- 写入:`POST /api/calculate` 成功后自动 `repository.insert(expression, result)`;
- 读取:`GET /api/history` 按 `id DESC` 返回最近 100 条(新记录在前);
- 删除:`DELETE /api/history/{id}` 真实删除数据库行,`rowcount == 0` 返回 404;
- 清空(扩展):`DELETE /api/history` 删除全部行并返回删除数量;
- 持久化验证:历史存于服务端 SQLite,前端刷新、重启、重新打开后历史依然存在(端到端测试已验证)。
### 4.9 异常处理
| 场景 | 后端行为 | 前端展示 |
|---|---|---|
| 非法表达式(`1+`、`a+1`、空表达式、超长) | `InvalidExpressionError` → `400` | 显示后端 message |
| 除零(`1/0`) | `DivisionByZeroError` → `400` | 显示 `Division by zero` |
| 删除不存在的记录 | `404` | 提示删除失败 |
| 后端不可用 | — | 显示“无法连接后端服务,请确认后端已启动” |
### 4.10 前后端交互流程
```
用户输入表达式(按钮/键盘)
│
▼
前端拼接字符串(×→*, ÷→/)
│
▼
POST /api/calculate {"expression":"12+8"}
│
▼
后端:校验 → 词法分析 → 递归下降解析求值
│
├─ 失败 → 400 {success:false, message} → 前端展示错误
└─ 成功 → 写入数据库 → 200 {success:true, result} → 前端展示结果
│
▼
前端重新 GET /api/history 刷新历史面板
```
### 4.11 部署过程
**本地部署(已验证)**:
```bash
# 1. 后端
cd calculator_backend
pip install -r requirements.txt
uvicorn app.main:app --host 0.0.0.0 --port 8000
# 2. 前端(另一终端)
cd calculator_frontend/src
python3 -m http.server 5500
# 3. 浏览器打开
http://localhost:5500/index.html
```
**公开部署(已完成)**:
- 后端(Render,FastAPI):https://calculator-backend-oaoe.onrender.com
- 健康检查:https://calculator-backend-oaoe.onrender.com/api/health
- 由仓库根目录 `render.yaml`(Blueprint)自动构建,启动命令
`uvicorn app.main:app --host 0.0.0.0 --port $PORT`,已开启全来源 CORS。
- 前端(GitHub Pages,静态托管):https://jorgehuang7.github.io/832401112_calculator_frontend/
- `src/js/calculator.js` 中 `API_BASE` 已指向上述后端公网地址。
**如何访问 / 测试**:
1. 浏览器打开前端地址
https://jorgehuang7.github.io/832401112_calculator_frontend/
2. 直接点击按钮或键盘输入表达式,按 `=` 查看后端返回结果;
3. 历史面板显示数据库中的记录,可删除单条或清空全部;
4. 后端免费实例首次访问可能需数秒唤醒,请稍作等待。
> 说明:Render 免费实例不挂持久磁盘,若服务重启/休眠,SQLite 历史会重置;
> 评测期间保持访问即可满足“前端刷新/重开不丢历史”。
**验证清单**:
- [x] 四则运算、复合表达式、括号、一元符号、小数
- [x] 非法表达式 / 除零错误处理
- [x] 历史写入数据库并持久化(刷新不丢失)
- [x] 删除指定记录、清空全部
- [x] 停掉后端 → 前端无法独立得到新结果(分离验证通过)
- [x] 15 项端到端自动化检查全部通过
---
## 5. Code Explanation
### 5.1 表达式解析与求值(后端 `app/parser.py`)
**为什么这样设计**:作业明确禁止 `eval`/`exec` 直接执行用户输入。手写递归下降解析器只接受算术文法,天然安全,且能精确控制优先级、括号、一元符号与异常。
```python
def _tokenize(expression: str) -> List[_Token]:
# 字符白名单预检:只允许数字、运算符、括号、小数点、空格
if not _ALLOWED_CHARS.match(expression):
raise InvalidExpressionError("Expression contains unsupported characters")
...
# 数字用正则 \d+(\.\d+)? 识别为 NUMBER token
class _Parser:
def _expression(self) -> float: # + 和 -
value = self._term()
while True:
if self._accept(_TOKEN_PLUS):
value = value + self._term()
elif self._accept(_TOKEN_MINUS):
value = value - self._term()
else:
return value
def _term(self) -> float: # * 和 /
value = self._factor()
while True:
if self._accept(_TOKEN_TIMES):
value = value * self._factor()
elif self._accept(_TOKEN_DIVIDE):
divisor = self._factor()
if divisor == 0:
raise DivisionByZeroError("Division by zero")
value = value / divisor
else:
return value
def _factor(self) -> float: # 一元符号 / 数字 / 括号
if self._accept(_TOKEN_PLUS):
return self._factor()
if self._accept(_TOKEN_MINUS):
return -self._factor()
if self._peek().kind == _TOKEN_NUMBER:
return float(self._advance().value)
if self._accept(_TOKEN_LPAREN):
value = self._expression()
self._expect(_TOKEN_RPAREN)
return value
raise InvalidExpressionError(f"Unexpected token {self._peek().kind}")
```
文法分层(`expression → term → factor`)天然实现了“先乘除后加减”,`factor` 中递归处理一元正负号,`( expr )` 处理括号;未匹配完的 token 视为非法表达式。
### 5.2 计算接口(后端 `app/main.py`)
**为什么这样设计**:接口只接受表达式字符串,计算与存储都在后端完成,返回统一结构便于前端处理。
```python
@app.post("/api/calculate", response_model=CalculateResponse)
def calculate_expression(payload: CalculateRequest) -> CalculateResponse:
expression = payload.expression.strip()
try:
result = calculate(expression) # 核心计算只发生在后端
except DivisionByZeroError as exc:
raise HTTPException(400, detail={"success": False, "message": str(exc)})
except InvalidExpressionError as exc:
raise HTTPException(400, detail={"success": False, "message": str(exc)})
record_id = repository.insert(expression, result) # 成功后写库
...
return CalculateResponse(success=True, expression=expression,
result=result, record_id=record_id, created_at=...)
```
### 5.3 数据库插入 / 查询 / 删除(后端 `app/database.py`)
**为什么这样设计**:把所有 SQL 集中在一个仓储类,路由层不接触 SQL;`sqlite3.Row` 直接转 dict,方便 FastAPI 序列化。
```python
def insert(self, expression: str, result: str) -> int:
created_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
with self._connect() as connection:
cursor = connection.execute(
"INSERT INTO calculation_history (expression, result, created_at) "
"VALUES (?, ?, ?)", (expression, result, created_at))
connection.commit()
return int(cursor.lastrowid)
def delete(self, record_id: int) -> bool:
with self._connect() as connection:
cursor = connection.execute(
"DELETE FROM calculation_history WHERE id = ?", (record_id,))
connection.commit()
return cursor.rowcount > 0 # rowcount==0 → 记录不存在 → 404
```
### 5.4 前端 API 请求(前端 `src/js/calculator.js`)
**为什么这样设计**:前端职责是“发表达式、收结果”,绝不计算;所有请求用 `async/await` 并捕获网络异常。
```js
async function evaluate() {
if (expression.trim() === '') return;
const payload = { expression: toApiExpression(expression) }; // ×→*, ÷→/
try {
const response = await fetch(`${API_BASE}/api/calculate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await response.json();
if (!response.ok || data.success === false) {
showError((data.detail && data.detail.message) || data.message || 'Calculation failed');
return;
}
showResult(data.result); // 结果来自后端
expression = '';
renderDisplay();
await loadHistory(); // 计算成功后刷新历史
} catch (error) {
showError('无法连接后端服务,请确认后端已启动');
}
}
```
### 5.5 输入校验与错误处理(前后端配合)
- 前端:空表达式不发送请求;对非法输入给出即时反馈;
- 后端:长度限制(≤200)、字符白名单、语法校验、除零检测——**后端是安全边界**,前端校验仅为体验优化。
---
## 6. Personal Journey and Learnings
### 遇到的问题与解决过程
1. **浮点精度问题**:`0.1 + 0.2` 直接计算得到 `0.30000000000000004`。解决:结果 `round(value, 10)` 后格式化为字符串,整数去除尾零(`3.0 → "3"`)。
2. **`204 No Content` 与响应体冲突**:FastAPI 不允许 204 带响应体,删除接口改为返回 `200 + JSON`,语义依然清晰。
3. **前后端联调跨域**:开发时前端在 `5500` 端口、后端在 `8000` 端口,通过后端 CORS 中间件允许所有来源解决。
4. **“停后端验证”**:最初担心前端会缓存结果,实测停掉后端后前端确实无法获得任何新结果,说明分离架构是真实有效的,这也成为验证报告中最有说服力的一点。
5. **端到端自动化测试**:用 Playwright 驱动真实浏览器点击计算器,15 项检查全绿,比手工点击更可靠,也为博客提供了真实截图。
### 新学到的知识
- **前后端分离的本质**:不是“两个文件夹”,而是职责边界的划分——数据校验、计算、持久化归属后端,展示与交互归属前端,通信只通过 HTTP API。
- **递归下降解析器**:通过 `expression → term → factor` 的文法分层,天然实现运算符优先级;这也是编译原理课程知识的第一次落地。
- **安全编码**:`eval` 是危险操作,白名单 + 手写解析器是更稳妥的方案。
- **数据库持久化与 REST 语义**:`200/400/404` 状态码的正确使用,`DELETE` 幂等性设计。
### 可改进方向
- 后端接入 MySQL/PostgreSQL,支持多用户独立历史;
- 历史分页加载与更复杂的统计(如最常用表达式);
- 前端引入单元测试(Vitest/Jest)与 ESLint 静态检查;
- 公网部署到云服务器并配置 HTTPS 域名。
---
*感谢助教与老师的作业反馈,欢迎在评论区交流讨论。*