diff --git a/docs/superpowers/plans/2026-09-14-mp-webview-transport-plan.md b/docs/superpowers/plans/2026-09-14-mp-webview-transport-plan.md new file mode 100644 index 000000000..d1a79a857 --- /dev/null +++ b/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 ``、三墙 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): Record { + 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 + + +``` + +- [ ] **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)` +- 模板仅 ``(src 空时先显示加载文案,有 src 后只留 web-view) +- 业务域名:生产 `WEB_ORIGIN` 已是 `https://w.shanxiachem.com` + +```vue + +``` + +- [ ] **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'` 时: + +- 根节点改为 `
`,**不**使用 `el-dialog` 遮罩 +- 保留现有 `pl-plan-form-body` 与提交逻辑(`openForArrange`、`planId`、`copyFromPlanId` 等 props 保持) +- 底栏固定:取消 → `router.back()`;确定仍走原保存/安排 + +桌面调用不传 `variant`,行为不变。 + +打开时机:page 模式下 `onMounted` 直接 `handleOpened` 等价逻辑(现有 `@opened`)。 + +- [ ] **Step 2: `form.vue`** + +```vue + +``` + +路由: + +- `/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"`。 diff --git a/docs/superpowers/specs/2026-09-14-mp-webview-transport-plan-design.md b/docs/superpowers/specs/2026-09-14-mp-webview-transport-plan-design.md index 9e845c91c..a44c76053 100644 --- a/docs/superpowers/specs/2026-09-14-mp-webview-transport-plan-design.md +++ b/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) diff --git a/docs/web-admin-mp-webview-modules.md b/docs/web-admin-mp-webview-modules.md new file mode 100644 index 000000000..965ab2c45 --- /dev/null +++ b/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//` 写列表/详情等;套 `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 加密