Переглянути джерело

docs: add implementation plan for web-admin WeChat QR login.

Co-authored-by: Cursor <cursoragent@cursor.com>
wuzilong 1 місяць тому
батько
коміт
b438495b1d

+ 911 - 0
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: 模板**
+
+在现有 `<el-form class="login-form">` 内、`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
+<p class="login-subtitle">
+  {{ loginTab === 'wechat' ? '微信扫一扫,授权手机号后自动登录' : '请输入账号密码登录系统' }}
+</p>
+```
+
+登录按钮按规范带 `<el-icon><Check /></el-icon>`。
+
+- [ ] **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`。