Jelajahi Sumber

docs: record miniprogram WebView template and transport plan design.

Capture the approved card-list, ticket-based login, and reusable shell so later modules can follow the same path.

Co-authored-by: Cursor <cursoragent@cursor.com>
wuzilong 1 bulan lalu
induk
melakukan
da7476df5a

+ 563 - 0
bak/mobile-ui/shanxia-mp-pl-plan-webview-demo.html

@@ -0,0 +1,563 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+  <meta charset="UTF-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
+  <title>山峡化工 · 运输计划(WebView H5 Demo)</title>
+  <style>
+    :root {
+      --brand: #0a9f6d;
+      --brand-dark: #087f57;
+      --brand-soft: #e9f9f2;
+      --barley: #d6a343;
+      --barley-soft: #fff7e8;
+      --bg: #f5f8f7;
+      --surface: #ffffff;
+      --text: #1f2a26;
+      --muted: #6b7b74;
+      --placeholder: #9aa8a1;
+      --border: #dcebe4;
+      --danger: #d14343;
+      --warning: #cf8a19;
+      --success: #189b65;
+      --radius: 14px;
+      --shadow: 0 6px 16px rgba(10, 159, 109, 0.08);
+      --header: linear-gradient(145deg, #087f57 0%, #0a9f6d 72%, #d6a343 165%);
+    }
+    * { box-sizing: border-box; margin: 0; padding: 0; }
+    body {
+      background: #dfe8e3;
+      color: var(--text);
+      font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif;
+      font-size: 14px;
+      line-height: 1.45;
+    }
+    .stage {
+      max-width: 980px;
+      margin: 0 auto;
+      padding: 20px 16px 40px;
+    }
+    .banner {
+      background: #1f2a26;
+      color: #fff;
+      border-radius: 12px;
+      padding: 14px 16px;
+      margin-bottom: 16px;
+      font-size: 13px;
+    }
+    .banner strong { color: #9fe3c4; }
+    .banner code {
+      background: rgba(255,255,255,.12);
+      padding: 1px 6px;
+      border-radius: 4px;
+      font-size: 12px;
+    }
+    .grid {
+      display: grid;
+      grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
+      gap: 18px;
+      align-items: start;
+    }
+    .phone {
+      max-width: 390px;
+      margin: 0 auto;
+      background: var(--bg);
+      border-radius: 28px;
+      overflow: hidden;
+      box-shadow: 0 18px 40px rgba(0,0,0,.14);
+      border: 8px solid #24312c;
+      min-height: 760px;
+      position: relative;
+    }
+    .phone-label {
+      text-align: center;
+      font-size: 12px;
+      color: #4d5c56;
+      margin-bottom: 8px;
+      font-weight: 600;
+    }
+    .status-bar {
+      height: 28px;
+      background: var(--brand-dark);
+      color: rgba(255,255,255,.85);
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      padding: 0 16px;
+      font-size: 11px;
+    }
+    .header {
+      background: var(--header);
+      color: #fff;
+      padding: 10px 14px 14px;
+    }
+    .header-row {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      gap: 8px;
+    }
+    .back { opacity: .92; font-size: 13px; }
+    .header h1 { font-size: 20px; font-weight: 700; margin-top: 8px; }
+    .header .hint {
+      margin-top: 4px;
+      font-size: 11px;
+      opacity: .85;
+    }
+    .search {
+      margin-top: 12px;
+      background: rgba(255,255,255,.95);
+      color: var(--placeholder);
+      border-radius: 999px;
+      padding: 10px 14px;
+      display: flex;
+      align-items: center;
+      gap: 8px;
+      font-size: 13px;
+    }
+    .search .dot {
+      width: 8px; height: 8px; border-radius: 50%;
+      background: var(--brand);
+      flex-shrink: 0;
+    }
+    .date-row {
+      display: flex;
+      gap: 8px;
+      overflow-x: auto;
+      padding: 10px 12px 0;
+      -webkit-overflow-scrolling: touch;
+    }
+    .chip {
+      flex: 0 0 auto;
+      padding: 6px 12px;
+      border-radius: 999px;
+      background: #fff;
+      border: 1px solid var(--border);
+      color: var(--muted);
+      font-size: 12px;
+    }
+    .chip.active {
+      background: var(--brand-soft);
+      border-color: #b7ebce;
+      color: var(--brand-dark);
+      font-weight: 600;
+    }
+    .tabs {
+      display: flex;
+      gap: 6px;
+      overflow-x: auto;
+      padding: 10px 12px;
+      -webkit-overflow-scrolling: touch;
+    }
+    .tab {
+      flex: 0 0 auto;
+      padding: 7px 12px;
+      border-radius: 999px;
+      background: #fff;
+      border: 1px solid var(--border);
+      color: var(--muted);
+      font-size: 12px;
+      cursor: pointer;
+    }
+    .tab.active {
+      background: var(--brand);
+      border-color: var(--brand);
+      color: #fff;
+      font-weight: 600;
+    }
+    .toolbar {
+      display: flex;
+      gap: 8px;
+      padding: 0 12px 10px;
+      overflow-x: auto;
+    }
+    .btn {
+      flex: 0 0 auto;
+      height: 34px;
+      padding: 0 12px;
+      border-radius: 8px;
+      border: 1px solid var(--border);
+      background: #fff;
+      color: var(--text);
+      font-size: 12px;
+      cursor: pointer;
+      display: inline-flex;
+      align-items: center;
+      gap: 4px;
+    }
+    .btn-primary {
+      background: var(--brand);
+      border-color: var(--brand);
+      color: #fff;
+      font-weight: 600;
+    }
+    .btn-danger {
+      color: var(--danger);
+      border-color: #f0c2c2;
+      background: #fff7f7;
+    }
+    .list {
+      padding: 0 12px 88px;
+      display: flex;
+      flex-direction: column;
+      gap: 10px;
+    }
+    .card {
+      background: var(--surface);
+      border-radius: var(--radius);
+      box-shadow: var(--shadow);
+      padding: 12px 12px 10px;
+      border: 1px solid rgba(220,235,228,.8);
+      cursor: pointer;
+    }
+    .card:active { transform: scale(.995); }
+    .card-head {
+      display: flex;
+      justify-content: space-between;
+      gap: 8px;
+      align-items: flex-start;
+    }
+    .plan-no {
+      font-size: 15px;
+      font-weight: 700;
+      color: var(--brand-dark);
+    }
+    .sub {
+      margin-top: 2px;
+      font-size: 12px;
+      color: var(--muted);
+    }
+    .tag {
+      flex-shrink: 0;
+      font-size: 11px;
+      padding: 3px 8px;
+      border-radius: 999px;
+      font-weight: 600;
+      color: #fff;
+    }
+    .tag-warn { background: #d97706; }
+    .tag-ok { background: #059669; }
+    .tag-mute { background: #64748b; }
+    .tag-wait { background: #cf8a19; }
+    .meta {
+      margin-top: 8px;
+      display: grid;
+      gap: 4px;
+      font-size: 12px;
+      color: var(--muted);
+    }
+    .meta b { color: var(--text); font-weight: 500; }
+    .products {
+      margin-top: 8px;
+      background: var(--brand-soft);
+      border-radius: 10px;
+      padding: 8px 10px;
+      font-size: 12px;
+      color: var(--brand-dark);
+    }
+    .card-foot {
+      margin-top: 10px;
+      display: flex;
+      justify-content: flex-end;
+      gap: 8px;
+      flex-wrap: wrap;
+    }
+    .fab-bar {
+      position: absolute;
+      left: 0; right: 0; bottom: 0;
+      padding: 10px 12px calc(12px + env(safe-area-inset-bottom, 0px));
+      background: linear-gradient(180deg, transparent, var(--bg) 28%);
+      display: flex;
+      gap: 8px;
+    }
+    .fab-bar .btn { flex: 1; justify-content: center; height: 42px; font-size: 13px; }
+    .fab-bar .btn-primary { flex: 1.35; }
+
+    /* detail */
+    .detail-body { padding: 12px; padding-bottom: 96px; }
+    .section {
+      background: #fff;
+      border-radius: var(--radius);
+      padding: 12px;
+      margin-bottom: 10px;
+      box-shadow: var(--shadow);
+    }
+    .section h3 {
+      font-size: 13px;
+      margin-bottom: 8px;
+      color: var(--brand-dark);
+    }
+    .kv { display: flex; justify-content: space-between; gap: 12px; padding: 6px 0; font-size: 13px; border-bottom: 1px dashed var(--border); }
+    .kv:last-child { border-bottom: none; }
+    .kv span:first-child { color: var(--muted); flex-shrink: 0; }
+    .kv span:last-child { text-align: right; }
+    .actions-grid {
+      display: grid;
+      grid-template-columns: 1fr 1fr;
+      gap: 8px;
+    }
+    .actions-grid .btn { height: 40px; justify-content: center; }
+    .note {
+      margin-top: 8px;
+      font-size: 11px;
+      color: var(--muted);
+      background: var(--barley-soft);
+      border-radius: 8px;
+      padding: 8px 10px;
+    }
+    .hidden { display: none !important; }
+    .choice-hint {
+      margin-top: 18px;
+      background: #fff;
+      border-radius: 12px;
+      padding: 14px 16px;
+      border: 1px solid #cfe3d9;
+      font-size: 13px;
+      color: #355248;
+    }
+    .choice-hint ol { margin: 8px 0 0 18px; }
+  </style>
+</head>
+<body>
+  <div class="stage">
+    <div class="banner">
+      <strong>运输计划 · 小程序 WebView H5 Demo</strong><br />
+      对齐管理端 <code>/#/pl/plan</code> 核心能力;小程序侧仅壳页 + <code>web-view</code> 加载本 H5,业务改动发 H5 即可,无需反复提审。<br />
+      文件路径:<code>bak/mobile-ui/shanxia-mp-pl-plan-webview-demo.html</code>
+    </div>
+
+    <div class="grid">
+      <!-- LIST -->
+      <div>
+        <div class="phone-label">① 列表页(默认)</div>
+        <div class="phone" id="phone-list">
+          <div class="status-bar"><span>09:41</span><span>WebView</span></div>
+          <header class="header">
+            <div class="header-row">
+              <span class="back">← 工作台</span>
+              <button class="btn btn-primary" type="button" style="height:30px">+ 新增</button>
+            </div>
+            <h1>运输计划</h1>
+            <div class="hint">登录态由小程序注入 Token · H5 调同一套 /api</div>
+            <div class="search"><span class="dot"></span>计划编号 / 供方 / 需方 / 产品 / 车牌号</div>
+          </header>
+
+          <div class="date-row">
+            <button class="chip active" type="button">今天</button>
+            <button class="chip" type="button">明天</button>
+            <button class="chip" type="button">本周</button>
+            <button class="chip" type="button">本月</button>
+            <button class="chip" type="button">自定义</button>
+          </div>
+
+          <div class="tabs" id="list-tabs">
+            <button class="tab active" type="button" data-tab="pending">待安排</button>
+            <button class="tab" type="button" data-tab="arranged">已安排</button>
+            <button class="tab" type="button" data-tab="draft">草稿中</button>
+            <button class="tab" type="button" data-tab="all">全部</button>
+            <button class="tab" type="button" data-tab="inbound">入库单</button>
+            <button class="tab" type="button" data-tab="outbound">出库单</button>
+            <button class="tab" type="button" data-tab="direct">直销单</button>
+          </div>
+
+          <div class="toolbar">
+            <button class="btn" type="button">复制</button>
+            <button class="btn" type="button">安排运输</button>
+            <button class="btn" type="button">等通知</button>
+            <button class="btn" type="button">打印</button>
+            <button class="btn btn-danger" type="button">作废</button>
+          </div>
+
+          <div class="list" id="plan-list">
+            <!-- cards injected -->
+          </div>
+
+          <div class="fab-bar">
+            <button class="btn" type="button">三方人车</button>
+            <button class="btn btn-primary" type="button" onclick="showDetail()">查看选中计划</button>
+          </div>
+        </div>
+      </div>
+
+      <!-- DETAIL -->
+      <div>
+        <div class="phone-label">② 详情 / 操作(点卡片进入)</div>
+        <div class="phone" id="phone-detail">
+          <div class="status-bar"><span>09:41</span><span>WebView</span></div>
+          <header class="header">
+            <div class="header-row">
+              <span class="back" style="cursor:pointer" onclick="showList()">← 列表</span>
+              <span class="tag tag-warn">待安排</span>
+            </div>
+            <h1>JH20260914003</h1>
+            <div class="hint">出库单 · 计划日期 2026-09-14</div>
+          </header>
+
+          <div class="detail-body">
+            <div class="section">
+              <h3>基本信息</h3>
+              <div class="kv"><span>跟进状态</span><span><b>待安排</b></span></div>
+              <div class="kv"><span>送货时间</span><span>09-14 14:00</span></div>
+              <div class="kv"><span>车牌号</span><span>皖H·D1234 / 挂 皖H·G88</span></div>
+              <div class="kv"><span>人员</span><span>司机 张伟 · 押运 李强</span></div>
+              <div class="kv"><span>跟进人</span><span>王调度</span></div>
+            </div>
+
+            <div class="section">
+              <h3>产品摘要</h3>
+              <div class="products" style="margin:0">
+                甲醇 32 吨 · 包装吨袋<br />
+                供方:安庆山峡 · 需方:合肥某化
+              </div>
+              <div class="note">移动端以卡片摘要为主;完整编辑走「编辑计划」全屏表单(同管理端字段,分步/分区展示)。</div>
+            </div>
+
+            <div class="section">
+              <h3>快捷操作(对齐管理端工具栏)</h3>
+              <div class="actions-grid">
+                <button class="btn btn-primary" type="button">安排运输</button>
+                <button class="btn" type="button">编辑计划</button>
+                <button class="btn" type="button">复制计划</button>
+                <button class="btn" type="button">等客户通知</button>
+                <button class="btn" type="button">打印单据</button>
+                <button class="btn btn-danger" type="button">作废</button>
+              </div>
+            </div>
+          </div>
+
+          <div class="fab-bar">
+            <button class="btn" type="button" onclick="showList()">返回列表</button>
+            <button class="btn btn-primary" type="button">安排运输</button>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <div class="choice-hint">
+      <strong>请确认 Demo 方向(回复编号即可)</strong>
+      <ol>
+        <li><b>A</b> — 列表用「卡片流」如左图(推荐,适合手机一屏信息)</li>
+        <li><b>B</b> — 列表尽量仿桌面表格(横滑多列,信息更全但操作更重)</li>
+        <li><b>C</b> — 先只做列表+查看+安排运输,其余打印/作废/三方人车二期再上</li>
+      </ol>
+      也可直接说要改哪里(Tab、按钮优先级、字段展示等)。
+    </div>
+  </div>
+
+  <script>
+    const plans = [
+      {
+        no: 'JH20260914003',
+        type: '出库单',
+        status: '待安排',
+        tag: 'tag-warn',
+        date: '2026-09-14',
+        plate: '未派车',
+        people: '—',
+        product: '甲醇 32 吨 · 安庆山峡 → 合肥某化',
+        tab: 'pending'
+      },
+      {
+        no: 'JH20260914001',
+        type: '入库单',
+        status: '已安排',
+        tag: 'tag-ok',
+        date: '2026-09-14',
+        plate: '皖H·D1234',
+        people: '张伟 / 李强',
+        product: '乙醇 28 吨 · 张家港 → 安庆仓',
+        tab: 'arranged'
+      },
+      {
+        no: 'JH20260913018',
+        type: '直销单',
+        status: '等通知',
+        tag: 'tag-wait',
+        date: '2026-09-13',
+        plate: '苏E·5M892',
+        people: '陈师傅',
+        product: '冰醋酸 20 吨 · 南通 → 芜湖',
+        tab: 'pending'
+      },
+      {
+        no: 'JH20260912007',
+        type: '出库单',
+        status: '草稿中',
+        tag: 'tag-mute',
+        date: '2026-09-12',
+        plate: '—',
+        people: '—',
+        product: '待完善产品行',
+        tab: 'draft'
+      }
+    ]
+
+    const listEl = document.getElementById('plan-list')
+    let activeTab = 'pending'
+
+    function renderList() {
+      const rows = activeTab === 'all'
+        ? plans
+        : plans.filter((p) => {
+            if (activeTab === 'pending') return p.status === '待安排' || p.status === '等通知'
+            if (activeTab === 'arranged') return p.status === '已安排'
+            if (activeTab === 'draft') return p.status === '草稿中'
+            if (activeTab === 'inbound') return p.type === '入库单'
+            if (activeTab === 'outbound') return p.type === '出库单'
+            if (activeTab === 'direct') return p.type === '直销单'
+            return true
+          })
+
+      listEl.innerHTML = rows.map((p) => `
+        <article class="card" onclick="showDetail()">
+          <div class="card-head">
+            <div>
+              <div class="plan-no">${p.no}</div>
+              <div class="sub">${p.type} · ${p.date}</div>
+            </div>
+            <span class="tag ${p.tag}">${p.status}</span>
+          </div>
+          <div class="meta">
+            <div>车牌:<b>${p.plate}</b></div>
+            <div>人员:<b>${p.people}</b></div>
+          </div>
+          <div class="products">${p.product}</div>
+          <div class="card-foot">
+            <button class="btn" type="button" onclick="event.stopPropagation()">复制</button>
+            <button class="btn btn-primary" type="button" onclick="event.stopPropagation()">安排运输</button>
+          </div>
+        </article>
+      `).join('') || '<div style="padding:24px;text-align:center;color:#6b7b74">该 Tab 暂无数据</div>'
+    }
+
+    document.getElementById('list-tabs').addEventListener('click', (e) => {
+      const btn = e.target.closest('.tab')
+      if (!btn) return
+      document.querySelectorAll('#list-tabs .tab').forEach((t) => t.classList.remove('active'))
+      btn.classList.add('active')
+      activeTab = btn.dataset.tab
+      renderList()
+    })
+
+    document.querySelectorAll('.date-row .chip').forEach((chip) => {
+      chip.addEventListener('click', () => {
+        document.querySelectorAll('.date-row .chip').forEach((c) => c.classList.remove('active'))
+        chip.classList.add('active')
+      })
+    })
+
+    function showDetail() {
+      document.getElementById('phone-list').classList.add('hidden')
+      document.getElementById('phone-detail').classList.remove('hidden')
+      // on dual layout keep both; on narrow, scroll to detail
+      document.getElementById('phone-detail').scrollIntoView({ behavior: 'smooth', block: 'start' })
+    }
+    function showList() {
+      document.getElementById('phone-list').classList.remove('hidden')
+      document.getElementById('phone-list').scrollIntoView({ behavior: 'smooth', block: 'start' })
+    }
+
+    renderList()
+  </script>
+</body>
+</html>

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

@@ -0,0 +1,229 @@
+# 小程序 WebView 嵌入模板 + 运输计划首发 — 设计文档
+
+> **日期**: 2026-09-14  
+> **状态**: 待评审  
+> **方案**: 小程序通用 `web-view` 壳 + `web-admin` 移动端路由 `/#/m/*`;首个业务为管理端「运输计划」全量能力  
+> **Demo**: `bak/mobile-ui/shanxia-mp-pl-plan-webview-demo.html`(卡片流列表,已选方案 A)
+
+## 1. 目标
+
+在微信小程序中提供与 Web 管理端 **运输管理 → 运输计划**(`/#/pl/plan-module/plan`)同等业务能力,并以 **WebView 加载 H5** 落地,使业务改动只发门户静态资源,不必每次提审小程序。
+
+本次同时沉淀 **可复用模板**:后续其它管理端页面按同一骨架接入,不再复制鉴权、壳页与布局。
+
+## 2. 已确认决策
+
+| # | 项 | 决策 |
+|---|-----|------|
+| D1 | 列表形态 | 卡片流(Demo A),不做桌面表格横滑 |
+| D2 | 功能范围 | **全量对齐**管理端:新增/编辑、安排运输、复制、等通知、打印、作废、三方人车快速创建 |
+| D3 | H5 落点 | 在 `web-admin` 新增 `/#/m/...` 移动端路由,复用现有 API 与业务逻辑;不为 WebView 新建独立工程 |
+| D4 | 登录态 | **必须使用小程序已登录用户的 JWT**;H5 禁止再走 `/auth/login` |
+| D5 | Token 传递 | **一次性短时 ticket** 换票;URL **不得**长期携带 accessToken |
+| D6 | 模板化 | 小程序只维护一个通用 WebView 壳;后续模块只加 H5 路由 + 工作台入口 |
+| D7 | 与现网小程序页 | **并存**:现有 `pages/pl/transport-plan/*`(现场记录/现场回传)不替换、不改名混用 |
+| D8 | 视觉 | WebView 内 H5 使用小程序三墙 Token(`#0a9f6d` 等),**不**套管理端 Fresh Mint 侧栏/WorkTab;这是「嵌在小程序里的 H5」,不是管理端皮肤 |
+| D9 | 打印 | WebView 内先做单据 **预览 / 存图**;不承诺调用手机系统打印机或 Lodop |
+
+## 3. 非目标
+
+- 不把桌面 `el-table` / `el-dialog` 原样塞进 WebView
+- 不改运输计划后端业务规则、状态机、租户隔离与数据权限
+- 不下线现场人员原生「运输计划单」与公开 H5 免登链接
+- 不在本变更中把其它模块(入库、加油、CRM 等)迁入 WebView(只定模板,首发仅运输计划)
+- 不把 ticket 或 JWT 写入可被中间日志长期保留的 query(使用 hash 片段携带 ticket)
+
+## 4. 架构(模板)
+
+```text
+小程序工作台
+  → pages/common/mp-webview?path=/m/pl/plan
+       1) POST /api/web/auth/mp-webview-ticket   (小程序 Bearer)
+       2) web-view src = {WEB_ORIGIN}/#/m/pl/plan?mpTicket={ticket}
+            (Hash 路由下 query 在 # 之后,不会进 Nginx 的 path/query 日志;见 §5)
+       3) H5 启动:POST /api/web/auth/mp-webview-exchange
+            → accessToken + refreshToken 写入 web-admin userStore
+       4) 之后与管理端相同:Authorization: Bearer + 现有 /api/transport/plan/...
+
+后续模块
+  → 同一壳页 ?path=/m/<module>
+  → 只需新增 web-admin 页面,不必再提审小程序
+```
+
+```text
+┌─────────────┐     ticket      ┌──────────────────┐     JWT      ┌────────────┐
+│  miniprogram│ ──────────────► │ web-admin /#/m/* │ ───────────► │  FastAPI   │
+│  web-view壳 │ ◄────────────── │ MpWebviewShell   │ ◄─────────── │  既有计划API │
+└─────────────┘   401→reLaunch  └──────────────────┘   租户/权限  └────────────┘
+```
+
+**边界**
+
+| 单元 | 职责 | 依赖 |
+|------|------|------|
+| `pages/common/mp-webview` | 取票、拼 URL、承载 `<web-view>`、接收 H5 回登录指令 | 小程序 token、`WEB_ORIGIN`、业务域名白名单 |
+| `MpWebviewShell` | 无侧栏布局、安全区、401 桥接、禁止桌面壳 | 换票结果、`wx.miniProgram` |
+| `/#/m/pl/plan*` | 运输计划移动端 UI | 现有 `planApi` / composable / 校验 |
+| ticket API | 签发与一次性兑换 | Redis + 当前用户 session |
+
+微信后台 **业务域名** 配置一次:`w.shanxiachem.com`(测试环境另配对应 IP/域名)。开发者工具本地联调使用 `WEB_ORIGIN`(如 `127.0.0.1:3006`),需在开发者工具中关闭/放宽域名校验。
+
+## 5. 登录与 Token
+
+### 5.1 为何不能直接复用 Web 登录
+
+`web-view` 是独立浏览上下文:读不到 `uni.setStorageSync`,H5 的 `localStorage` 初始为空。两端虽都走 `/web/auth/login` 发的同一套 JWT,但 **不会自动共享**。
+
+禁止:H5 弹出管理端登录页;禁止 URL 明文长期挂 `accessToken`。
+
+### 5.2 Ticket 协议
+
+**签发** `POST /api/web/auth/mp-webview-ticket`
+
+- 鉴权:小程序当前 `Authorization: Bearer`
+- 成功:`{ ticket, expiresIn: 60 }`
+- Redis:`mp_wv_ticket:{ticket}` → `{ userId, sessionId, refreshJti? }`,TTL **60 秒**,读后删除(一次性)
+
+**兑换** `POST /api/web/auth/mp-webview-exchange`  body `{ ticket }`
+
+- 无 Bearer(ticket 即凭证)
+- 校验 Redis 后 **复用该用户当前 session** 签发一对新的 access/refresh(或返回与小程序相同 session 下的新 access jti)
+- 成功体与登录接口对齐:`token`、`refreshToken`、`expiresIn`、`userInfo`
+- 失败:401,H5 通知小程序壳 `reLaunch` 登录页
+
+**加密排除**:`mp-webview-exchange` 须加入 `API_ENCRYPT_EXCLUDE_PATHS`(与 login 同类,此时 H5 尚未持有会话密钥)。签发接口走已登录加密通道。
+
+**URL 形态(约定)**
+
+Hash 路由下 ticket 放在 hash 内,避免出现在 Nginx access log 的 path/query:
+
+```text
+https://w.shanxiachem.com/#/m/pl/plan?mpTicket=<ticket>
+```
+
+实现时 H5 启动立刻 `replace` 掉 `mpTicket`,只留干净路由。若必须进一步降低泄露,可将 ticket 仅通过 `wx.miniProgram.postMessage` 不可行(加载前 H5 收不到);因此短 TTL + 一次性是主要防护。
+
+### 5.3 路由守卫(关键)
+
+`/#/m/*` **不能**走现有「未登录 → `/auth/login`」逻辑,也 **不能**在换票后走桌面动态菜单注册(会挂侧栏、WorkTab)。
+
+约定 `meta.isMpWebview: true`:
+
+1. 允许无 `userStore.isLogin` 进入页面
+2. 页面内完成 exchange → `setLoginInfo`(或等价写入 token)
+3. **跳过** `handleDynamicRoutes` 的管理端菜单注入
+4. 401 / ticket 失败 → `wx.miniProgram.reLaunch({ url: '/pages/login/login' })`,永不 `next({ name: 'Login' })`
+
+### 5.4 与「司机/现场免登 token」的区别
+
+`driver_access_token` / `site_access_token` 是单据级公开链接,**禁止**用于本模板。本模板始终是员工登录态。
+
+## 6. 权限与菜单
+
+### 6.1 小程序工作台(入口)
+
+现网「运输计划单」perms = `mini:pl:transportPlan:list`,对应 **现场回传**,保持不变。
+
+新增菜单(名称与管理端对齐,避免再叫「运输计划单」):
+
+| 字段 | 值 |
+|------|-----|
+| menu_name | `运输计划` |
+| path | `mp-webview` 或工作台 code `pl-plan-webview` |
+| perms | `mini:pl:plan:manage` |
+| 父级 | 运输管理(`MINI_PL_ROOT`) |
+| order_num | 排在现有「运输计划单」之后,避免打乱现场人员入口顺序 |
+
+工作台配置:`enabled` + `code` → 打开通用壳 `PAGE_PATHS.mpWebview + '?path=' + encodeURIComponent('/m/pl/plan')`。
+
+### 6.2 H5 / API(能力)
+
+运输计划接口继续校验 **Web 菜单码** `pl:plan:*`(及现有按钮权限),不另写一套后端。
+
+因此:能在小程序里办计划的角色,必须同时拥有:
+
+- 小程序入口 `mini:pl:plan:manage`
+- 与桌面相同的 `pl:plan:list` 等(否则 H5 调接口 403)
+
+无 Web 计划权限时:H5 展示「无权限」空态,不跳桌面 403 页。
+
+## 7. 运输计划移动端(首发业务)
+
+视觉与结构对齐 Demo:`bak/mobile-ui/shanxia-mp-pl-plan-webview-demo.html`。规范:`docs/miniprogram-ui-spec-sanqiang.md`。
+
+| 能力 | 移动端形态 | 逻辑来源 |
+|------|------------|----------|
+| 列表 | 日期快捷 + 状态/单据 Tab + 检索 + 卡片 | `planApi` 列表筛选(`PL_PLAN_LIST_TABS`) |
+| 查看 | 全屏详情 | 现有查看字段/状态 Tag |
+| 新增/编辑 | 全屏分区表单(非桌面弹窗) | `PlPlanFormDialog` 校验与提交,拆成移动分区 |
+| 安排/调整运输 | 全屏安排页 | `usePlPlanArrangeActions` 等 |
+| 复制 | 详情/列表操作 + 确认 | 现有复制 API |
+| 等客户通知 | 同上 | 现有 wait-notice |
+| 作废 | 危险操作确认 | 现有 void |
+| 打印单据 | 预览页(入库单/送提货单/分装单/空白单) | 现有 print API;无系统打印则引导长按存图 |
+| 三方人车快速创建 | 独立全屏页 | 现有 paste-create 逻辑 |
+
+列表卡片主信息:计划编号、单据类型、计划日期、跟进状态 Tag、车牌、人员摘要、产品/供需摘要。点卡片进详情。
+
+**代码复用原则**:抽 composable / 纯函数,避免复制 `index.vue` 整页。桌面弹窗组件可按「移动全屏包装」复用表单子块,不在 WebView 里渲染 `el-dialog` 作为主路径。
+
+建议路由(均在 `/#/m` 下):
+
+```text
+/m/pl/plan              列表
+/m/pl/plan/new          新增
+/m/pl/plan/:id          详情
+/m/pl/plan/:id/edit     编辑
+/m/pl/plan/:id/arrange  安排运输
+/m/pl/plan/paste        三方人车
+/m/pl/plan/:id/print    打印预览
+```
+
+## 8. 后续模块接入清单(模板)
+
+下一个模块只需:
+
+1. `web-admin`:`views/m/<module>/...` + 路由 `meta.isMpWebview`
+2. 套 `MpWebviewShell`
+3. 小程序工作台增加一项:同一 `mp-webview` 壳 + `path=/m/...`
+4. 角色同时具备对应 **mini 入口 perms** 与 **Web 业务 perms**
+5. 微信业务域名已配则 **不必提审**(仅改 H5)
+
+禁止:每个模块再做一个 `web-view` 页面、再做一套登录注入。
+
+## 9. 错误处理
+
+| 情况 | 行为 |
+|------|------|
+| 未登录打开壳 | 小程序拦截,先去登录页 |
+| ticket 过期/已用 | H5 提示后 `reLaunch` 登录或重新进入工作台再开页 |
+| API 401 | 清 H5 token,通知小程序登录 |
+| API 403 | 页内无权限 |
+| 网络失败 | Toast,可重试 |
+| 非小程序打开 `/#/m/*` | 无 ticket 则提示「请从山峡化工小程序打开」,不提供 Web 登录入口(防把移动页当第二套后台) |
+
+## 10. 测试要点
+
+- ticket 60s 过期;第二次 exchange 失败
+- 换票后列表能拉到当前租户计划,数据范围与桌面同一账号一致
+- 全量操作在窄屏可走通(至少:新增草稿、安排、复制、等通知、作废、打开打印预览、三方人车)
+- 401 回到小程序登录,不到 `/#/auth/login`
+- 只发 `web-admin` 静态资源、不发包:卡片文案或样式变更在小程序内可见
+- 现场「运输计划单」原生页回归不受影响
+- 生产 API 加密开启时,exchange 明文、后续业务请求加密均正常
+
+## 11. 实现落点(供计划拆任务,非本次编码)
+
+| 层 | 路径(预期) |
+|----|----------------|
+| 后端 | `backend/app/api/public.py` 增加 ticket/exchange;Redis TTL |
+| H5 壳 | `web-admin/src/layouts/MpWebviewShell.vue`、`src/views/m/**`、守卫分支 |
+| 小程序壳 | `miniprogram/pages/common/mp-webview.vue`、`pages.json`、`config/api.js`、`workbench-menus.js` |
+| 菜单 SQL | `docs/sql/migrations/2026-09-14-mini-pl-plan-webview-menus.sql` |
+| Demo | 已有 `bak/mobile-ui/shanxia-mp-pl-plan-webview-demo.html` |
+
+## 12. 风险
+
+- 运输计划桌面表单极重,移动全屏分区工作量大;模板先行(壳 + ticket + 列表 + 详情),表单可按同一变更内分批提交,但 **范围仍是全量能力**,不把打印/三方人车划出本期。
+- 微信 `web-view` 同一页面只能有一个组件、分享/原生导航受限;打印与文件下载体验弱于桌面。
+- 本地 `WEB_ORIGIN` 不在业务域名内,真机必须指向已配置的测试/生产门户。