我的导航页终于能在线改了:一次克制的重构

举报
yd_291797886 发表于 2026/10/25 14:07:46 2026/10/25
【摘要】 # 我的导航页终于能在线改了:一次克制的重构> 上一篇《用一个 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)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。