Просмотр исходного кода

docs: describe how to add more miniprogram webview modules.

Co-authored-by: Cursor <cursoragent@cursor.com>
wuzilong 2 месяцев назад
Родитель
Сommit
fe9d30105d

+ 929 - 0
docs/superpowers/plans/2026-09-14-mp-webview-transport-plan.md

@@ -0,0 +1,929 @@
+# 小程序 WebView 模板 + 运输计划 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-view` 壳与 `/#/m/*` H5 骨架(一次性 ticket 换 JWT),并首发管理端「运输计划」全量能力(卡片流)。
+
+**Architecture:** 小程序只维护 `pages/common/mp-webview`:先调 `POST /web/auth/mp-webview-ticket`,再打开 `{WEB_ORIGIN}/#/m/...?mpTicket=`。H5 用 `mp-webview-exchange` 写入 `userStore`,之后走现有 `planApi`。`/#/m/*` 为静态路由 + `MpWebviewShell`,跳过桌面菜单/WorkTab/登录页。运输计划页复用现有表单/查看组件,以 `variant="page"` 全屏展示。
+
+**Tech Stack:** FastAPI + Redis、Vue3 + Vue Router hash、Pinia、Element Plus、uni-app `<web-view>`、三墙 Token。
+
+**Spec:** `docs/superpowers/specs/2026-09-14-mp-webview-transport-plan-design.md`
+
+---
+
+## 文件结构
+
+| 路径 | 职责 |
+|------|------|
+| `backend/app/services/mp_webview_ticket.py` | ticket 签发/一次性消费(Redis TTL 60s) |
+| `backend/app/api/public.py` | `POST /web/auth/mp-webview-ticket`、`POST /web/auth/mp-webview-exchange` |
+| `backend/app/core/config.py`、`backend/.env*` | exchange 加入加密排除 |
+| `backend/tests/test_mp_webview_ticket.py` | ticket 单测 |
+| `web-admin/src/utils/mp-webview.ts` | 是否 WebView 路由、通知小程序登录、清 ticket query |
+| `web-admin/src/layouts/MpWebviewShell.vue` | 无侧栏壳、换票、401 桥接 |
+| `web-admin/src/views/m/pl/plan/*.vue` | 列表/详情/表单/安排/打印/三方人车 |
+| `web-admin/src/router/routes/staticRoutes.ts` | 注册 `/m/*` |
+| `web-admin/src/router/guards/beforeEach.ts` | 登录后不注入桌面菜单 |
+| `web-admin/src/store/modules/user.ts`、`src/utils/http/index.ts` | WebView 内 401 不进 `/auth/login` |
+| `web-admin/.env*` | 前端加密排除 exchange |
+| `miniprogram/pages/common/mp-webview.vue` | 通用壳 |
+| `miniprogram/pages.json`、`config/api.js`、`config/workbench-menus.js` | 路由与工作台入口 |
+| `docs/sql/migrations/2026-09-14-mini-pl-plan-webview-menus.sql` | 菜单 `mini:pl:plan:manage` |
+| `docs/web-admin-mp-webview-modules.md` | 后续模块接入说明 |
+
+前端无 Vitest:TDD 覆盖后端 ticket;H5/小程序用命令与手工清单验证。
+
+---
+
+### Task 1: Redis ticket 服务 + 单测
+
+**Files:**
+- Create: `backend/app/services/mp_webview_ticket.py`
+- Create: `backend/tests/test_mp_webview_ticket.py`
+
+- [ ] **Step 1: 写失败单测**
+
+```python
+# backend/tests/test_mp_webview_ticket.py
+import json
+from unittest.mock import patch
+
+import pytest
+
+from app.services import mp_webview_ticket as svc
+
+
+class FakeRedis:
+    def __init__(self):
+        self.data = {}
+
+    def set(self, key, value, ex=None, nx=False):
+        if nx and key in self.data:
+            return False
+        self.data[key] = value
+        return True
+
+    def get(self, key):
+        return self.data.get(key)
+
+    def delete(self, key):
+        return 1 if self.data.pop(key, None) is not None else 0
+
+
+def test_issue_and_consume_once():
+    fake = FakeRedis()
+    with patch.object(svc, "get_sync_redis_client", return_value=fake):
+        ticket = svc.issue_ticket("u1", "sid-1", "rj-1")
+        payload = svc.consume_ticket(ticket)
+        assert payload["userId"] == "u1"
+        assert payload["sessionId"] == "sid-1"
+        assert payload["refreshJti"] == "rj-1"
+        assert svc.consume_ticket(ticket) is None
+
+
+def test_consume_unknown_returns_none():
+    fake = FakeRedis()
+    with patch.object(svc, "get_sync_redis_client", return_value=fake):
+        assert svc.consume_ticket("missing") is None
+
+
+def test_issue_without_redis_raises():
+    with patch.object(svc, "get_sync_redis_client", return_value=None):
+        with pytest.raises(RuntimeError, match="Redis"):
+            svc.issue_ticket("u1", "sid-1")
+```
+
+- [ ] **Step 2: 跑测确认失败**
+
+```bash
+cd backend && source venv/bin/activate && pytest tests/test_mp_webview_ticket.py -v
+```
+
+Expected: `ModuleNotFoundError` 或 `cannot import mp_webview_ticket`
+
+- [ ] **Step 3: 实现服务**
+
+```python
+# backend/app/services/mp_webview_ticket.py
+"""小程序 WebView 一次性短时 ticket(不把 JWT 放进 URL)。"""
+from __future__ import annotations
+
+import json
+from typing import Optional
+
+from app.core.redis_client import get_sync_redis_client
+from app.core.utils import generate_uuid
+
+TICKET_TTL_SEC = 60
+KEY_PREFIX = "mp_wv_ticket:"
+
+
+def _key(ticket: str) -> str:
+    return f"{KEY_PREFIX}{ticket}"
+
+
+def issue_ticket(user_id: str, session_id: str, refresh_jti: str = "") -> str:
+    r = get_sync_redis_client()
+    if r is None:
+        raise RuntimeError("Redis 不可用,无法签发 WebView ticket")
+    ticket = generate_uuid()
+    body = json.dumps(
+        {"userId": user_id, "sessionId": session_id, "refreshJti": refresh_jti or ""},
+        ensure_ascii=False,
+    )
+    ok = r.set(_key(ticket), body, ex=TICKET_TTL_SEC, nx=True)
+    if not ok:
+        raise RuntimeError("签发 WebView ticket 失败")
+    return ticket
+
+
+def consume_ticket(ticket: str) -> Optional[dict]:
+    raw = (ticket or "").strip()
+    if not raw:
+        return None
+    r = get_sync_redis_client()
+    if r is None:
+        raise RuntimeError("Redis 不可用,无法兑换 WebView ticket")
+    key = _key(raw)
+    payload = r.get(key)
+    r.delete(key)
+    if not payload:
+        return None
+    if isinstance(payload, bytes):
+        payload = payload.decode("utf-8")
+    data = json.loads(payload)
+    if not isinstance(data, dict):
+        return None
+    return data
+```
+
+- [ ] **Step 4: 再跑测**
+
+```bash
+cd backend && source venv/bin/activate && pytest tests/test_mp_webview_ticket.py -v
+```
+
+Expected: 3 passed
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add backend/app/services/mp_webview_ticket.py backend/tests/test_mp_webview_ticket.py
+git commit -m "$(cat <<'EOF'
+feat(backend): add one-time Redis tickets for miniprogram webview login.
+
+EOF
+)"
+```
+
+---
+
+### Task 2: 签发 / 兑换 HTTP 接口 + 加密排除
+
+**Files:**
+- Modify: `backend/app/api/public.py`
+- Modify: `backend/app/core/config.py`(`API_ENCRYPT_EXCLUDE_PATHS` 增加 exchange)
+- Modify: `backend/.env`、`backend/.env.production` 同样追加路径
+- Create: `backend/tests/test_mp_webview_auth_api.py`(用 FastAPI TestClient 时若现有套件过重,可只测 helper `_issue_tokens_for_session`;优先测路由 401)
+
+- [ ] **Step 1: 在 `public.py` 增加两个路由(放在 `refresh_token` 之后)**
+
+签发:从 `Authorization: Bearer` 取当前用户与 JWT `sid`。
+
+```python
+from jose import JWTError
+from app.services.mp_webview_ticket import TICKET_TTL_SEC, consume_ticket, issue_ticket
+
+@router.post("/web/auth/mp-webview-ticket", response_model=dict)
+def mp_webview_ticket(request: Request, db: Session = Depends(get_db)):
+    authorization = request.headers.get("Authorization", "")
+    if not authorization.startswith("Bearer "):
+        return error_response(401, "未授权")
+    token = authorization.replace("Bearer ", "").strip()
+    try:
+        current_user = get_current_user_by_token(token=token, db=db)
+        payload = decode_token(token)
+    except HTTPException:
+        return error_response(401, "Token无效或已过期")
+    except JWTError:
+        return error_response(401, "Token无效或已过期")
+    session_id = str(payload.get("sid") or "").strip()
+    if not session_id:
+        return error_response(401, "Token无效或已过期")
+    try:
+        ticket = issue_ticket(
+            user_id=str(current_user.get("user_id") or ""),
+            session_id=session_id,
+            refresh_jti=str(payload.get("jti") or ""),
+        )
+    except RuntimeError:
+        return error_response(503, "暂时无法打开内嵌页,请稍后重试")
+    return success_response({"ticket": ticket, "expiresIn": TICKET_TTL_SEC})
+```
+
+兑换:无 Bearer。消费 ticket → 校验 session → 复用 **同一 `sessionId`** 签发新 access/refresh(与 `refresh_token` 相同写法,但 **不要** revoke 旧 refresh,以免小程序原生页掉线)。
+
+```python
+@router.post("/web/auth/mp-webview-exchange", response_model=dict)
+def mp_webview_exchange(body: dict, db: Session = Depends(get_db)):
+    ticket = str((body or {}).get("ticket") or "").strip()
+    if not ticket:
+        return error_response(400, "ticket不能为空")
+    try:
+        data = consume_ticket(ticket)
+    except RuntimeError:
+        return error_response(503, "暂时无法打开内嵌页,请稍后重试")
+    if not data:
+        return error_response(401, "ticket无效或已过期")
+    user_id = str(data.get("userId") or "").strip()
+    session_id = str(data.get("sessionId") or "").strip()
+    if not user_id or not session_id:
+        return error_response(401, "ticket无效或已过期")
+    if not is_session_valid(session_id):
+        return error_response(401, "登录已失效,请重新登录")
+    user = user_crud.get(db, user_id)
+    if not user or getattr(user, "del_flag", 0) != 0:
+        return error_response(401, "用户不存在或已删除")
+    if user.status != "1":
+        return error_response(403, "账号已被停用,请联系管理员")
+    web_role = resolve_web_role_for_user(db, user.id, user.tenant_no)
+    access_jti = generate_uuid()
+    refresh_jti = generate_uuid()
+    access_token = create_access_token(
+        data=_jwt_user_payload(user, web_role),
+        session_id=session_id,
+        token_jti=access_jti,
+    )
+    refresh_token = create_refresh_token(
+        data={"sub": user.id, "username": user.user_no, "role": web_role},
+        session_id=session_id,
+        refresh_jti=refresh_jti,
+    )
+    store_refresh_token(refresh_jti=refresh_jti, user_id=user.id)
+    return success_response(
+        {
+            "token": access_token,
+            "refreshToken": refresh_token,
+            "expiresIn": settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
+            "userInfo": _build_user_info_payload(user, web_role),
+        }
+    )
+```
+
+注意:`resolve_web_role_for_user` 现有签名以 `public.py` 登录处为准(含 `tenant_no`)。
+
+- [ ] **Step 2: 加密排除**
+
+在 `backend/app/core/config.py` 的 `API_ENCRYPT_EXCLUDE_PATHS` 默认串中追加:
+
+`/api/web/auth/mp-webview-exchange`
+
+`backend/.env` 与 `backend/.env.production` 的 `API_ENCRYPT_EXCLUDE_PATHS` 同样追加。签发接口保持加密(已登录)。
+
+- [ ] **Step 3: 手工/pytest 冒烟**
+
+```bash
+cd backend && source venv/bin/activate && pytest tests/test_mp_webview_ticket.py -v
+```
+
+Expected: 仍 3 passed。接口级可用 curl:无 Bearer 调 ticket → 401;空 body 调 exchange → 400。
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add backend/app/api/public.py backend/app/core/config.py backend/.env backend/.env.production
+git commit -m "$(cat <<'EOF'
+feat(backend): exchange miniprogram webview tickets for JWT sessions.
+
+EOF
+)"
+```
+
+---
+
+### Task 3: web-admin WebView 工具函数 + 守卫 + 401
+
+**Files:**
+- Create: `web-admin/src/utils/mp-webview.ts`
+- Modify: `web-admin/src/router/guards/beforeEach.ts`
+- Modify: `web-admin/src/store/modules/user.ts`(`logOut`)
+- Modify: `web-admin/.env`、`.env.development`、`.env.production` 的 `VITE_API_ENCRYPT_EXCLUDE_PATHS` 追加 `/web/auth/mp-webview-exchange`
+
+- [ ] **Step 1: 工具函数**
+
+```ts
+// web-admin/src/utils/mp-webview.ts
+export const MP_WEBVIEW_PATH_PREFIX = '/m/'
+
+export function isMpWebviewPath(path: string): boolean {
+  return (path || '').startsWith(MP_WEBVIEW_PATH_PREFIX)
+}
+
+export function getWxMiniProgram(): {
+  reLaunch?: (opt: { url: string }) => void
+  navigateBack?: (opt?: { delta?: number }) => void
+} | null {
+  if (typeof window === 'undefined') return null
+  const wxObj = (window as unknown as { wx?: { miniProgram?: unknown } }).wx
+  const mini = wxObj?.miniProgram as
+    | { reLaunch?: (opt: { url: string }) => void; navigateBack?: (opt?: { delta?: number }) => void }
+    | undefined
+  return mini || null
+}
+
+export function redirectToMiniLogin(): void {
+  const mini = getWxMiniProgram()
+  if (mini?.reLaunch) {
+    mini.reLaunch({ url: '/pages/login/login' })
+    return
+  }
+  // 非小程序:只展示空态,禁止跳管理端登录
+}
+
+export function stripMpTicketFromUrl(path: string, query: Record<string, unknown>): Record<string, unknown> {
+  const next = { ...query }
+  delete next.mpTicket
+  return next
+}
+```
+
+- [ ] **Step 2: `handleRouteGuard` 在「处理动态路由注册」处短路**
+
+在 `handleRouteGuard` 第 3 步(约 177 行)改为:
+
+```ts
+import { isMpWebviewPath } from '@/utils/mp-webview'
+
+if (!routeRegistry?.isRegistered() && userStore.isLogin) {
+  if (isMpWebviewPath(to.path)) {
+    setPageTitle(to)
+    next()
+    return
+  }
+  // 原 handleDynamicRoutes ...
+}
+```
+
+`handleLoginStatus`:`isMpWebviewPath(to.path)` 时与静态路由一样放行(即使尚未 isLogin)。`/m` 将注册为静态路由,本来就会放行;此判断防止漏配。
+
+- [ ] **Step 3: `userStore.logOut` 在 WebView 路由不要 `router.push({ name: 'Login' })`**
+
+在清空状态后:
+
+```ts
+import { isMpWebviewPath, redirectToMiniLogin } from '@/utils/mp-webview'
+
+const currentRoute = router.currentRoute.value
+if (isMpWebviewPath(currentRoute.path)) {
+  redirectToMiniLogin()
+  return
+}
+// 原 Login 跳转
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add web-admin/src/utils/mp-webview.ts web-admin/src/router/guards/beforeEach.ts web-admin/src/store/modules/user.ts web-admin/.env web-admin/.env.development web-admin/.env.production
+git commit -m "$(cat <<'EOF'
+feat(web-admin): skip desktop chrome and login page inside mp webview routes.
+
+EOF
+)"
+```
+
+---
+
+### Task 4: MpWebviewShell + 静态路由占位页
+
+**Files:**
+- Create: `web-admin/src/layouts/MpWebviewShell.vue`
+- Create: `web-admin/src/views/m/pl/plan/index.vue`(先占位「加载中 / 请从山峡化工小程序打开」)
+- Modify: `web-admin/src/router/routes/staticRoutes.ts`
+- 在 `index.html` 或壳内加载 `https://res.wx.qq.com/open/js/jweixin-1.6.0.js`(仅壳 `onMounted` 动态插入一次)
+
+- [ ] **Step 1: Shell**
+
+`MpWebviewShell.vue`:
+
+- 插槽包一层 `.mp-wv-root`:`max-width: 480px; margin: 0 auto; min-height: 100vh; background: #f5f8f7; color: #1f2a26;`
+- CSS 变量:`--brand: #0a9f6d`(三墙,勿用 `#10b981`)
+- `onMounted`:
+  1. 动态加载 jweixin(已有则跳过)
+  2. 读 `route.query.mpTicket`
+  3. 有 ticket:`POST /web/auth/mp-webview-exchange`(走现有 `request`,url 须命中加密排除)
+  4. `userStore.setToken` + `setUserInfo` + `setLoginStatus(true)`
+  5. `router.replace({ path: route.path, query: stripMpTicketFromUrl(...) })`
+  6. `useDictStore().loadAllDict()`
+  7. 无 ticket 且未登录:展示「请从山峡化工小程序打开」,**不要**出现登录表单
+  8. ticket 失败:`redirectToMiniLogin()`,文案「登录已失效」
+
+exchange 请求示例(新建 `web-admin/src/api/mp-webview.ts`):
+
+```ts
+import request from '@/utils/http'
+
+export const mpWebviewApi = {
+  exchange: (ticket: string) =>
+    request.post<{
+      token: string
+      refreshToken: string
+      expiresIn: number
+      userInfo: Api.Auth.UserInfo
+    }>('/web/auth/mp-webview-exchange', { ticket })
+}
+```
+
+- [ ] **Step 2: 占位列表页**
+
+```vue
+<!-- web-admin/src/views/m/pl/plan/index.vue -->
+<template>
+  <MpWebviewShell>
+    <p class="mp-wv-placeholder">运输计划加载中…</p>
+  </MpWebviewShell>
+</template>
+```
+
+- [ ] **Step 3: 静态路由(不要挂 `views/index/index.vue` 布局)**
+
+```ts
+{
+  path: '/m/pl/plan',
+  name: 'MpPlPlanList',
+  component: () => import('@views/m/pl/plan/index.vue'),
+  meta: { title: '运输计划', isHideTab: true, isMpWebview: true }
+},
+{
+  path: '/m/pl/plan/new',
+  name: 'MpPlPlanNew',
+  component: () => import('@views/m/pl/plan/form.vue'),
+  meta: { title: '新增计划', isHideTab: true, isMpWebview: true }
+},
+{
+  path: '/m/pl/plan/paste',
+  name: 'MpPlPlanPaste',
+  component: () => import('@views/m/pl/plan/paste.vue'),
+  meta: { title: '三方人车快速创建', isHideTab: true, isMpWebview: true }
+},
+{
+  path: '/m/pl/plan/:id/edit',
+  name: 'MpPlPlanEdit',
+  component: () => import('@views/m/pl/plan/form.vue'),
+  meta: { title: '编辑计划', isHideTab: true, isMpWebview: true }
+},
+{
+  path: '/m/pl/plan/:id/arrange',
+  name: 'MpPlPlanArrange',
+  component: () => import('@views/m/pl/plan/form.vue'),
+  meta: { title: '安排运输', isHideTab: true, isMpWebview: true }
+},
+{
+  path: '/m/pl/plan/:id/print',
+  name: 'MpPlPlanPrint',
+  component: () => import('@views/m/pl/plan/print.vue'),
+  meta: { title: '打印单据', isHideTab: true, isMpWebview: true }
+},
+{
+  path: '/m/pl/plan/:id',
+  name: 'MpPlPlanDetail',
+  component: () => import('@views/m/pl/plan/detail.vue'),
+  meta: { title: '计划详情', isHideTab: true, isMpWebview: true }
+}
+```
+
+本 Task 可先让 `form.vue` / `detail.vue` / `print.vue` / `paste.vue` 都是套 Shell 的空页,避免路由 404。把更具体的 `:id` 路由放在 `/m/pl/plan` 之后。
+
+- [ ] **Step 4: 浏览器打开** `http://localhost:3006/#/m/pl/plan`(端口以实际 Vite 为准)
+
+Expected: 文案为「请从山峡化工小程序打开」,无侧栏。
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add web-admin/src/layouts/MpWebviewShell.vue web-admin/src/api/mp-webview.ts web-admin/src/views/m/pl/plan web-admin/src/router/routes/staticRoutes.ts
+git commit -m "$(cat <<'EOF'
+feat(web-admin): add mp webview shell and /m/pl/plan static routes.
+
+EOF
+)"
+```
+
+---
+
+### Task 5: 小程序通用 web-view 壳 + 工作台入口
+
+**Files:**
+- Create: `miniprogram/pages/common/mp-webview.vue`
+- Modify: `miniprogram/pages.json`(主包 `pages` 数组增加 `pages/common/mp-webview`,`navigationStyle: custom`)
+- Modify: `miniprogram/config/api.js`
+- Modify: `miniprogram/config/workbench-menus.js`
+- Modify: `miniprogram/utils/request.js` 无需改(ticket 用已有 Bearer)
+
+- [ ] **Step 1: API 常量**
+
+`AUTH_API` 增加:
+
+```js
+MP_WEBVIEW_TICKET: '/web/auth/mp-webview-ticket'
+```
+
+`PAGE_PATHS`:
+
+```js
+mpWebview: '/pages/common/mp-webview'
+```
+
+`WORKBENCH_ROUTE_MAP`:
+
+```js
+'pl-plan-webview': PAGE_PATHS.mpWebview + '?path=' + encodeURIComponent('/m/pl/plan')
+```
+
+(若工作台只吃无 query 的 map,则在 `workbench.vue` 对 `pl-plan-webview` 特殊拼接;**优先改 map 为完整 url**,`workbench.vue` 已有 `WORKBENCH_ROUTE_MAP[menu.code]`。)
+
+- [ ] **Step 2: 工作台菜单**(插在「运输计划单」**之后**,不改现场入口)
+
+```js
+{
+  name: '运输计划',
+  enabled: true,
+  code: 'pl-plan-webview',
+  icon: '📝',
+  perms: 'mini:pl:plan:manage'
+}
+```
+
+模块 `desc` 可改为 `加油登记 · 运输计划单 · 运输计划 · 司机运输计划单`。
+
+- [ ] **Step 3: `mp-webview.vue`**
+
+规则:
+
+- `onLoad(query)`:`path` 必须以 `/m/` 开头,否则 toast「无效地址」并 `navigateBack`
+- 未登录:`reLaunch` 登录页
+- `request({ url: AUTH_API.MP_WEBVIEW_TICKET, method: 'POST' })` 取 `data.ticket`
+- `src = WEB_ORIGIN.replace(/\/$/, '') + '/#' + path + '?mpTicket=' + encodeURIComponent(ticket)`
+- 模板仅 `<web-view :src="src" />`(src 空时先显示加载文案,有 src 后只留 web-view)
+- 业务域名:生产 `WEB_ORIGIN` 已是 `https://w.shanxiachem.com`
+
+```vue
+<template>
+  <view class="mp-wv-page">
+    <view v-if="errorText" class="mp-wv-error">{{ errorText }}</view>
+    <web-view v-else-if="src" :src="src" />
+    <view v-else class="mp-wv-error">加载中…</view>
+  </view>
+</template>
+```
+
+- [ ] **Step 4: 开发者工具** 关域名校验,点工作台「运输计划」
+
+Expected: 能打开 H5 占位页(需本机 `WEB_ORIGIN` 与 Vite 一致;`config/env.js` 的 `webOrigin`)。
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add miniprogram/pages/common/mp-webview.vue miniprogram/pages.json miniprogram/config/api.js miniprogram/config/workbench-menus.js
+git commit -m "$(cat <<'EOF'
+feat(miniprogram): add reusable web-view shell for H5 business pages.
+
+EOF
+)"
+```
+
+---
+
+### Task 6: 菜单 SQL
+
+**Files:**
+- Create: `docs/sql/migrations/2026-09-14-mini-pl-plan-webview-menus.sql`
+
+- [ ] **Step 1: 写入菜单**(id=`MINI_PL_PLAN_WEBVIEW`,parent=`MINI_PL_ROOT`,perms=`mini:pl:plan:manage`,path=`mp-webview`,order_num=`4`,remark=`小程序 WebView 运输计划(管理端全量)`)
+
+角色绑定:**不要**绑「现场人员」。绑已经拥有 Web `PL_PLAN`(`pl:plan:list`)的角色,并带上祖先 `1` + `MINI_PL_ROOT`(`NOT EXISTS` 防重复)。
+
+```sql
+INSERT INTO `sys_role_menu` (...)
+SELECT
+  MD5(CONCAT(r.id, '|', m.menu_id, '|mini-pl-plan-webview|v1')),
+  r.id, m.menu_id, ...
+FROM `sys_role` r
+JOIN `sys_role_menu` web ON web.role_id = r.id AND web.menu_id = 'PL_PLAN' AND web.del_flag = 0
+JOIN (
+  SELECT '1' AS menu_id UNION ALL
+  SELECT 'MINI_PL_ROOT' UNION ALL
+  SELECT 'MINI_PL_PLAN_WEBVIEW'
+) m
+WHERE r.del_flag = 0
+  AND NOT EXISTS (
+    SELECT 1 FROM `sys_role_menu` x
+    WHERE x.role_id = r.id AND x.menu_id = m.menu_id AND x.del_flag = 0
+  );
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add docs/sql/migrations/2026-09-14-mini-pl-plan-webview-menus.sql
+git commit -m "$(cat <<'EOF'
+docs(sql): add miniprogram menu for webview transport plan.
+
+EOF
+)"
+```
+
+测试/生产库由实施时执行,本 Task 只交 SQL。
+
+---
+
+### Task 7: 抽出列表卡片展示纯函数 + 移动端列表页
+
+**Files:**
+- Create: `web-admin/src/utils/mp-pl-plan-card.ts`
+- Create: `web-admin/src/styles/mp-webview.scss`(卡片/Tab/Tag,对齐 Demo)
+- Modify: `web-admin/src/views/m/pl/plan/index.vue`
+- Modify: `web-admin/src/main.ts` 或 Shell 内 import `mp-webview.scss`
+
+桌面 `pl/plan/index.vue` 列表筛选逻辑复用:`PL_PLAN_LIST_TABS`、`plPlanListTabToFilters`、`planApi.list`。日期快捷对齐现有 `ListToolbarDateShortcuts` 的 today/tomorrow/week/month;移动端做成 pill。
+
+- [ ] **Step 1: 卡片字段**
+
+```ts
+// web-admin/src/utils/mp-pl-plan-card.ts
+import { getPlanTypeLabel, getPlPlanFollowStatusLabel, getPlPlanFollowStatusTagType } from '@/utils/pl-display'
+import { formatPlVehiclePlateDisplay } from '@/utils/pl-display'
+
+export function buildPlanCard(plan: {
+  id: string
+  planNo?: string
+  planType?: string
+  planDate?: string
+  followStatus?: string
+  licensePlate?: string
+  trailerNo?: string
+  personnelSummary?: string
+  productSummary?: string
+}) {
+  return {
+    id: plan.id,
+    planNo: plan.planNo || '—',
+    typeLabel: getPlanTypeLabel(plan.planType || ''),
+    planDate: plan.planDate || '—',
+    statusLabel: getPlPlanFollowStatusLabel(plan.followStatus),
+    statusTagType: getPlPlanFollowStatusTagType(plan.followStatus),
+    plate: formatPlVehiclePlateDisplay(plan.licensePlate, plan.trailerNo) || '—',
+    people: plan.personnelSummary || '—',
+    product: plan.productSummary || '—'
+  }
+}
+```
+
+若 `getPlanTypeLabel` 等导出名与 `pl-display.ts` 不一致,以该文件实际 export 为准,**不要编造新 helper**。
+
+- [ ] **Step 2: 列表页行为**
+
+- 顶栏:返回(`getWxMiniProgram()?.navigateBack` 或 `uni` 不可用时忽略)、标题「运输计划」、主按钮「新增」→ `/m/pl/plan/new`
+- 检索:`q` 调 `planApi.list`(字段与桌面 `quickSearch` 一致,查看 `getPlanExportFilterParams` / list params)
+- Tab:`PL_PLAN_LIST_TABS` 逐个渲染,`plPlanListTabToFilters` 并入 list params
+- 分页:触底 `pageNum++` 或底部分页按钮
+- 卡片点击 → `/m/pl/plan/${id}`
+- 无 `pl:plan:list`:接口 403 时页内「无权限」,不跳 `/403`
+- 工具条次要:三方人车 → `/m/pl/plan/paste`
+
+列表请求继续走 `planApi.list`,默认 `create_time DESC`(后端已有)。
+
+- [ ] **Step 3: 样式** 对齐 `bak/mobile-ui/shanxia-mp-pl-plan-webview-demo.html`:绿顶栏、pill tab、白卡片、状态深底 Tag(`el-tag`)。
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add web-admin/src/utils/mp-pl-plan-card.ts web-admin/src/styles/mp-webview.scss web-admin/src/views/m/pl/plan/index.vue
+git commit -m "$(cat <<'EOF'
+feat(web-admin): add mobile card list for transport plans in webview.
+
+EOF
+)"
+```
+
+---
+
+### Task 8: 详情页 + 复制 / 等通知 / 作废
+
+**Files:**
+- Modify: `web-admin/src/views/m/pl/plan/detail.vue`
+- Modify: `web-admin/src/components/business/pl-plan-view-dialog/index.vue` **或** 详情页直接 `planApi.get` + 只读字段(优先复用 `PlPlanViewTabs` 只读内容,不要套桌面超宽 dialog)
+
+- [ ] **Step 1: 详情数据** `planApi.get(id)`,展示:计划编号、类型、日期、跟进状态 Tag、车牌、人员、产品摘要、供需。操作按钮显隐复用桌面同一条件(从 `pl/plan/index.vue` **拷贝函数**到 `web-admin/src/utils/pl-plan-action-guards.ts`,桌面页改为 import,避免两套逻辑):
+
+```ts
+export function canEditPlan(row: { archiveStatus?: string; followStatus?: string }) {
+  return row.archiveStatus !== 'archived' && row.followStatus !== 'cancelled'
+}
+export function canArrangeTransport(row: {
+  transportStatus?: string
+  archiveStatus?: string
+  followStatus?: string
+}) {
+  return (
+    (row.transportStatus === 'pending' || row.transportStatus === 'arranged') &&
+    row.archiveStatus !== 'archived' &&
+    row.followStatus !== 'cancelled'
+  )
+}
+export function arrangeTransportLabel(row: { transportStatus?: string }) {
+  return row.transportStatus === 'arranged' ? '调整运输' : '安排运输'
+}
+export function canVoidPlan(row: { archiveStatus?: string; followStatus?: string }) {
+  return row.archiveStatus !== 'archived' && row.followStatus !== 'cancelled'
+}
+export function canWaitNotice(row: { followStatus?: string; archiveStatus?: string }) {
+  return (
+    (row.followStatus === 'pending_arrange' || row.followStatus === 'drafting') &&
+    row.archiveStatus !== 'archived'
+  )
+}
+```
+
+然后改 `web-admin/src/views/pl/plan/index.vue` 删除本地同名函数,改为从该文件 import。
+
+- [ ] **Step 2: 动作**
+
+| 按钮 | 行为 |
+|------|------|
+| 编辑 | `router.push(/m/pl/plan/${id}/edit)` |
+| 安排/调整 | `.../arrange` |
+| 复制 | `router.push({ path: '/m/pl/plan/new', query: { copyFrom: id } })` |
+| 等通知 | `planApi.waitNotice(id)` + 确认框 |
+| 作废 | `planApi.cancel(id)` + 危险确认 |
+| 打印 | `.../print` |
+
+底栏主按钮:可安排时「安排运输」,否则「编辑」。
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add web-admin/src/utils/pl-plan-action-guards.ts web-admin/src/views/pl/plan/index.vue web-admin/src/views/m/pl/plan/detail.vue
+git commit -m "$(cat <<'EOF'
+feat(web-admin): add mobile plan detail and shared action guards.
+
+EOF
+)"
+```
+
+---
+
+### Task 9: 新增 / 编辑 / 安排 — 表单 `variant="page"`
+
+**Files:**
+- Modify: `web-admin/src/components/business/pl-plan-form-dialog/index.vue`
+- Modify: `web-admin/src/views/m/pl/plan/form.vue`
+
+- [ ] **Step 1: 给表单对话框增加 prop `variant?: 'dialog' | 'page'`,默认 `'dialog'`**
+
+`variant==='page'` 时:
+
+- 根节点改为 `<div class="mp-plan-form-page">`,**不**使用 `el-dialog` 遮罩
+- 保留现有 `pl-plan-form-body` 与提交逻辑(`openForArrange`、`planId`、`copyFromPlanId` 等 props 保持)
+- 底栏固定:取消 → `router.back()`;确定仍走原保存/安排
+
+桌面调用不传 `variant`,行为不变。
+
+打开时机:page 模式下 `onMounted` 直接 `handleOpened` 等价逻辑(现有 `@opened`)。
+
+- [ ] **Step 2: `form.vue`**
+
+```vue
+<template>
+  <MpWebviewShell>
+    <PlPlanFormDialog
+      variant="page"
+      :visible="true"
+      :plan-id="planId"
+      :copy-from-plan-id="copyFrom"
+      :open-for-arrange="openForArrange"
+      @success="onSuccess"
+      @close="onClose"
+    />
+  </MpWebviewShell>
+</template>
+```
+
+路由:
+
+- `/m/pl/plan/new` → 无 id;`copyFrom` 来自 query
+- `/m/pl/plan/:id/edit` → `planId`
+- `/m/pl/plan/:id/arrange` → `planId` + `openForArrange=true`
+
+`onSuccess`:回详情或列表。
+
+- [ ] **Step 3: 窄屏** 表单栅格在 page 模式用 CSS 强制单列(`.mp-plan-form-page .form-grid-5 { grid-template-columns: 1fr; }`),`label-position="top"`。
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add web-admin/src/components/business/pl-plan-form-dialog/index.vue web-admin/src/views/m/pl/plan/form.vue web-admin/src/styles/mp-webview.scss
+git commit -m "$(cat <<'EOF'
+feat(web-admin): reuse plan form as a full-page webview variant.
+
+EOF
+)"
+```
+
+---
+
+### Task 10: 打印预览页
+
+**Files:**
+- Modify: `web-admin/src/views/m/pl/plan/print.vue`
+- 复用 `planApi.getPrintDeliverySlip` / 入库/分装模板 API(与桌面打印 dialog 相同函数,从 `pl-batch-print.ts` / 各 print dialog 引用)
+
+- [ ] **Step 1:** 选择单据类型(入库单 / 送提货单 / 分装单 / 空白三种),调用现有 API 得到 HTML,放入 `.mp-print-preview`。页顶提示「长按预览区可保存图片;WebView 不支持系统打印」。
+
+- [ ] **Step 2:** 无权限或 `canBatchPrintSlip` 失败时展示桌面同一错误文案。
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add web-admin/src/views/m/pl/plan/print.vue
+git commit -m "$(cat <<'EOF'
+feat(web-admin): add mobile print preview for transport plan slips.
+
+EOF
+)"
+```
+
+---
+
+### Task 11: 三方人车快速创建
+
+**Files:**
+- Modify: `web-admin/src/views/m/pl/plan/paste.vue`
+- Modify: `web-admin/src/components/business/pl-paste-recognize-dialog/index.vue`
+
+- [ ] **Step 1:** 与表单相同增加 `variant="page"`,`mode="vehicle-bundle"`。成功后 `router.replace('/m/pl/plan')` 并 toast。
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add web-admin/src/views/m/pl/plan/paste.vue web-admin/src/components/business/pl-paste-recognize-dialog
+git commit -m "$(cat <<'EOF'
+feat(web-admin): add mobile paste-create for third-party vehicle bundles.
+
+EOF
+)"
+```
+
+---
+
+### Task 12: 模板文档 + 回归清单 + graphify
+
+**Files:**
+- Create: `docs/web-admin-mp-webview-modules.md`
+- Modify: `docs/superpowers/specs/2026-09-14-mp-webview-transport-plan-design.md` 状态改为「已规划」
+- 若改了代码:根目录 `./scripts/update-graph.sh all` 或分别 backend/web-admin
+
+- [ ] **Step 1: 接入文档写清 5 步**(spec §8):新 `/m/...` 页、Shell、`isMpWebview` 静态路由、工作台同一壳 + `path`、双 perms。禁止新建第二个 web-view 页。
+
+- [ ] **Step 2: 手工回归(实施者勾选)**
+
+- ticket 第二次 exchange 失败
+- 换票后列表租户数据与桌面同账号一致
+- 新增草稿、安排、复制、等通知、作废、打印预览、三方人车可走通
+- 401 回小程序登录,不到 `/#/auth/login`
+- 只发 web-admin:改卡片文案可见
+- 原生「运输计划单」现场页仍可用
+- 生产加密:exchange 明文、后续计划 API 加密
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add docs/web-admin-mp-webview-modules.md docs/superpowers/specs/2026-09-14-mp-webview-transport-plan-design.md
+git commit -m "$(cat <<'EOF'
+docs: describe how to add more miniprogram webview modules.
+
+EOF
+)"
+```
+
+---
+
+## Spec 覆盖自检
+
+| Spec | Task |
+|------|------|
+| D1 卡片流 | 7 |
+| D2 全量能力 | 7–11 |
+| D3 `/#/m` | 4 |
+| D4/D5 ticket JWT | 1–5 |
+| D6 单壳模板 | 5、12 |
+| D7 现场页并存 | 5 不改 `transport-plan/*` |
+| D8 三墙色 | 4、7 |
+| D9 打印预览 | 10 |
+| 守卫不进桌面壳 | 3 |
+| 双权限 + SQL | 6、7 的 403 |
+| 加密排除 exchange | 2、3 |
+| 非小程序打开无登录页 | 4 |
+
+无 TBD/TODO 占位。表单不重写业务规则,只加 `variant="page"`。

+ 1 - 1
docs/superpowers/specs/2026-09-14-mp-webview-transport-plan-design.md

@@ -1,7 +1,7 @@
 # 小程序 WebView 嵌入模板 + 运输计划首发 — 设计文档
 
 > **日期**: 2026-09-14  
-> **状态**: 待评审  
+> **状态**: 已实现  
 > **方案**: 小程序通用 `web-view` 壳 + `web-admin` 移动端路由 `/#/m/*`;首个业务为管理端「运输计划」全量能力  
 > **Demo**: `bak/mobile-ui/shanxia-mp-pl-plan-webview-demo.html`(卡片流列表,已选方案 A)
 

+ 42 - 0
docs/web-admin-mp-webview-modules.md

@@ -0,0 +1,42 @@
+# 小程序 WebView 模块接入
+
+后续管理端业务进入微信小程序时,**只加 H5 路由与工作台入口**,不要再复制鉴权、登录注入或第二个 `web-view` 页。
+
+权威决策见 `docs/superpowers/specs/2026-09-14-mp-webview-transport-plan-design.md`。首发实现:运输计划 `/#/m/pl/plan`。
+
+## 已有骨架(不要新建)
+
+| 层 | 路径 |
+|----|------|
+| 小程序壳 | `miniprogram/pages/common/mp-webview.vue`(主包已注册) |
+| 换票 | `POST /api/web/auth/mp-webview-ticket`(需登录,走加密)→ H5 `POST /api/web/auth/mp-webview-exchange`(加密排除) |
+| H5 壳 | `web-admin/src/layouts/MpWebviewShell.vue` |
+| 路由识别 | `isMpWebviewPath`(`/m/` 前缀)、静态路由 `meta.isMpWebview` |
+| 视觉 | 三墙绿 `#0a9f6d`(`src/styles/mp-webview.scss`),不是管理端 Fresh Mint |
+
+## 新模块 5 步
+
+1. **H5 页面**:在 `web-admin/src/views/m/<module>/` 写列表/详情等;套 `MpWebviewShell`。桌面弹窗可加 `variant="page"` 全屏复用,不要重写业务规则。
+2. **静态路由**:`web-admin/src/router/routes/staticRoutes.ts` 增加 `/m/...`,`meta: { isHideTab: true, isMpWebview: true }`,且必须写在 catch-all 之前。不要挂桌面 `index/index` 布局。
+3. **工作台入口**:`miniprogram/config/workbench-menus.js` 增加一项;`config/api.js` 的 `WORKBENCH_ROUTE_MAP` 指向**同一个**壳:
+   `pages/common/mp-webview?path=` + `encodeURIComponent('/m/...')`。
+4. **双权限**:入口菜单用 `mini:...`;业务 API 仍校验对应 Web `perms`。角色需同时具备两者(参考 `docs/sql/migrations/2026-09-14-mini-pl-plan-webview-menus.sql`)。
+5. **发版**:微信业务域名已配置时,只发 `web-admin` 静态资源即可,**不必提审**小程序。
+
+禁止:每个模块再做一个 `web-view` 页面、再做一套登录注入、URL 长期带 `accessToken`、把公开司机/现场 token 页(`/#/public/pl/transport/:token`)当登录模板。
+
+## 运输计划对照
+
+- 列表 `/m/pl/plan`,详情 `/:id`,表单 `/new` `/:id/edit` `/:id/arrange`,打印 `/:id/print`,三方人车 `/paste`
+- 工作台 code `pl-plan-webview`,perms `mini:pl:plan:manage`
+- 与原生现场页 `pages/pl/transport-plan/*`(`mini:pl:transportPlan:list`)并存,不要混名
+
+## 回归清单
+
+- ticket 第二次 exchange 失败
+- 换票后列表租户数据与桌面同账号一致
+- 新增草稿、安排、复制、等通知、作废、打印预览、三方人车可走通
+- 401 回小程序登录,不到 `/#/auth/login`
+- 只发 web-admin:改卡片文案在小程序 WebView 内可见
+- 原生「运输计划单」现场页仍可用
+- 生产加密:exchange 明文、后续计划 API 加密