我的导航页终于能在线改了:一次克制的重构
【摘要】 # 我的导航页终于能在线改了:一次克制的重构> 上一篇《用一个 Flask 文件,搭一个属于自己的导航首页》里,我把 19 个常用网站塞进了一个单文件 Flask 应用。它很好,但有个硬伤:**每改一个链接,都要动代码、重启服务**。这一篇讲我怎么给它加上一个带密码的管理后台——以及为什么这次我破了"不引入前端框架"的戒。## 上一篇留下的问题先说清楚上一篇做了什么,免得你看到一半发现对不上...
# 我的导航页终于能在线改了:一次克制的重构
> 上一篇《用一个 Flask 文件,搭一个属于自己的导航首页》里,我把 19 个常用网站塞进了一个单文件 Flask 应用。它很好,但有个硬伤:**每改一个链接,都要动代码、重启服务**。这一篇讲我怎么给它加上一个带密码的管理后台——以及为什么这次我破了"不引入前端框架"的戒。
## 上一篇留下的问题
先说清楚上一篇做了什么,免得你看到一半发现对不上。
那是一个 `app.py`,里面有一个 `CATEGORIES` 列表,装着我常用的 19 个网站;一个内联 HTML 模板;十几行原生 JavaScript 做搜索过滤。跑起来就是 `python app.py`,打开 `localhost:8080` 就能用。
问题出在"改"这件事上。
有一天我想把"Python 文档"换成公司内部 Wiki,于是我打开 `app.py`,在字典里加一行,保存,重启服务。一次两次还行,次数多了就烦了。更要命的是——**我想要的"能在页面上直接改",和"别人不应该能改我的数据"这两件事,必须在同一个架构里同时满足**。
上一篇的设计里根本没有"用户"这个概念,所有人看到的都是同一个静态页面,谁也改不了。现在我要开一个口子,让"我"能改,还得保证"不是别人"。
这就是这次重构的全部动机。
## 先想清楚:要什么,不要什么
动手之前,我把需求列成了三句话:
1. **前台依然只读**——没登录的人,只能看,不能改,最好连"能改"的入口都看不出来。
2. **后台能增删改**——分类、链接、站点标题,都能在页面上操作,改完立即生效。
3. **防护从简**——我不是要做一个用户系统。一个口令就够,我不打算为这个页面做注册、找回密码、多角色权限。
第 3 条很关键。它直接决定了后面所有的取舍:**不做完整的用户体系,只做"单口令 + 会话"**。
## 技术选型:这次为什么用 Vue
上一篇我特意写了"没有用 Vue、React,因为杀鸡不用牛刀"。这一篇我要解释为什么这次**改主意用了 Vue 3 + Vite**——否则就自相矛盾了。
原因不是"Vue 更好",而是**问题变了**:
| 维度 | 上一篇(只读展示) | 这一次(可管理) |
|------|--------------------|------------------|
| 页面数量 | 1 个 | 3 个(导航 / 登录 / 后台) |
| 状态 | 无 | 登录态、编辑态、待保存表单 |
| 交互 | 一个搜索框 | 弹窗编辑、排序、即时保存、提示 |
| 数据 | 硬编码在代码里 | 来自接口,需要加载与刷新 |
当交互从"过滤一下"变成"一个能增删改的管理界面"时,用原生 JS 手写 DOM 操作会迅速失控。这时候引入框架,不是追新,是**匹配复杂度**。
但同样重要的是——**克制**。Vue 生态里现成的东西很多,我几乎都没用:
- **不用 vue-router**:三个视图的切换,我用 `location.hash` 加一个 `ref` 就够了,省掉一个依赖。
- **不用 Pinia / Vuex**:整个应用共享的状态就一个 `store`(站点信息 + 分类列表),用一个 `reactive` 对象 + `provide/inject` 传递即可。
- **不用 UI 组件库(Element / Naive / Ant)**:为了几个弹窗和按钮引入一整个组件库,性价比太低,手写 CSS 反而更贴合我要的深色风格。
最终依赖只有三个:`vue`、`vite`、`@vitejs/plugin-vue`。**框架该用就用,但框架的生态不必照单全收。**
## 架构:前端构建产物,交给 Flask 托管
这次的结构比上一篇多了一层,但依然简单:
```
浏览器
│
├── 静态资源(HTML / JS / CSS) ──► Flask 托管 frontend/dist
│
└── /api/* 请求 ──► Flask API
├── 公开:读取数据
└── 鉴权:写入数据(改 links.json)
```
前端用 Vue 3 + Vite 开发、构建,产物 `frontend/dist` 由 Flask 直接托管。**部署的时候只有一个服务要跑**,不需要 Nginx 再单独伺服前端静态文件,也就少了一份配置。
开发的时候则是两个进程:Vite 的开发服务器跑在 `5173`,它把 `/api` 反向代理到 Flask 的 `8080`。这一段在 `vite.config.js` 里配:
```js
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: {
'/api': { target: 'http://127.0.0.1:8080', changeOrigin: true },
},
},
})
```
这样开发时前端改代码有热更新,改接口逻辑又能实时生效,两边都不将就。
## 数据模型:从"代码里的字典"到"一个 JSON 文件"
上一篇数据是写在 `app.py` 里的 Python 字典。这次它得能被程序读写,所以挪到了一个 JSON 文件 `data/links.json`:
```json
{
"site": {
"title": "我的导航",
"subtitle": "常用网站一键直达",
"accent": "#5b8def"
},
"categories": [
{
"id": "search",
"name": "搜索引擎",
"icon": "🔍",
"color": "#4285F4",
"links": [
{ "id": "google", "name": "Google", "url": "https://www.google.com", "color": "#4285F4", "desc": "" }
]
}
]
}
```
比上一篇多了两样东西:给分类和链接都加了 `id`,以及多了一层 `site` 站点信息。`id` 是为了在接口里能精准定位到某一个分类或链接——增删改都得有个稳定的标识。
写入用了一个"先写临时文件再原子替换"的小技巧,避免写到一半崩溃导致 JSON 损坏:
```python
def _write_json(path, data):
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), "utf-8")
tmp.replace(path) # 原子替换
```
## 安全:单口令到底怎么做才算"够"
这是我花最多心思的部分。需求是"一个口令,别人改不了",但实现上有几个坑必须填。
### 1. 口令不存明文
口令用 Werkzeug 提供的哈希函数存储,`data/secret.json` 里只留一个哈希值:
```python
from werkzeug.security import generate_password_hash, check_password_hash
data["password_hash"] = generate_password_hash(password)
# 校验
if not check_password_hash(data["password_hash"], submitted):
return jsonify({"ok": False, "error": "管理口令错误"}), 401
```
有人可能会想"我自己的服务器,明文存也无所谓吧"。但口令往往会被复用——我不想因为图省事,让自己的一个常用密码躺在某个文件里。
### 2. 口令从哪来:环境变量优先
启动时按这个顺序决定口令:
1. 如果设置了环境变量 `NAV_ADMIN_PASSWORD`,以它为准;
2. 否则读 `data/secret.json` 里的哈希;
3. 两者都没有,**自动生成一个随机口令并打印到控制台**。
第 3 条是为首次运行准备的——不会出现"装好了但进不去"的死锁。
### 3. 会话用 HttpOnly + SameSite 的 Cookie
登录成功后,服务端把登录态写进会话,浏览器存一个 Cookie。关键的几个属性:
```python
app.config.update(
SESSION_COOKIE_HTTPONLY=True, # JS 读不到,降低 XSS 窃取风险
SESSION_COOKIE_SAMESITE="Lax", # 跨站请求不自动带 Cookie,缓解 CSRF
PERMANENT_SESSION_LIFETIME=timedelta(hours=12),
)
```
### 4. 所有写接口统一挂一把锁
后端最有价值的那几行,是一个装饰器:
```python
def require_admin(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
if not is_authenticated():
return jsonify({"ok": False, "error": "未登录或登录已过期"}), 401
return fn(*args, **kwargs)
return wrapper
```
然后**每一个**会改数据的接口都挂上它:
```python
@app.post("/api/categories")
@require_admin
def api_add_category():
...
```
这一点我在设计时反复确认过:**安全不能靠前端**。前端可以隐藏"管理"按钮,但真正决定"能不能改"的,是服务端这个装饰器。只要有一个写接口忘了挂,前面所有的防护都白做。
### 5. 登录失败要限速
单口令最怕被暴力猜。我加了一个简单的内存计数:同一个 IP 连续失败 5 次,锁定 60 秒。
```python
if _locked(ip):
return jsonify({"ok": False, "error": "尝试过于频繁,请稍后再试"}), 429
```
### 6. 服务端二次校验输入
前端已经做了校验(比如链接必须以 `http://` 或 `https://` 开头),但服务端仍然独立校验一遍。理由和上一条一样:**前端的校验是体验,服务端的校验才是防线**。顺手还挡掉了一类常见问题——只允许 `http/https` 协议,杜绝 `javascript:` 这种用来做 XSS 的伪协议。
## 前端:前台几乎没变,后台是新的
### 前台:数据来自接口,搜索用 computed
前台展示的样子基本继承上一篇,只是数据从"模板渲染时注入"变成了"启动后从 `/api/site` 拉取"。搜索过滤这次用 Vue 的 `computed` 写,比上次的原生 DOM 操作更清爽:
```js
const filtered = computed(() => {
const query = keyword.value.trim().toLowerCase()
if (!query) return store.categories
return store.categories
.map((category) => ({
...category,
links: (category.links || []).filter((link) =>
`${link.name} ${link.desc || ''} ${link.url}`.toLowerCase().includes(query),
),
}))
.filter((category) => category.links.length > 0)
})
```
输入关键词 → 过滤链接 → 丢掉没有命中的分类。和上一篇逻辑一样,只是换了种写法。
### 后台:一个弹窗,管住所有增删改
管理后台我用同一个弹窗组件处理"新建分类 / 编辑分类 / 新建链接 / 编辑链接"这四种场景,靠一个 `editor` 对象区分:
```js
const editor = reactive({
open: false,
type: 'category', // 'category' | 'link'
mode: 'create', // 'create' | 'edit'
cid: '', lid: '',
form: { name: '', icon: '', color: '#5b8def', url: '', desc: '' },
})
```
每次操作都是"调接口 → 成功后重新拉取数据 → 弹一个提示",页面状态永远和服务器一致,不会出现"界面上改了但其实没存上"的错觉。
## 一个真实的坑:Windows 上的换行符
这个坑值得单独说,因为它差点让我的 `.gitignore` 完全失效。
我在 Windows 上写了 `.gitignore`,里面排除了 `data/secret.json`(绝不能进版本库)和几个云上凭证文件。写完检查时发现——**文件被存成了 CRLF 换行**。
对一般文件来说,CRLF 没什么影响。但 `.gitignore` 对行尾很敏感,CRLF 可能导致规则匹配不上,于是本该被忽略的 `secret.json` 和凭证文件,照样出现在 `git status` 的未跟踪列表里。
修复很简单,转成 LF 就行:
```powershell
$t = [IO.File]::ReadAllText($path).Replace("`r`n", "`n").Replace("`r", "`n")
[IO.File]::WriteAllText($path, $t, (New-Object System.Text.UTF8Encoding($false)))
```
修复后用 `git status` 复核,那几个敏感文件果然从列表里消失了。**在 Windows 上,`.gitignore` 写完一定要验一遍**——这条经验我是花了代价才记住的。
## 验证:别只看"页面能不能打开"
上一篇的验证就是"打开浏览器看一眼"。这次逻辑多了、还涉及登录和权限,靠肉眼看不够。我做了两轮验证。
### 第一轮:接口级端到端测试
用 Flask 自带的 `test_client`(不需要真的起服务器),把关键路径都跑了一遍。13 个用例里,我最在意的是这几个"反向"用例:
| 请求 | 预期 | 结果 |
|------|------|------|
| 未登录时 POST 写接口 | 401 拒绝 | 401 ✓ |
| 错误口令登录 | 401 拒绝 | 401 ✓ |
| 非法链接地址(`ftp://…`) | 400 拒绝 | 400 ✓ |
| 退出登录后再写 | 401 拒绝 | 401 ✓ |
| 正确口令登录后写 | 200 成功 | 200 ✓ |
**"该被拒绝的请求确实被拒绝了",比"该成功的请求成功了"更重要。** 尤其那几条 401,直接对应上面第 4 点的 `require_admin` 装饰器有没有漏挂。
### 第二轮:真实 HTTP 冒烟
接口层通过后,再真的起一个服务器,用 HTTP 请求验证静态资源托管:
```
GET / -> 200
GET /assets/index-xxx.js -> 200 84534 bytes
GET /assets/index-xxx.css -> 200 10936 bytes
GET /api/site -> 200
POST /api/login -> 200
POST /api/categories (带 Cookie) -> 200
```
`npm run build` 的输出也顺手记一下,作为体积参考:
```
dist/index.html 0.46 kB │ gzip: 0.31 kB
dist/assets/index-xxx.css 10.94 kB │ gzip: 3.09 kB
dist/assets/index-xxx.js 84.53 kB │ gzip: 32.40 kB
✓ built in 1.03s
```
一个带完整管理后台的应用,gzip 后 32KB 的 JS,我觉得这个体积是可以接受的——**克制选型的回报,有一部分就体现在这里**。
## 部署到自己的服务器
这次的目标是部署到我自己的服务器上,和上一篇"用隧道临时暴露"不同。步骤不复杂:
1. 构建前端:`cd frontend && npm install && npm run build`
2. 装上后端依赖:`pip install -r requirements.txt`
3. 设置口令:`export NAV_ADMIN_PASSWORD='你的口令'`
4. 用生产级 WSGI 服务器托管(**不要直接用 `app.run` 对外**):
- Linux:`gunicorn -w 2 -b 127.0.0.1:8080 app:app`
- Windows:`waitress-serve --listen=127.0.0.1:8080 app:app`
5. 前面挂 Nginx 反代,**并配好 HTTPS**。
最后一条我必须强调:**口令是以明文 POST 提交的**,如果不用 HTTPS,同网络里任何人都能抓到它。对这种"带着钥匙"的应用,HTTPS 不是可选项。
## 一点思考:什么时候该"过度设计"?
上一篇我写过一句话:"单文件的边界在哪里?如果导航页要加用户系统、加自定义分类管理、加数据持久化,单文件就会变得臃肿。"
这一篇,正好是那句话的实践——我确实加了这三样东西,于是**顺理成章地做了拆分**:数据从代码里搬到 JSON,视图从前端模板变成 Vue 组件,后端从"渲染一个页面"变成"提供一组 API"。
有意思的是:**上一版和这一版,都不算"错"**。上一版的极简,匹配当时"只读展示"的需求;这一版的拆分,匹配现在"要能管理"的需求。架构没有绝对的对错,只有**和当前问题合不合身**。
如果非要说这次重构的原则,大概就是那三个字:**够用就好**。要管理后台,就加;要框架,就用;但框架的生态、路由库、状态库、UI 库——只要不是非用不可,就都不加。
## 总结
从一个改链接要重启的单文件,到一个能登录、能在线增删改、还防住了别人的导航站,这一版比上一版复杂了不少。但复杂的地方都花在了刀刃上:**数据与代码分离、前端与后端分离、权限在服务端守住**。
而克制的地方也同样明确:**三个前端依赖、一个 JSON 文件、一个装饰器搞定鉴权。**
写到这里我又想起上一版结尾那句:"有时候最好的架构就是没有架构。"这一版我想补一句——**当问题长大时,让架构跟着长,但别让它长得比问题更胖。**
---
*技术栈*:Vue 3 + Vite(前端)· Flask 3(后端)· JSON 文件(存储)
*功能*:前台导航 + 实时搜索 · 后台分类/链接增删改与排序 · 单口令登录鉴权
*安全*:口令哈希存储 · 登录限速 · HttpOnly + SameSite 会话 · 服务端二次校验
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱:
cloudbbs@huaweicloud.com
- 点赞
- 收藏
- 关注作者
评论(0)