# MoKeeText 接入 Mokee Gateway 统一登录网关 — 实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 将 MoKeeText 接入 Mokee Gateway 统一登录网关,实现用户登录认证、用户数据隔离、按钮权限控制。
**Architecture:** 前端所有 API 请求从直连后端改为经过网关(`gw.server.zgitm.com`),网关验证 Token 后注入 `X-User-*` 请求头转发到后端。后端通过 FastAPI 中间件读取请求头,存入 `request.state.user`,所有业务查询据此过滤用户数据。前端用 UserChip Widget 替代硬编码的 Admin 头像,新增路由守卫和 v-permission 指令。
**Tech Stack:** FastAPI (Python), Vue 3 + Vite, Axios, Mokee Gateway UserChip Widget
---
## 文件结构总览
```
mokeeText/
├── app.py # [修改] CORS 配置 + 注册用户上下文中间件
├── middleware/
│ └── user_context.py # [新建] FastAPI 用户上下文中间件
├── routes/
│ ├── notes.py # [修改] 查询加 created_by 过滤,写入取 X-User-Id
│ ├── websites.py # [修改] 同上
│ ├── import_api.py # [修改] 同上
│ ├── share.py # [修改] 同上
│ └── upload.py # [修改] 同上
├── src/ # Vue 3 前端源码
│ ├── main.js # [修改] 挂载后加载 UserChip Widget
│ ├── router.js # [修改] 添加路由守卫
│ ├── App.vue # [修改] 用 UserChip 占位替换硬编码 Admin
│ ├── composables/
│ │ └── useApi.js # [修改] 改为网关地址 + 携带 Token + X-System-Code
│ ├── utils/
│ │ └── request.js # [新建] Axios 实例(拦截器)
│ └── directives/
│ └── permission.js # [新建] v-permission 指令
├── .env # [新建] 开发环境变量
├── .env.production # [新建] 生产环境变量
├── index.html # [修改] 添加 UserChip CSS + process polyfill
└── vite.config.js # [修改] 确保 env 前缀配置
```
---
## 前置准备:网关平台操作
这些操作在网关平台的数据库中执行,不涉及本项目代码。
### Task 0: 网关数据库初始化
**执行位置:** 网关平台数据库(非 mokeeText 数据库)
- [ ] **Step 1: 执行菜单和角色初始化 SQL**
执行接入文档 §6.2 的完整 SQL(创建目录、页面菜单、按钮权限、管理员角色、普通用户角色)。
```sql
-- 在网关数据库执行
-- 0. 获取系统 ID
SET @system_id = (SELECT id FROM sys_system WHERE system_code = 'mokeetext-edit' AND is_deleted = 0 LIMIT 1);
-- 1. 创建顶级菜单目录
SET @menu_sys = REPLACE(UUID(),'-','');
SET @menu_biz = REPLACE(UUID(),'-','');
INSERT INTO sys_menu (id, system_id, parent_id, menu_name, menu_path, component, icon, menu_type, sort_order, status, permission) VALUES
(@menu_sys, @system_id, '0', '系统管理', '/system', 'Layout', 'Setting', 1, 1, 1, NULL),
(@menu_biz, @system_id, '0', '业务管理', '/biz', 'Layout', 'Document', 1, 2, 1, NULL);
-- 2. 创建页面菜单(对应 MoKeeText 实际路由:notes, websites, shares)
INSERT INTO sys_menu (id, system_id, parent_id, menu_name, menu_path, component, icon, menu_type, sort_order, status, permission) VALUES
(REPLACE(UUID(),'-',''), @system_id, @menu_sys, '笔记管理', '/notes', 'notes/index', 'Document', 2, 1, 1, 'notes:list'),
(REPLACE(UUID(),'-',''), @system_id, @menu_sys, '网站管理', '/websites', 'websites/index', 'Key', 2, 2, 1, 'websites:list'),
(REPLACE(UUID(),'-',''), @system_id, @menu_sys, '分享管理', '/shares', 'shares/index', 'Link', 2, 3, 1, 'shares:list');
-- 3. 创建按钮权限
INSERT INTO sys_menu (id, system_id, parent_id, menu_name, menu_path, component, icon, menu_type, sort_order, status, permission) VALUES
-- 笔记操作
(REPLACE(UUID(),'-',''), @system_id, @menu_sys, '创建笔记', NULL, NULL, '', 3, 1, 1, 'notes:add'),
(REPLACE(UUID(),'-',''), @system_id, @menu_sys, '编辑笔记', NULL, NULL, '', 3, 2, 1, 'notes:edit'),
(REPLACE(UUID(),'-',''), @system_id, @menu_sys, '删除笔记', NULL, NULL, '', 3, 3, 1, 'notes:delete'),
-- 网站操作
(REPLACE(UUID(),'-',''), @system_id, @menu_sys, '创建网站', NULL, NULL, '', 3, 1, 1, 'websites:add'),
(REPLACE(UUID(),'-',''), @system_id, @menu_sys, '编辑网站', NULL, NULL, '', 3, 2, 1, 'websites:edit'),
(REPLACE(UUID(),'-',''), @system_id, @menu_sys, '删除网站', NULL, NULL, '', 3, 3, 1, 'websites:delete'),
-- 分享操作
(REPLACE(UUID(),'-',''), @system_id, @menu_sys, '创建分享', NULL, NULL, '', 3, 1, 1, 'shares:add'),
(REPLACE(UUID(),'-',''), @system_id, @menu_sys, '删除分享', NULL, NULL, '', 3, 2, 1, 'shares:delete');
-- 4. 创建角色
SET @role_admin = REPLACE(UUID(),'-','');
SET @role_user = REPLACE(UUID(),'-','');
INSERT INTO sys_role (id, system_id, role_name, role_code, description, status) VALUES
(@role_admin, @system_id, '管理员', 'admin', '拥有全部权限', 1),
(@role_user, @system_id, '普通用户', 'user', '拥有基本权限', 1);
-- 5. 管理员获得全部菜单权限
INSERT INTO sys_role_menu (id, role_id, menu_id)
SELECT REPLACE(UUID(),'-',''), @role_admin, m.id
FROM sys_menu m WHERE m.system_id = @system_id;
-- 6. 普通用户只分配页面菜单(不含按钮权限)
INSERT INTO sys_role_menu (id, role_id, menu_id)
SELECT REPLACE(UUID(),'-',''), @role_user, m.id
FROM sys_menu m WHERE m.system_id = @system_id
AND m.menu_type != 3;
```
- [ ] **Step 2: 创建网关管理员账号**
执行接入文档 §7 的 SQL。
```sql
-- 在网关数据库执行
-- 1. 创建管理员用户(密码 admin123 的 BCrypt 密文)
INSERT INTO sys_user (id, username, password, real_name, email, phone, status, is_deleted)
SELECT REPLACE(UUID(),'-',''), 'admin_mokeetext-edit',
'$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iKTVKIUi',
'mokee编辑器管理员', 'admin@mokeetext-edit.com', NULL, 1, 0
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM sys_user WHERE username = 'admin_mokeetext-edit');
-- 2. 绑定到 gateway 系统
INSERT INTO sys_user_system (id, user_id, system_id)
SELECT REPLACE(UUID(),'-',''), u.id, s.id
FROM sys_user u, sys_system s
WHERE u.username = 'admin_mokeetext-edit'
AND s.system_code = 'gateway'
AND NOT EXISTS (
SELECT 1 FROM sys_user_system us
WHERE us.user_id = u.id AND us.system_id = s.id
);
-- 3. 分配 gateway-app-admin 角色
INSERT INTO sys_user_role (id, user_id, role_id)
SELECT REPLACE(UUID(),'-',''), u.id, r.id
FROM sys_user u, sys_role r, sys_system s
WHERE u.username = 'admin_mokeetext-edit'
AND r.role_code = 'gateway-app-admin'
AND r.system_id = s.id
AND s.system_code = 'gateway'
AND NOT EXISTS (
SELECT 1 FROM sys_user_role ur
WHERE ur.user_id = u.id AND ur.role_id = r.id
);
-- 4. 绑定到 mokeetext-edit 系统
INSERT INTO sys_user_system (id, user_id, system_id)
SELECT REPLACE(UUID(),'-',''), u.id, s.id
FROM sys_user u, sys_system s
WHERE u.username = 'admin_mokeetext-edit'
AND s.system_code = 'mokeetext-edit'
AND NOT EXISTS (
SELECT 1 FROM sys_user_system us
WHERE us.user_id = u.id AND us.system_id = s.id
);
-- 5. 分配本系统 admin 角色
INSERT INTO sys_user_role (id, user_id, role_id)
SELECT REPLACE(UUID(),'-',''), u.id, r.id
FROM sys_user u, sys_role r, sys_system s
WHERE u.username = 'admin_mokeetext-edit'
AND r.role_code = 'admin'
AND r.system_id = s.id
AND s.system_code = 'mokeetext-edit'
AND NOT EXISTS (
SELECT 1 FROM sys_user_role ur
WHERE ur.user_id = u.id AND ur.role_id = r.id
);
```
- [ ] **Step 3: 验证网关配置**
在网关管理后台用 `admin_mokeetext-edit / admin123` 登录,确认:
- 能看到「mokee编辑器」系统
- 菜单树包含笔记管理、网站管理、分享管理
- 角色列表包含 admin 和 user
---
## 阶段一:后端改造
### Task 1: 新建 FastAPI 用户上下文中间件
**文件:**
- Create: `middleware/__init__.py`
- Create: `middleware/user_context.py`
- [ ] **Step 1: 创建 middleware 包**
```bash
mkdir -p middleware
```
```python
# middleware/__init__.py
"""中间件包"""
```
- [ ] **Step 2: 编写 UserContext 辅助类 + 中间件**
```python
# middleware/user_context.py
"""Mokee Gateway 用户上下文中间件
网关转发请求时注入以下请求头,本中间件读取并存入 request.state.user:
X-User-Id, X-User-Name, X-User-Account, X-User-Email,
X-User-Post, X-User-Roles, X-System-Id, X-System-Code, X-System-Name
本地开发时(无网关),使用 X-Dev-Mock-User 请求头或默认 admin 用户。
"""
import os
from starlette.middleware.base import BaseHTTPMiddleware
from fastapi import Request
# 网关注入的所有请求头
GATEWAY_HEADERS = [
"X-User-Id",
"X-User-Name",
"X-User-Account",
"X-User-Email",
"X-User-Post",
"X-User-Roles",
"X-System-Id",
"X-System-Code",
"X-System-Name",
]
# 本地开发默认用户(模拟网关注入)
DEV_DEFAULT_USER = {
"X-User-Id": "admin",
"X-User-Name": "管理员",
"X-User-Account": "admin",
"X-User-Email": "admin@zgitm.com",
"X-User-Post": "",
"X-User-Roles": '[{"roleId":"1","roleName":"管理员","roleCode":"admin"}]',
"X-System-Id": "1",
"X-System-Code": "mokeetext-edit",
"X-System-Name": "mokee编辑器",
}
class UserContext:
"""用户上下文 — 提供快捷访问方法"""
@staticmethod
def from_request(request: Request) -> dict:
"""从 request.state 获取用户信息"""
return getattr(request.state, "user", {})
@staticmethod
def get_user_id(request: Request) -> str:
return UserContext.from_request(request).get("X-User-Id", "admin")
@staticmethod
def get_user_name(request: Request) -> str:
return UserContext.from_request(request).get("X-User-Name", "")
@staticmethod
def get_user_account(request: Request) -> str:
return UserContext.from_request(request).get("X-User-Account", "")
@staticmethod
def get_system_code(request: Request) -> str:
return UserContext.from_request(request).get("X-System-Code", "")
class UserContextMiddleware(BaseHTTPMiddleware):
"""将网关注入的用户信息存入 request.state.user"""
async def dispatch(self, request: Request, call_next):
user_info = {}
# 读取网关注入的请求头
for header in GATEWAY_HEADERS:
value = request.headers.get(header)
if value:
user_info[header] = value
# 本地开发:无网关时使用默认用户
if not user_info.get("X-User-Id"):
dev_mode = os.getenv("DEV_MODE", "false").lower() == "true"
if dev_mode or request.headers.get("X-Dev-Mock-User"):
user_info = DEV_DEFAULT_USER.copy()
request.state.user = user_info
response = await call_next(request)
return response
```
**设计说明:**
- `request.state.user` 存储当前用户信息,路由函数通过 `UserContext.from_request(request)` 获取
- `DEV_MODE=true` 环境变量或 `X-Dev-Mock-User` 请求头开启本地开发模式
- 生产环境无网关请求头时 `user_info` 为空字典,不会错误地暴露数据
- [ ] **Step 3: 验证中间件可以导入**
```bash
cd /e/AI/claude/new/mokeeText && python -c "from middleware.user_context import UserContextMiddleware, UserContext; print('OK')"
```
Expected: `OK`
---
### Task 2: 修改 app.py — 注册中间件 + 更新 CORS
**文件:**
- Modify: `app.py`
- [ ] **Step 1: 更新 CORS 配置 + 注册用户上下文中间件**
将 `app.py` 中的 CORS 配置和中间件注册改为:
```python
"""MoKeeText — FastAPI 入口"""
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, Response
from logger import logger
from middleware.user_context import UserContextMiddleware
from routes.notes import router as notes_router
from routes.websites import router as websites_router
from routes.import_api import router as import_router
from routes.share import router as share_router
from routes.upload import router as upload_router
app = FastAPI(title="MoKeeText", version="2.1.0")
# CORS — 允许网关域名和前端域名跨域
# 注意:allow_credentials=True 时不能使用 allow_origins=["*"]
app.add_middleware(
CORSMiddleware,
allow_origins=[
"https://mokeetext.zgitm.com",
"https://mokeetext.server.zgitm.com", # 分享页 + 图片直连
"https://gw.server.zgitm.com",
"http://localhost:5173",
],
allow_methods=["*"],
allow_headers=["*"],
allow_credentials=True,
)
# 用户上下文中间件 — 在 CORS 之后、请求日志之前注册
app.add_middleware(UserContextMiddleware)
# ... 后续代码保持不变 ...
```
**关键变更:**
1. `allow_origins` 从 `["*"]` 改为显式列表,因为 `allow_credentials=True` 与 `*` 不兼容
2. 新增 `UserContextMiddleware` 注册
- [ ] **Step 2: 验证应用可以启动**
```bash
cd /e/AI/claude/new/mokeeText && python -c "from app import app; print('App loaded OK, routes:', len(app.routes))"
```
Expected: `App loaded OK, routes: N`
---
### Task 3: 改造后端路由 — 用户数据隔离
**涉及 5 个文件。** 核心思路:所有 `created_by` / `updated_by` 从 `request.state.user` 获取,所有查询加 `created_by` 过滤。
**文件:**
- Modify: `routes/notes.py`
- Modify: `routes/websites.py`
- Modify: `routes/import_api.py`
- Modify: `routes/share.py`
- Modify: `routes/upload.py`
- [ ] **Step 1: 改造 routes/notes.py**
当前 `created_by` 硬编码为 `"admin"`,查询不过滤用户。改为从请求上下文获取。
在每个路由函数开头添加:
```python
from fastapi import Request
from middleware.user_context import UserContext
# 在每个路由函数中:
def list_notes(folder_id: int = None, db: Session = Depends(get_db), request: Request = None):
user_id = UserContext.get_user_id(request)
q = db.query(Note).filter(Note.created_by == user_id)
if folder_id:
q = q.filter(Note.folder_id == folder_id)
return q.order_by(Note.updated_at.desc()).all()
def create_note(data: dict, db: Session = Depends(get_db), request: Request = None):
user_id = UserContext.get_user_id(request)
note = Note(
title=data.get("title", "无标题"),
content=data.get("content", ""),
folder_id=data.get("folder_id"),
created_by=user_id,
updated_by=user_id,
)
db.add(note)
db.commit()
db.refresh(note)
return {...}
def update_note(note_id: int, data: dict, db: Session = Depends(get_db), request: Request = None):
user_id = UserContext.get_user_id(request)
note = db.query(Note).filter(Note.id == note_id, Note.created_by == user_id).first()
if not note:
raise HTTPException(404, "笔记不存在")
# 更新字段...
note.updated_by = user_id
db.commit()
return {...}
def delete_note(note_id: int, db: Session = Depends(get_db), request: Request = None):
user_id = UserContext.get_user_id(request)
note = db.query(Note).filter(Note.id == note_id, Note.created_by == user_id).first()
if not note:
raise HTTPException(404, "笔记不存在")
db.delete(note)
db.commit()
return {"ok": True}
```
**规则:**
- 查询:加 `.filter(Model.created_by == user_id)`
- 创建:`created_by=user_id, updated_by=user_id`
- 修改:先按 `id + created_by` 查找,再 `updated_by=user_id`
- 删除:先按 `id + created_by` 查找,再删除
- [ ] **Step 2: 改造 routes/websites.py**
同样模式,涉及 `Website`、`Account`、`Folder`(note 类型和 website 类型)三个模型。改造要点:
```python
from fastapi import Request
from middleware.user_context import UserContext
# 示例 — 获取网站列表
@router.get("/api/websites")
def list_websites(folder_id: int = None, db: Session = Depends(get_db), request: Request = None):
user_id = UserContext.get_user_id(request)
q = db.query(Website).options(joinedload(Website.accounts)).filter(Website.created_by == user_id)
if folder_id:
q = q.filter(Website.folder_id == folder_id)
return q.order_by(Website.name).all()
# 示例 — 创建网站
@router.post("/api/websites")
def create_website(data: dict, db: Session = Depends(get_db), request: Request = None):
user_id = UserContext.get_user_id(request)
website = Website(
name=data.get("name", ""),
url=data.get("url", ""),
folder_id=data.get("folder_id"),
created_by=user_id,
updated_by=user_id,
)
# ...
# Folder 同样需要过滤
@router.get("/api/folders")
def list_folders(type: str, db: Session = Depends(get_db), request: Request = None):
user_id = UserContext.get_user_id(request)
folders = db.query(Folder).filter(
Folder.type == type,
Folder.created_by == user_id,
).order_by(Folder.sort_order).all()
return folders
```
- [ ] **Step 3: 改造 routes/import_api.py**
```python
from middleware.user_context import UserContext
@router.post("/api/import/parse")
def parse_import(data: dict, db: Session = Depends(get_db), request: Request = None):
user_id = UserContext.get_user_id(request)
# ... 解析逻辑中创建数据时使用 user_id ...
website = Website(
name=...,
url=...,
created_by=user_id,
updated_by=user_id,
)
account = Account(
username=...,
password=...,
created_by=user_id,
updated_by=user_id,
)
```
- [ ] **Step 4: 改造 routes/share.py**
分享功能分两部分:**公开查看**(不鉴权)和**分享管理**(需鉴权)。
```python
from middleware.user_context import UserContext
# 公开查看 — 不走网关,直接后端渲染 HTML,不回 Vue SPA
# 用户访问: https://mokeetext.server.zgitm.com/share/{token}
# 无需 request 参数,保持原样
@router.get("/share/{token}")
def view_shared(token: str, db: Session = Depends(get_db)):
note = db.query(Note).filter_by(share_token=token).first()
if not note:
raise HTTPException(404, "链接无效或已取消分享")
if note.share_expires_at and note.share_expires_at < datetime.utcnow():
raise HTTPException(410, "分享链接已过期")
# ... 返回 HTMLResponse,保持原样
# 分享管理 — 走网关,需鉴权
@router.post("/api/notes/{note_id}/share")
def share_note(note_id: int, data: dict, db: Session = Depends(get_db), request: Request = None):
user_id = UserContext.get_user_id(request)
note = db.query(Note).filter(Note.id == note_id, Note.created_by == user_id).first()
if not note:
raise HTTPException(404, "笔记不存在")
# ... 生成 share_token
@router.delete("/api/notes/{note_id}/share")
def unshare_note(note_id: int, db: Session = Depends(get_db), request: Request = None):
user_id = UserContext.get_user_id(request)
note = db.query(Note).filter(Note.id == note_id, Note.created_by == user_id).first()
if not note:
raise HTTPException(404, "笔记不存在")
# ... 清除 share_token
@router.get("/api/shares")
def list_shares(db: Session = Depends(get_db), request: Request = None):
user_id = UserContext.get_user_id(request)
shares = db.query(Note).filter(
Note.share_token.isnot(None),
Note.created_by == user_id,
).all()
return shares
```
**关键**:`GET /share/{token}` 不走网关、不走 Vue SPA、不鉴权。分享链接格式为 `https://mokeetext.server.zgitm.com/share/{token}`(后端地址)。
- [ ] **Step 5: 改造 routes/upload.py**
```python
from middleware.user_context import UserContext
@router.post("/api/upload/image")
async def upload_image(file: UploadFile = File(...), db: Session = Depends(get_db), request: Request = None):
"""上传图片 — 网关已通过 Authorization 头鉴权,后端记录创建者"""
user_id = UserContext.get_user_id(request)
data = await file.read()
upload = Upload(
filename=file.filename or "img",
content_type=file.content_type,
data=data,
size=len(data),
created_by=user_id,
)
db.add(upload)
db.commit()
db.refresh(upload)
return {"url": f"/api/file/{upload.id}"}
@router.get("/api/file/{file_id}")
def get_file(file_id: int, db: Session = Depends(get_db)):
"""读取图片 — 网关已通过 Cookie 鉴权( 标签浏览器自动带 Cookie)
流程:浏览器加载
→ 浏览器自动发送 .zgitm.com 域的 Cookie
→ 网关从 Cookie 取 Token 验证(Authorization 头为空时 fallback 到 Cookie)
→ 验证通过,注入 X-User-* 请求头,转发到后端
→ 后端直接返回图片数据
"""
upload = db.query(Upload).filter_by(id=file_id).first()
if not upload:
raise HTTPException(404, "文件不存在")
return Response(content=upload.data, media_type=upload.content_type)
```
**设计说明:**
- `POST /api/upload/image`:JS fetch 发起,带上 `Authorization` 头 + `X-System-Code`,网关鉴权后注入 `X-User-Id`
- `GET /api/file/{id}`:`
` 标签浏览器发起,**无法带 Authorization 头**,但网关会从 URL 所在域(`gw.server.zgitm.com`)的 Cookie 中取 Token 验证。因为 Cookie domain 是 `.zgitm.com`,所有子域请求都会自动携带
- 图片读取由网关统一鉴权,后端不需要自己做用户隔离(到达后端的请求已经是合法的)
- [ ] **Step 6: 验证所有路由改造完成**
```bash
cd /e/AI/claude/new/mokeeText && python -c "
from app import app
# 检查所有路由的依赖注入是否包含 request: Request
for route in app.routes:
if hasattr(route, 'methods'):
print(f'{route.methods} {route.path}')
"
```
Expected: 列出所有 API 路由,确认没有遗漏。
---
## 阶段二:前端改造
### Task 4: 环境变量 + Vite 配置
**文件:**
- Create: `.env`
- Create: `.env.production`
- Modify: `vite.config.js`
- [ ] **Step 1: 创建 .env(开发环境)**
```bash
# .env — 本地开发环境变量
VITE_GATEWAY_URL = http://localhost:4006
VITE_LOGIN_URL = https://login.user.zgitm.com
VITE_SYSTEM_CODE = mokeetext-edit
```
开发时 Vite 代理到本地 FastAPI 后端,不走网关。
- [ ] **Step 2: 创建 .env.production(生产环境)**
```bash
# .env.production — 生产环境变量
VITE_GATEWAY_URL = https://gw.server.zgitm.com
VITE_LOGIN_URL = https://login.user.zgitm.com
VITE_SYSTEM_CODE = mokeetext-edit
```
生产环境前端请求走网关。
- [ ] **Step 3: 确认 vite.config.js 已正确处理 env 前缀**
当前 `vite.config.js` 不需要改动——Vite 默认将 `VITE_` 前缀的变量暴露给客户端代码。
---
### Task 5: 新建 Axios 实例 + 请求拦截器
**文件:**
- Create: `src/utils/request.js`
- [ ] **Step 1: 安装 axios**
```bash
cd /e/AI/claude/new/mokeeText && npm install axios
```
- [ ] **Step 2: 创建 Axios 实例**
```javascript
// src/utils/request.js
import axios from 'axios'
const SYSTEM_CODE = import.meta.env.VITE_SYSTEM_CODE
const GATEWAY_URL = import.meta.env.VITE_GATEWAY_URL
const LOGIN_URL = import.meta.env.VITE_LOGIN_URL
// 从 Cookie 读取 Token(登录后网关写入的跨域 Cookie)
function getToken() {
const match = document.cookie.match(/(?:^|;\s*)token=([^;]*)/)
return match ? decodeURIComponent(match[1]) : null
}
const request = axios.create({
baseURL: GATEWAY_URL,
timeout: 30000,
})
// 请求拦截器:携带 Token 和系统编码
request.interceptors.request.use(config => {
const token = getToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
// 关键:网关根据此头识别业务系统
config.headers['X-System-Code'] = SYSTEM_CODE
return config
})
// 响应拦截器:401 自动跳转登录
request.interceptors.response.use(
res => {
// 网关统一响应格式 { code: 200, data: ..., msg: ... }
if (res.data && res.data.code === 200) {
return res.data
}
return Promise.reject(res.data)
},
err => {
if (err.response?.status === 401) {
window.location.href = `${LOGIN_URL}?systemCode=${SYSTEM_CODE}`
}
return Promise.reject(err)
}
)
export { getToken }
export default request
```
- [ ] **Step 3: 验证 axios 实例可导入**
```bash
cd /e/AI/claude/new/mokeeText && node -e "require('./src/utils/request.js')" 2>&1 || echo "ESM — 需要在 Vite 环境中验证"
```
---
### Task 6: 改造 useApi.js — 从 fetch 切换到 axios
**文件:**
- Modify: `src/composables/useApi.js`
- [ ] **Step 1: 重写 useApi.js**
```javascript
// src/composables/useApi.js
//
// 开发模式:直连本地后端(localhost:4006),请求头注入 X-Dev-Mock-User
// 生产模式:走网关(gw.server.zgitm.com),请求头注入 Authorization + X-System-Code
const isDev = window.location.hostname === 'localhost'
// 本地开发:直连后端 + mock 用户
const DEV_BASE = 'http://localhost:4006'
async function devRequest(url, options = {}) {
const res = await fetch(DEV_BASE + url, {
headers: {
'Content-Type': 'application/json',
'X-Dev-Mock-User': 'admin', // 触发后端 DEV_MODE 默认用户
...options.headers,
},
...options,
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err.detail || res.statusText)
}
return res.json()
}
// 生产:走网关(通过 axios 实例)
import request, { getToken } from '../utils/request.js'
async function gwRequest(url, options = {}) {
const method = (options.method || 'GET').toLowerCase()
const config = { method, url, headers: options.headers || {} }
if (options.body) {
config.data = JSON.parse(options.body)
}
const res = await request(config)
return res.data !== undefined ? res.data : res
}
const doRequest = isDev ? devRequest : gwRequest
export function useApi() {
const api = {
// Notes
listNotes: (folderId) => doRequest(folderId ? `/api/notes?folder_id=${folderId}` : '/api/notes'),
getNote: (id) => doRequest(`/api/notes/${id}`),
createNote: (data) => doRequest('/api/notes', { method: 'POST', body: JSON.stringify(data) }),
updateNote: (id, data) => doRequest(`/api/notes/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteNote: (id) => doRequest(`/api/notes/${id}`, { method: 'DELETE' }),
shareNote: (id, ttl) => doRequest(`/api/notes/${id}/share`, { method: 'POST', body: JSON.stringify({ ttl }) }),
unshareNote: (id) => doRequest(`/api/notes/${id}/share`, { method: 'DELETE' }),
moveNote: (id, folderId) => doRequest(`/api/notes/${id}/move`, { method: 'PUT', body: JSON.stringify({ folder_id: folderId }) }),
// Websites
listWebsites: (folderId) => doRequest(folderId ? `/api/websites?folder_id=${folderId}` : '/api/websites'),
getWebsite: (id) => doRequest(`/api/websites/${id}`),
createWebsite: (data) => doRequest('/api/websites', { method: 'POST', body: JSON.stringify(data) }),
updateWebsite: (id, data) => doRequest(`/api/websites/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteWebsite: (id) => doRequest(`/api/websites/${id}`, { method: 'DELETE' }),
// Accounts
getAccount: (id) => doRequest(`/api/accounts/${id}`),
addAccount: (siteId, data) => doRequest(`/api/websites/${siteId}/accounts`, { method: 'POST', body: JSON.stringify(data) }),
updateAccount: (id, data) => doRequest(`/api/accounts/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteAccount: (id) => doRequest(`/api/accounts/${id}`, { method: 'DELETE' }),
moveAccount: (id, direction) => doRequest(`/api/accounts/${id}/move`, { method: 'PUT', body: JSON.stringify({ direction }) }),
// Folders
listFolders: (type) => doRequest(`/api/folders?type=${type}`),
createFolder: (data) => doRequest('/api/folders', { method: 'POST', body: JSON.stringify(data) }),
updateFolder: (id, data) => doRequest(`/api/folders/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteFolder: (id) => doRequest(`/api/folders/${id}`, { method: 'DELETE' }),
// Import
parseImport: (text) => doRequest('/api/import/parse', { method: 'POST', body: JSON.stringify({ text }) }),
}
return api
}
```
**设计说明:**
- 开发模式(`localhost`)直连 FastAPI 后端,通过 `X-Dev-Mock-User` 头触发后端的 DEV_DEFAULT_USER
- 生产模式走网关,通过 axios 实例带 Token 和 X-System-Code
- API 方法签名不变,上层 Vue 组件无需改动
- [ ] **Step 2: 同步改造 NoteEditor.vue 的图片上传(双地址方案)**
`NoteEditor.vue` 里有一份独立的 `API_BASE` 和 `fetch` 调用,不走 `useApi.js`,需要单独改。
**关键设计**:图片上传走网关(鉴权),图片读取走后端(公开)。因为分享链接是公开访问的,`
` 标签只能指向无需鉴权的地址。
```
上传 POST: https://gw.server.zgitm.com/api/upload/image ← 网关(鉴权)
读取 GET: https://mokeetext.server.zgitm.com/api/file/123 ← 后端直连(公开)
```
```javascript
// src/components/NoteEditor.vue — 改动点
// 现在(第 127 行)
const API_BASE = window.location.hostname === 'localhost' ? '' : 'https://mokeetext.server.zgitm.com'
// 接入后 — 拆成两个地址
const isDev = window.location.hostname === 'localhost'
const GW_BASE = isDev ? 'http://localhost:4006' : 'https://gw.server.zgitm.com'
// ↑ 上传走网关
const FILE_BASE = isDev ? 'http://localhost:4006' : 'https://mokeetext.server.zgitm.com'
// ↑ 图片读取走后端(公开,分享页也需要加载图片)
// uploadAndInsert 函数:
async function uploadAndInsert(file) {
const form = new FormData()
form.append('file', file)
// 上传通过网关(需要鉴权头)
const headers = {}
if (!isDev) {
const token = document.cookie.match(/(?:^|;\s*)token=([^;]*)/)?.[1]
if (token) headers['Authorization'] = `Bearer ${token}`
headers['X-System-Code'] = 'mokeetext-edit'
} else {
headers['X-Dev-Mock-User'] = 'admin'
}
const res = await fetch(GW_BASE + '/api/upload/image', { method: 'POST', body: form, headers })
const data = await res.json()
if (data.url) {
// 图片 URL 指向后端直连(公开,分享页 + 编辑器都能加载)
const src = data.url.startsWith('http') ? data.url : FILE_BASE + data.url
editor?.chain().focus().setImage({ src }).run()
}
}
```
**NoteEditor.vue 改动总结**:`API_BASE` 拆成 `GW_BASE` + `FILE_BASE`,上传请求加鉴权头,~20 行改动。
---
### Task 7: 路由守卫 — 未登录跳转登录页
**文件:**
- Modify: `src/router.js`
- [ ] **Step 1: 添加路由守卫**
```javascript
import { createRouter, createWebHistory } from 'vue-router'
import NotesView from './views/NotesView.vue'
import WebsitesView from './views/WebsitesView.vue'
import SharesView from './views/SharesView.vue'
const SYSTEM_CODE = import.meta.env.VITE_SYSTEM_CODE
const LOGIN_URL = import.meta.env.VITE_LOGIN_URL
const routes = [
{ path: '/', redirect: '/notes' },
{ path: '/notes', name: 'notes', component: NotesView },
{ path: '/websites', name: 'websites', component: WebsitesView },
{ path: '/shares', name: 'shares', component: SharesView },
]
const router = createRouter({
history: createWebHistory(),
routes,
})
// 路由守卫:无 Token 则跳转统一登录页
router.beforeEach((to, _from, next) => {
// 分享链接查看页不需要登录
if (to.path.startsWith('/share/')) {
next()
return
}
// 本地开发模式:跳过登录检查
if (window.location.hostname === 'localhost') {
next()
return
}
const token = document.cookie.match(/(?:^|;\s*)token=([^;]*)/)?.[1]
if (!token) {
window.location.href = `${LOGIN_URL}?systemCode=${SYSTEM_CODE}`
return
}
next()
})
export default router
```
- [ ] **Step 2: 为分享链接查看添加路由(如果尚未添加)**
检查 `router.js` 是否已有 `/share/:token` 路由。如果没有,后续 Task 9 在 App.vue 改造时会处理。
---
### Task 8: 新建 v-permission 指令
**文件:**
- Create: `src/directives/permission.js`
- [ ] **Step 1: 创建 permission 指令**
```javascript
// src/directives/permission.js
// 从 Cookie 中读取 Token,解析 JWT payload 中的 permissions
function getPermissions() {
const token = document.cookie.match(/(?:^|;\s*)token=([^;]*)/)?.[1]
if (!token) return []
try {
const payload = JSON.parse(atob(token.split('.')[1]))
return payload.permissions ?? []
} catch {
return []
}
}
export const permission = {
mounted(el, binding) {
const permissions = getPermissions()
const required = binding.value
// 没有该权限则从 DOM 中移除元素
if (required && !permissions.includes(required)) {
el.parentNode?.removeChild(el)
}
},
}
```
- [ ] **Step 2: 在 main.js 中全局注册指令**
修改 `src/main.js`:
```javascript
import { createApp } from 'vue'
import App from './App.vue'
import router from './router.js'
import { permission } from './directives/permission.js'
import './assets/main.css'
const app = createApp(App)
app.use(router)
app.directive('permission', permission)
app.mount('#app')
```
---
### Task 9: 替换用户头像 — 接入 UserChip Widget + 更新路由守卫
**文件:**
- Modify: `src/App.vue`
- Modify: `index.html`
- Modify: `src/main.js`
- [ ] **Step 1: 修改 index.html — 添加 UserChip CSS + process polyfill**
```html