docs: describe how to add more miniprogram webview modules.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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)
|
||||
|
||||
|
||||
@@ -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 加密
|
||||
Reference in new issue
Block a user