From b438495b1d92845cd77ac7e114ba52cabaa46aaf Mon Sep 17 00:00:00 2001 From: wuzilong <704541015@qq.com> Date: Fri, 18 Sep 2026 18:13:31 +0800 Subject: [PATCH] docs: add implementation plan for web-admin WeChat QR login. Co-authored-by: Cursor --- .../2026-09-18-web-admin-wx-qr-phone-login.md | 911 ++++++++++++++++++ 1 file changed, 911 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-18-web-admin-wx-qr-phone-login.md diff --git a/docs/superpowers/plans/2026-09-18-web-admin-wx-qr-phone-login.md b/docs/superpowers/plans/2026-09-18-web-admin-wx-qr-phone-login.md new file mode 100644 index 000000000..d9b4ef994 --- /dev/null +++ b/docs/superpowers/plans/2026-09-18-web-admin-wx-qr-phone-login.md @@ -0,0 +1,911 @@ +# web-admin 微信扫码登录(手机号匹配)Implementation Plan + +> **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:** 山峡 web-admin 登录页默认微信扫码(小程序码 + 手机号匹配 `sys_user.phone_no`),并可切换账号密码登录。 + +**Architecture:** 后端 Redis(无 Redis 则进程内存)保存短时扫码会话;`create` 用 `WECHAT_MP_*` 生成小程序码;手机打开 `miniprogram/pages/login/web-qr` 授权后 `confirm` 复用 `resolve_wechat_login_user` + 同步的 `issue_web_login_tokens`;web-admin 轮询 `status` 一次性取走 JWT。UI 保留山峡全屏背景 + 右侧毛玻璃卡,卡内 Tab。 + +**Tech Stack:** FastAPI、Redis、微信 `getwxacodeunlimit`、Vue 3(web-admin)、uni-app(miniprogram) + +**Spec:** `docs/superpowers/specs/2026-09-18-web-admin-wx-qr-phone-login-design.md` + +--- + +## File map + +| 路径 | 职责 | +|------|------| +| `backend/app/core/config.py` | `WX_QR_*`;`API_ENCRYPT_EXCLUDE_PATHS` 追加 | +| `backend/.env.example` | 文档化 `WX_QR_*` | +| `backend/app/services/wx_qr_session.py` | 会话状态机、短码映射、一次性消费 | +| `backend/app/services/wechat_mini_auth.py` | 新增 `get_unlimited_wxacode` | +| `backend/app/api/public.py` | `POST/GET /web/auth/wx-qr/create\|status` | +| `backend/app/api/mini_auth.py` | `web-qr-scanned` / `web-qr-confirm` | +| `backend/tests/test_wx_qr_session.py` | 会话单测 | +| `backend/tests/test_wxacode.py` | 小程序码 mock 单测 | +| `backend/tests/test_wx_qr_api.py` | create/status/confirm 接口测 | +| `web-admin/src/api/auth.ts` | `fetchWxQrCreate` / `fetchWxQrStatus` | +| `web-admin/.env` / `.env.development` / `.env.production` | 前端加密排除路径 | +| `web-admin/src/views/auth/login/index.vue` | Tab + 扫码轮询;密码逻辑保留 | +| `miniprogram/pages/login/web-qr/index.vue` | 授权落地页 | +| `miniprogram/pages.json` | 注册新页 | +| `miniprogram/config/api.js` | `WEB_QR_SCANNED` / `WEB_QR_CONFIRM` | +| `miniprogram/stores/user.js` | `webQrScanned` / `webQrConfirm`(不写本地 JWT) | +| `miniprogram/utils/request.js` | 加密排除路径追加 | + +**不修改:** 支付 `WXPAY_*`、开放平台网站应用、自动注册、密码登录签发结构。 + +**山峡差异(相对 zkxw):** 发票函数是同步 `issue_web_login_tokens`,不是 `await issue_tokens_for_user`。凭证用 `WECHAT_MP_APPID` / `WECHAT_MP_SECRET`。确认页在 `miniprogram/`。 + +--- + +### Task 1: 配置项与加密排除路径 + +**Files:** +- Modify: `backend/app/core/config.py` +- Modify: `backend/.env.example` + +- [ ] **Step 1: 在 `Settings` 中 `WECHAT_MP_SECRET` 之后增加** + +```python + # web-admin 微信扫码登录(小程序码) + WX_QR_SESSION_TTL_SEC: int = 180 + WX_QR_ENV_VERSION: str = "release" # release | trial | develop + WX_QR_PAGE: str = "pages/login/web-qr/index" +``` + +- [ ] **Step 2: 在 `API_ENCRYPT_EXCLUDE_PATHS` 的 `/api/mini/auth/wechat-login,` 之后追加** + +```text +"/api/web/auth/wx-qr/create," +"/api/web/auth/wx-qr/status," +"/api/mini/auth/web-qr-scanned," +"/api/mini/auth/web-qr-confirm," +``` + +- [ ] **Step 3: 在 `backend/.env.example` 的 `WECHAT_MP_SECRET=` 注释块后追加** + +```text +# web-admin 扫码登录(小程序码 page 须已发布到对应 env_version) +# WX_QR_SESSION_TTL_SEC=180 +# WX_QR_ENV_VERSION=release +# WX_QR_PAGE=pages/login/web-qr/index +``` + +- [ ] **Step 4: Commit** + +```bash +git add backend/app/core/config.py backend/.env.example +git commit -m "$(cat <<'EOF' +chore: add wx-qr login config and encrypt exclude paths. + +EOF +)" +``` + +--- + +### Task 2: 扫码会话服务(TDD) + +**Files:** +- Create: `backend/tests/test_wx_qr_session.py` +- Create: `backend/app/services/wx_qr_session.py` + +- [ ] **Step 1: 写失败单测 `backend/tests/test_wx_qr_session.py`** + +```python +"""wx_qr_session 状态机与一次性消费。""" +import time + +from app.services import wx_qr_session as s + + +def setup_function(): + s._memory_clear_for_tests() + + +def test_create_pending_and_get(): + sid, short = s.create_session(ttl_sec=60) + assert sid and short and len(short) <= 32 + data = s.get_session(sid) + assert data["status"] == "pending" + assert s.resolve_session_id(short) == sid + + +def test_mark_scanned(): + sid, _ = s.create_session(ttl_sec=60) + assert s.mark_scanned(sid) is True + assert s.get_session(sid)["status"] == "scanned" + + +def test_mark_success_consume_once(): + sid, _ = s.create_session(ttl_sec=60) + payload = {"token": "t", "refreshToken": "r", "expiresIn": 1, "userInfo": {}} + assert s.mark_success(sid, payload) is True + first = s.consume_success(sid) + assert first["token"] == "t" + assert s.consume_success(sid) is None + assert s.get_session(sid)["status"] == "consumed" + + +def test_mark_failed(): + sid, _ = s.create_session(ttl_sec=60) + assert s.mark_failed(sid, "未找到员工") is True + assert s.get_session(sid)["status"] == "failed" + assert s.get_session(sid)["message"] == "未找到员工" + + +def test_expire_old_session(): + sid, _ = s.create_session(ttl_sec=1) + time.sleep(1.1) + data = s.get_session(sid) + assert data is None or data["status"] == "expired" + + +def test_invalidate(): + sid, _ = s.create_session(ttl_sec=60) + s.invalidate(sid) + data = s.get_session(sid) + assert data is None or data["status"] == "expired" +``` + +- [ ] **Step 2: 跑测确认失败** + +Run: `cd backend && source venv/bin/activate && pytest tests/test_wx_qr_session.py -v` +Expected: FAIL(`No module named app.services.wx_qr_session` 或 import error) + +- [ ] **Step 3: 实现 `backend/app/services/wx_qr_session.py`** + +从 zkxw `backend/app/services/wx_qr_session.py` **原样复制**(已核对与山峡 `settings.REDIS_*` 字段兼容)。公开函数签名固定为: + +- `create_session(*, ttl_sec=None) -> tuple` 返回 `(session_id, short)` +- `resolve_session_id(short_or_id) -> Optional[str]` +- `get_session(session_id) -> Optional[dict]`(过期时 `status=expired`) +- `mark_scanned` / `mark_success` / `mark_failed` / `consume_success` / `invalidate` +- `_memory_clear_for_tests()` + +Redis key:`wxqr:sess:{id}`、`wxqr:short:{short}`。无 `REDIS_HOST` 时走进程内存。 + +- [ ] **Step 4: 跑测确认通过** + +Run: `cd backend && source venv/bin/activate && pytest tests/test_wx_qr_session.py -v` +Expected: PASS(6 tests) + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/services/wx_qr_session.py backend/tests/test_wx_qr_session.py +git commit -m "$(cat <<'EOF' +feat(auth): add wx-qr session state machine for web login. + +EOF +)" +``` + +--- + +### Task 3: 小程序码生成(TDD) + +**Files:** +- Modify: `backend/app/services/wechat_mini_auth.py` +- Create: `backend/tests/test_wxacode.py` + +- [ ] **Step 1: 写失败单测 `backend/tests/test_wxacode.py`** + +```python +from unittest.mock import MagicMock, patch + +from app.services.wechat_mini_auth import WechatMiniAuthError, get_unlimited_wxacode + + +def test_get_unlimited_wxacode_returns_png_bytes(): + token_resp = MagicMock() + token_resp.raise_for_status = MagicMock() + token_resp.json.return_value = {"access_token": "tok"} + + code_resp = MagicMock() + code_resp.raise_for_status = MagicMock() + code_resp.headers = {"Content-Type": "image/jpeg"} + code_resp.content = b"\xff\xd8fake" + + with patch( + "app.services.wechat_mini_auth._require_mini_credentials", + return_value=("app", "sec"), + ): + with patch("app.services.wechat_mini_auth.httpx.Client") as Client: + client = MagicMock() + Client.return_value.__enter__.return_value = client + client.get.return_value = token_resp + client.post.return_value = code_resp + raw = get_unlimited_wxacode(scene="abc", page="pages/login/web-qr/index") + assert raw.startswith(b"\xff\xd8") + + +def test_get_unlimited_wxacode_json_error(): + token_resp = MagicMock() + token_resp.raise_for_status = MagicMock() + token_resp.json.return_value = {"access_token": "tok"} + + code_resp = MagicMock() + code_resp.raise_for_status = MagicMock() + code_resp.headers = {"Content-Type": "application/json"} + code_resp.content = b'{"errcode":40001,"errmsg":"fail"}' + code_resp.json.return_value = {"errcode": 40001, "errmsg": "fail"} + + with patch( + "app.services.wechat_mini_auth._require_mini_credentials", + return_value=("app", "sec"), + ): + with patch("app.services.wechat_mini_auth.httpx.Client") as Client: + client = MagicMock() + Client.return_value.__enter__.return_value = client + client.get.return_value = token_resp + client.post.return_value = code_resp + try: + get_unlimited_wxacode(scene="abc", page="pages/login/web-qr/index") + assert False, "expected error" + except WechatMiniAuthError: + pass +``` + +- [ ] **Step 2: 跑测确认失败** + +Run: `cd backend && source venv/bin/activate && pytest tests/test_wxacode.py -v` +Expected: FAIL(`get_unlimited_wxacode` 不存在) + +- [ ] **Step 3: 在 `wechat_mini_auth.py` 文件末尾追加** + +```python +_WX_WXACODE_UNLIMITED = "https://api.weixin.qq.com/wxa/getwxacodeunlimit" + + +def get_unlimited_wxacode( + *, + scene: str, + page: str, + env_version: Optional[str] = None, +) -> bytes: + """获取不限制小程序码 PNG/JPEG 字节;失败抛 WechatMiniAuthError。""" + sc = (scene or "").strip() + if not sc or len(sc) > 32: + raise WechatMiniAuthError("scene 无效或超过 32 字符") + pg = (page or "").strip() + if not pg: + raise WechatMiniAuthError("page 不能为空") + ver = (env_version or getattr(settings, "WX_QR_ENV_VERSION", None) or "release").strip() + if ver not in ("release", "trial", "develop"): + ver = "release" + token = _get_access_token() + with httpx.Client(timeout=20.0) as client: + r = client.post( + f"{_WX_WXACODE_UNLIMITED}?access_token={token}", + json={ + "scene": sc, + "page": pg, + "check_path": False, + "env_version": ver, + "width": 280, + }, + ) + r.raise_for_status() + ctype = (r.headers.get("Content-Type") or "").lower() + if "application/json" in ctype: + data = r.json() + logger.warning("getwxacodeunlimit failed: %s", data) + raise WechatMiniAuthError( + "生成小程序码失败", + wx_errcode=data.get("errcode"), + ) + if not r.content: + raise WechatMiniAuthError("生成小程序码失败:空响应") + return r.content +``` + +`Optional` 已在该文件 import。`_get_access_token` 已使用 `WECHAT_MP_*`,不要改成 `WXPAY_*`。 + +- [ ] **Step 4: 跑测确认通过** + +Run: `cd backend && source venv/bin/activate && pytest tests/test_wxacode.py -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/services/wechat_mini_auth.py backend/tests/test_wxacode.py +git commit -m "$(cat <<'EOF' +feat(wechat): generate unlimited mini program codes for web QR login. + +EOF +)" +``` + +--- + +### Task 4: Web 公开端点 create / status + +**Files:** +- Modify: `backend/app/api/public.py` +- Create: `backend/tests/test_wx_qr_api.py` + +- [ ] **Step 1: 在 `public.py` 顶部 import 增加** + +```python +import base64 + +from app.services import wx_qr_session +from app.services.wechat_mini_auth import WechatMiniAuthError, get_unlimited_wxacode +``` + +- [ ] **Step 2: 在现有 `web_login` 路由之后插入** + +```python +@router.post("/web/auth/wx-qr/create", response_model=dict) +async def wx_qr_create(): + session_id, short = wx_qr_session.create_session() + page = (settings.WX_QR_PAGE or "pages/login/web-qr/index").strip() + try: + raw = get_unlimited_wxacode( + scene=short, + page=page, + env_version=settings.WX_QR_ENV_VERSION, + ) + except WechatMiniAuthError as exc: + wx_qr_session.invalidate(session_id) + return error_response(400, str(exc)) + b64 = base64.b64encode(raw).decode("ascii") + mime = "image/jpeg" if raw[:2] == b"\xff\xd8" else "image/png" + return success_response( + { + "sessionId": session_id, + "expiresIn": settings.WX_QR_SESSION_TTL_SEC, + "qrImageBase64": f"data:{mime};base64,{b64}", + } + ) + + +@router.get("/web/auth/wx-qr/status", response_model=dict) +async def wx_qr_status(sessionId: str = ""): + sid = (sessionId or "").strip() + data = wx_qr_session.get_session(sid) + if not data: + return success_response({"status": "expired", "message": "二维码已过期,请刷新"}) + status = data.get("status") or "expired" + if status == "success": + tokens = wx_qr_session.consume_success(sid) + if not tokens: + return success_response({"status": "expired", "message": "登录凭证已失效,请刷新二维码"}) + return success_response({"status": "success", **tokens}) + if status == "failed": + return success_response({"status": "failed", "message": data.get("message") or "登录失败"}) + if status in ("expired", "consumed"): + return success_response({"status": "expired", "message": "二维码已过期,请刷新"}) + return success_response({"status": status}) +``` + +- [ ] **Step 3: 写 `backend/tests/test_wx_qr_api.py` 的 create 用例** + +```python +"""web wx-qr create/status API。""" +from unittest.mock import patch + +from fastapi.testclient import TestClient + +from app.main import app +from app.services import wx_qr_session + +client = TestClient(app) + + +def setup_function(): + wx_qr_session._memory_clear_for_tests() + + +def test_wx_qr_create_and_poll_pending(): + with patch("app.services.wx_qr_session._redis", return_value=None), patch( + "app.api.public.get_unlimited_wxacode", + return_value=b"\x89PNG\r\n\x1a\n", + ): + r = client.post("/api/web/auth/wx-qr/create") + assert r.status_code == 200 + body = r.json() + assert body.get("code") == 200 + data = body["data"] + sid = data["sessionId"] + assert data["qrImageBase64"].startswith("data:image/") + st = client.get("/api/web/auth/wx-qr/status", params={"sessionId": sid}) + assert st.status_code == 200 + assert st.json()["data"]["status"] == "pending" +``` + +- [ ] **Step 4: 跑测** + +Run: `cd backend && source venv/bin/activate && pytest tests/test_wx_qr_api.py::test_wx_qr_create_and_poll_pending -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/api/public.py backend/tests/test_wx_qr_api.py +git commit -m "$(cat <<'EOF' +feat(api): add web wx-qr create and status endpoints. + +EOF +)" +``` + +--- + +### Task 5: 小程序 scanned / confirm + +**Files:** +- Modify: `backend/app/api/mini_auth.py` +- Modify: `backend/tests/test_wx_qr_api.py` + +- [ ] **Step 1: 在 `mini_auth.py` 增加 import** + +```python +from app.services import wx_qr_session +``` + +`code2session`、`get_phone_number`、`resolve_wechat_login_user`、`issue_web_login_tokens`、`datetime` 已存在。 + +- [ ] **Step 2: 在文件末尾追加两个路由** + +注意:`issue_web_login_tokens` 是**同步**函数,不要 `await`。 + +```python +@router.post("/web-qr-scanned", response_model=dict) +def web_qr_scanned(body: dict): + raw = (body.get("sessionId") or body.get("scene") or "").strip() + sid = wx_qr_session.resolve_session_id(raw) or raw + data = wx_qr_session.get_session(sid) + if not data or data.get("status") in ("expired", "consumed"): + return error_response(400, "二维码已过期,请在电脑上刷新后重扫") + wx_qr_session.mark_scanned(sid) + return success_response({"ok": True}) + + +@router.post("/web-qr-confirm", response_model=dict) +def web_qr_confirm(body: dict, db: Session = Depends(get_db)): + raw = (body.get("sessionId") or body.get("scene") or "").strip() + sid = wx_qr_session.resolve_session_id(raw) or raw + data = wx_qr_session.get_session(sid) + if not data or data.get("status") in ("expired", "consumed", "failed"): + return error_response(400, "二维码已过期,请在电脑上刷新后重扫") + if data.get("status") == "success": + return success_response({"ok": True, "message": "电脑端即将登录"}) + + wx_qr_session.mark_scanned(sid) + + login_code = (body.get("loginCode") or "").strip() + phone_code = (body.get("phoneCode") or "").strip() + if not login_code or not phone_code: + wx_qr_session.mark_failed(sid, "需要授权手机号才能完成扫码登录") + return error_response(400, "需要授权手机号才能完成扫码登录") + + try: + sess = code2session(login_code) + phone = get_phone_number(phone_code) + except WechatMiniAuthError as exc: + wx_qr_session.mark_failed(sid, str(exc)) + return error_response(400, str(exc)) + + user, err_msg, err_code = resolve_wechat_login_user( + db, openid=sess["openid"], phone=phone + ) + if err_msg: + wx_qr_session.mark_failed(sid, err_msg) + return error_response(err_code or 400, err_msg) + + unionid = (sess.get("unionid") or "").strip() + if unionid and not (getattr(user, "wx_unionid", None) or "").strip(): + user.wx_unionid = unionid + user.update_time = datetime.now() + db.add(user) + db.commit() + db.refresh(user) + + try: + tokens = issue_web_login_tokens(db, user) + except ValueError as exc: + wx_qr_session.mark_failed(sid, str(exc)) + return error_response(403, str(exc)) + + if not wx_qr_session.mark_success(sid, tokens): + return error_response(400, "二维码已过期,请在电脑上刷新后重扫") + return success_response({"ok": True, "message": "电脑端即将登录,可关闭本页"}) +``` + +- [ ] **Step 3: 在 `test_wx_qr_api.py` 追加 confirm 用例** + +patch 目标是 `issue_web_login_tokens`(同步 `MagicMock`),不是 `issue_tokens_for_user`。 + +```python +from types import SimpleNamespace +from unittest.mock import patch + + +def test_web_qr_confirm_success_sets_session(): + fake_user = SimpleNamespace( + id="u1", phone_no="13800138000", wx_openid=None, wx_unionid=None, status="1" + ) + tokens = { + "token": "access", + "refreshToken": "refresh", + "expiresIn": 3600, + "userInfo": {"id": "u1"}, + } + with patch("app.services.wx_qr_session._redis", return_value=None): + wx_qr_session._memory_clear_for_tests() + sid, _short = wx_qr_session.create_session(ttl_sec=60) + with patch( + "app.api.mini_auth.code2session", + return_value={"openid": "oid1", "unionid": ""}, + ), patch( + "app.api.mini_auth.get_phone_number", return_value="13800138000" + ), patch( + "app.api.mini_auth.resolve_wechat_login_user", + return_value=(fake_user, None, None), + ), patch( + "app.api.mini_auth.issue_web_login_tokens", return_value=tokens + ): + r = client.post( + "/api/mini/auth/web-qr-confirm", + json={"sessionId": sid, "loginCode": "lc", "phoneCode": "pc"}, + ) + assert r.status_code == 200 + body = r.json() + assert body.get("code") == 200 + assert body["data"].get("ok") is True + assert "token" not in (body.get("data") or {}) + consumed = wx_qr_session.consume_success(sid) + assert consumed and consumed["token"] == "access" +``` + +- [ ] **Step 4: 跑测** + +Run: `cd backend && source venv/bin/activate && pytest tests/test_wx_qr_api.py tests/test_wx_qr_session.py tests/test_wxacode.py -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/api/mini_auth.py backend/tests/test_wx_qr_api.py +git commit -m "$(cat <<'EOF' +feat(api): confirm web QR login from mini program phone auth. + +EOF +)" +``` + +--- + +### Task 6: web-admin API 与加密排除 + +**Files:** +- Modify: `web-admin/src/api/auth.ts` +- Modify: `web-admin/.env`、`web-admin/.env.development`、`web-admin/.env.production` + +- [ ] **Step 1: 在 `fetchLogin` 函数之后插入** + +```typescript +export function fetchWxQrCreate() { + return request.post<{ + sessionId: string + expiresIn: number + qrImageBase64: string + }>({ + url: '/web/auth/wx-qr/create', + data: {}, + headers: { 'X-Api-Crypto': 'off' }, + showErrorMessage: true + }) +} + +export function fetchWxQrStatus(sessionId: string) { + return request.get<{ + status: string + message?: string + token?: string + refreshToken?: string + expiresIn?: number + userInfo?: { + id: string + username: string + realName: string + phone: string + role: string + roles?: string[] + regionId: string + regionName: string + avatarUrl?: string | null + } + }>({ + url: '/web/auth/wx-qr/status', + params: { sessionId }, + headers: { 'X-Api-Crypto': 'off' }, + showErrorMessage: false + }) +} +``` + +`userInfo.roles` 对齐山峡现有 `fetchLogin`(不是 zkxw 的 `roleNames`)。 + +- [ ] **Step 2: 三份 env 的 `VITE_API_ENCRYPT_EXCLUDE_PATHS` 追加** + +`,/web/auth/wx-qr/create,/web/auth/wx-qr/status` + +- [ ] **Step 3: Commit** + +```bash +git add web-admin/src/api/auth.ts web-admin/.env web-admin/.env.development web-admin/.env.production +git commit -m "$(cat <<'EOF' +feat(web-admin): add wx-qr create and status API clients. + +EOF +)" +``` + +--- + +### Task 7: web-admin 登录页 Tab + 轮询 + +**Files:** +- Modify: `web-admin/src/views/auth/login/index.vue` + +**约束:** 保留现有 `.login` 全屏背景、`.login-form` 毛玻璃卡、页脚版权。不要改成 zkxw 左右分栏。参考 Demo:`docs/demos/web-admin-login-wx-qr-demo.html`。 + +- [ ] **Step 1: 模板** + +在现有 `` 内、`title-box` 之后,用 `el-tabs` 包住两块: + +- `name="wechat"` label `微信扫码`:`.qr-frame`(200×200)展示 `qrImage`;`.qr-status`;刷新按钮(`Refresh` 图标 + 「刷新二维码」) +- `name="password"` label `账号密码`:把现有用户名/密码/`登录` 按钮原样移入此 pane + +`v-model="loginTab"` 默认 `'wechat'`。副标题放在 `title-box` 下: + +```vue + +``` + +登录按钮按规范带 ``。 + +- [ ] **Step 2: script** + +从 `@/api/auth` 增加 `fetchWxQrCreate, fetchWxQrStatus`。图标增加 `Refresh, Check`。 + +状态机与轮询逻辑与 zkxw `login/index.vue` 一致: + +- `loginTab` 默认 `'wechat'` +- `qrStatus`: `creating | pending | scanned | failed | expired | success` +- `qrStatusText` 文案与 spec §5.1 一致 +- `refreshQr` → `fetchWxQrCreate` → `startPoll`(`setInterval` 1500ms) +- `pollStatus`:`success` 且带 `token` 时 `stopPoll` 后走与密码登录相同的 `setToken` / `setUserInfo` / `ElNotification` / `resetRouteInitState` / `resolveSafeRedirectTarget`(抽成 `finishLogin`,密码提交也调用) +- `handleLoginTabChange`:切到 password 停轮询;切回 wechat 时失败/过期/无 session 则 `refreshQr`,否则恢复轮询 +- `onMounted`:`preloadBackgroundImage()` + `refreshQr()` +- `onUnmounted`:`stopPoll()` + +- [ ] **Step 3: scoped 样式** + +在现有 `.login-form` 内追加(主色用 `var(--el-color-primary)`,不要写 `#0a9f6d`): + +```scss +.login-subtitle { + margin: -18px 0 16px; + text-align: center; + font-size: 13px; + color: #64748b; +} + +.qr-login { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding-bottom: 8px; +} + +.qr-frame { + width: 200px; + height: 200px; + border: 1px solid #e5e7eb; + border-radius: 10px; + background: #fff; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.qr-image { + width: 100%; + height: 100%; + object-fit: contain; +} + +.qr-status { + margin: 0; + font-size: 13px; + color: #64748b; + text-align: center; + + &.error { + color: #dc2626; + } +} +``` + +`:deep(.el-tabs__item.is-active)` 颜色跟现有主色即可,不必硬编码色值。 + +- [ ] **Step 4: 手工核对** + +启动 web-admin 后打开 `/#/auth/login`:默认扫码 Tab;可切到密码并提交(密码回归);离开页不再轮询(Network 无持续 status)。无微信凭证时刷新按钮应显示失败文案而不是白屏。 + +- [ ] **Step 5: Commit** + +```bash +git add web-admin/src/views/auth/login/index.vue +git commit -m "$(cat <<'EOF' +feat(web-admin): default login to WeChat QR with password tab. + +EOF +)" +``` + +--- + +### Task 8: 小程序授权落地页 + +**Files:** +- Create: `miniprogram/pages/login/web-qr/index.vue` +- Modify: `miniprogram/pages.json` +- Modify: `miniprogram/config/api.js` +- Modify: `miniprogram/stores/user.js` +- Modify: `miniprogram/utils/request.js` + +- [ ] **Step 1: `AUTH_API` 增加** + +```javascript + WEB_QR_SCANNED: '/mini/auth/web-qr-scanned', + WEB_QR_CONFIRM: '/mini/auth/web-qr-confirm' +``` + +- [ ] **Step 2: `request.js` 的 `API_ENCRYPT_EXCLUDE_PATHS` 追加** + +`,/mini/auth/web-qr-scanned,/mini/auth/web-qr-confirm` + +- [ ] **Step 3: `stores/user.js` 增加两个方法并 export** + +`webQrScanned` / `webQrConfirm` **不得**调用 `setLoginInfo`。 + +```javascript + async function webQrScanned(sessionId) { + const res = await request({ + url: AUTH_API.WEB_QR_SCANNED, + method: 'POST', + data: { sessionId } + }) + const result = parseResponseJson(res) + return !!(result && result.code === 200) + } + + async function webQrConfirm(params) { + const res = await request({ + url: AUTH_API.WEB_QR_CONFIRM, + method: 'POST', + data: { + sessionId: params.sessionId, + loginCode: params.loginCode, + phoneCode: params.phoneCode + } + }) + const result = parseResponseJson(res) + if (result && result.code === 200) { + return { ok: true, message: (result.data && result.data.message) || '电脑端即将登录,可关闭本页' } + } + return { ok: false, message: (result && result.message) || '授权失败' } + } +``` + +在 return 对象中加入 `webQrScanned, webQrConfirm`。 + +- [ ] **Step 4: `pages.json` 在 `pages/login/privacy-policy` 之后插入** + +```json + { + "path": "pages/login/web-qr/index", + "style": { + "navigationBarTitleText": "网页登录授权", + "navigationBarBackgroundColor": "#f3f7f5", + "navigationBarTextStyle": "black" + } + }, +``` + +- [ ] **Step 5: 新建 `miniprogram/pages/login/web-qr/index.vue`** + +行为: + +1. `onLoad`:从 `options.scene`(需 `decodeURIComponent`)或 `options.sessionId` 取码;无码则展示「请从电脑登录页重新扫码」。 +2. 有码则调 `userStore.webQrScanned(sessionId)`。 +3. 协议勾选复用登录页模式(用户协议 / 隐私政策已有页:`/pages/login/user-agreement`、`/pages/login/privacy-policy`)。 +4. 按钮 `open-type="getPhoneNumber"`;回调里 `uni.login` 取 `loginCode`,再 `webQrConfirm`。 +5. 成功:文案「电脑端即将登录,可关闭本页」。不 `setLoginInfo`、不 `reLaunch` 工作台。 +6. 失败:展示返回的 `message`。 +7. 主按钮色用三墙绿 `#0a9f6d`,不要用 `#10b981`。 + +页面结构可精简自 zkxw `mobile-h5/pages/login/web-qr/index.vue`(去掉 zkxw 专有 `ConfirmDialog` 组件名时,用本仓库登录页已有的协议交互,避免引入不存在的组件)。 + +- [ ] **Step 6: Commit** + +```bash +git add miniprogram/pages/login/web-qr/index.vue miniprogram/pages.json miniprogram/config/api.js miniprogram/stores/user.js miniprogram/utils/request.js +git commit -m "$(cat <<'EOF' +feat(miniprogram): add web QR phone confirm page for admin login. + +EOF +)" +``` + +--- + +### Task 9: 图谱与手工验收 + +**Files:** 无新业务文件(图谱脚本) + +- [ ] **Step 1: 更新图谱** + +```bash +./scripts/update-graph.sh all +``` + +若脚本失败,分别对 `backend` 与 `web-admin` 执行项目约定的 graphify 更新,并说明 miniprogram 是否在脚本覆盖范围内。 + +- [ ] **Step 2: 手工验收(对照 spec §7)** + +1. 登录页默认扫码 Tab;切到账号密码仍可登录。 +2. 配置 `WECHAT_MP_APPID` / `WECHAT_MP_SECRET` 与 `WX_QR_ENV_VERSION`(开发用 `develop` 或 `trial`)后,create 返回真实小程序码。 +3. 真机扫码 → 授权手机号 → PC 数秒内进入系统;手机不进工作台。 +4. 拒绝授权、过期、刷新二维码:两端文案正确;旧 session 不再成功。 +5. 离开登录页后 status 轮询停止。 + +未配置微信凭证时,只验证失败提示与密码 Tab,并在交付说明中写明未做真机扫码。 + +- [ ] **Step 3: 若图谱有变更则单独提交** + +```bash +git add backend/graphify-out web-admin/graphify-out miniprogram/graphify-out +git commit -m "$(cat <<'EOF' +chore: update graphify after wx-qr web login. + +EOF +)" +``` + +仅当这些目录确有 diff 时提交;没有 diff 则跳过。 + +--- + +## Spec coverage + +| Spec | Task | +|------|------| +| §2 主流程 | Task 4、5、7、8 | +| §3 状态机 / 一次性消费 / 短码 | Task 2、4、5 | +| §4 API | Task 4、5 | +| §4.5 复用 `resolve_wechat_login_user` + `issue_web_login_tokens` + `WECHAT_MP_*` | Task 3、5 | +| §5.1 山峡壳 + Tab + 轮询 | Task 6、7 | +| §5.2 小程序确认页 | Task 8 | +| §5.3 失败文案 | Task 5(后端 message)+ Task 7/8 展示 | +| §6 配置与加密排除 | Task 1、6、8 | +| §7 验收 | Task 9 | +| 无新业务表 | 不建表 | + +## Self-review notes + +- 发票函数统一为同步 `issue_web_login_tokens`,测试不使用 zkxw 的 `AsyncMock` + `issue_tokens_for_user`。 +- 前端 `userInfo` 字段用山峡的 `roles`,不用 `roleNames`。 +- 确认页目录是 `miniprogram/pages/login/web-qr/index.vue`,不是 `mobile-h5`。