|
|
@@ -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"`。
|