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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wuzilongandCursor committed 2026-09-18 18:13:31 +08:00
1 parent ef8b59d23c
commit b438495b1d
1 file changed
+911
@@ -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`。