docs: describe how to add more miniprogram webview modules.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wuzilongandCursor committed 2026-09-14 16:48:30 +08:00
1 parent bf22c4b031
commit fe9d30105d
3 files changed
+972 -1

No files matched your search

@@ -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,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
View File
@@ -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 加密