# 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 MoKeeText
``` - [ ] **Step 2: 修改 App.vue — 替换硬编码 Admin 为 UserChip 占位** 找到 `App.vue` 中 `
` 内的: ```html
M Admin
``` 替换为: ```html
``` 同时更新 `.user-widget-h` 样式为 UserChip 样式: ```css #mokee-user-chip { flex-shrink: 0; } ``` - [ ] **Step 3: 修改 main.js — 在 mount 后加载 UserChip 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') // 生产环境:动态加载 UserChip Widget(必须在 mount 之后,因为需要 DOM 中已有 #mokee-user-chip) // 本地开发:不加载 Widget,使用硬编码的 mock 头像 if (window.location.hostname !== 'localhost') { window.process = { env: {} } // polyfill:防止 "process is not defined" const widgetScript = document.createElement('script') widgetScript.src = 'https://web.gw.zgitm.com/widgets/user-chip.js' document.body.appendChild(widgetScript) } ``` - [ ] **Step 4: 本地开发保留 mock 头像** 在 App.vue 的 topbar 中,UserChip 占位旁边加一个开发环境 fallback: ```html
A Admin(本地)
``` 在 `