First Assignment — Front-End and Back-End Calculator (832401112-24125652-HuangZhiyuan)

832401112黄致远 832401112黄致远 墨衍会员 · AI 创作全网分发 墨衍智能分发 通过墨衍一键同步至各平台 可同步平台 CSDN微信公众号微博知乎掘金百家号博客园抖音小红书 你的文章也可以这样分发 墨衍会员 ¥399起/年,写一次发全网 我也要 + 加入社区

## 目录(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 域名。

 

---

 

*感谢助教与老师的作业反馈,欢迎在评论区交流讨论。*

 

...全文
评论
收藏 已收藏
分享
返回顶部