Explorar o código

docs: add driver transport history design and HTML demo

Capture the approved design for same-main-plate public driver history
(list by pickup/delivery day, today shortcuts, cross-plan feedback).

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu hai 2 meses
pai
achega
ab46a8c999

+ 657 - 0
docs/demo-driver-transport-history.html

@@ -0,0 +1,657 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+  <meta charset="UTF-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover" />
+  <title>司机链接 · 运输记录 Demo</title>
+  <style>
+    :root {
+      --green: #059669;
+      --green-deep: #047857;
+      --green-soft: #ecfdf5;
+      --text: #1e293b;
+      --muted: #64748b;
+      --line: #e2e8f0;
+      --bg: #f1f5f9;
+      --warn: #d97706;
+      --danger: #dc2626;
+      --card: #fff;
+      --radius: 12px;
+      --safe-bottom: env(safe-area-inset-bottom, 0px);
+    }
+    * { box-sizing: border-box; }
+    body {
+      margin: 0;
+      font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Segoe UI", sans-serif;
+      background: #0f172a;
+      color: var(--text);
+      min-height: 100vh;
+    }
+    .stage {
+      max-width: 1100px;
+      margin: 0 auto;
+      padding: 24px 16px 48px;
+    }
+    .stage-head {
+      color: #e2e8f0;
+      margin-bottom: 20px;
+    }
+    .stage-head h1 {
+      margin: 0 0 8px;
+      font-size: 22px;
+      font-weight: 700;
+    }
+    .stage-head p {
+      margin: 0;
+      color: #94a3b8;
+      font-size: 14px;
+      line-height: 1.6;
+    }
+    .phones {
+      display: grid;
+      grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
+      gap: 20px;
+    }
+    .phone-wrap {
+      background: #1e293b;
+      border-radius: 20px;
+      padding: 12px;
+      border: 1px solid #334155;
+    }
+    .phone-label {
+      color: #cbd5e1;
+      font-size: 13px;
+      font-weight: 600;
+      margin: 0 0 10px 4px;
+    }
+    .phone {
+      width: 100%;
+      max-width: 390px;
+      margin: 0 auto;
+      background: var(--bg);
+      border-radius: 18px;
+      overflow: hidden;
+      min-height: 720px;
+      display: flex;
+      flex-direction: column;
+      box-shadow: 0 20px 40px rgba(0,0,0,.35);
+      position: relative;
+    }
+    .nav {
+      background: var(--green);
+      color: #fff;
+      height: 48px;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      font-size: 16px;
+      font-weight: 600;
+      position: relative;
+      flex-shrink: 0;
+    }
+    .nav .back {
+      position: absolute;
+      left: 12px;
+      top: 50%;
+      transform: translateY(-50%);
+      background: transparent;
+      border: 0;
+      color: #fff;
+      font-size: 14px;
+      cursor: pointer;
+      padding: 8px;
+    }
+    .scroll {
+      flex: 1;
+      overflow: auto;
+      padding: 12px 12px 96px;
+    }
+    .scroll.no-footer-pad { padding-bottom: 12px; }
+    .card {
+      background: var(--card);
+      border-radius: var(--radius);
+      margin-bottom: 12px;
+      overflow: hidden;
+      border: 1px solid #eef2f7;
+    }
+    .card-title {
+      padding: 10px 14px 0;
+      font-size: 13px;
+      color: var(--muted);
+      font-weight: 600;
+    }
+    .row {
+      display: flex;
+      justify-content: space-between;
+      gap: 12px;
+      padding: 12px 14px;
+      border-bottom: 1px solid #f1f5f9;
+      font-size: 14px;
+    }
+    .row:last-child { border-bottom: 0; }
+    .row .k { color: var(--muted); flex-shrink: 0; }
+    .row .v { color: var(--text); text-align: right; word-break: break-all; }
+    .empty {
+      padding: 64px 24px;
+      text-align: center;
+      color: var(--muted);
+    }
+    .empty .icon {
+      width: 64px;
+      height: 64px;
+      margin: 0 auto 16px;
+      border-radius: 50%;
+      background: #e2e8f0;
+      display: grid;
+      place-items: center;
+      font-size: 28px;
+      color: #94a3b8;
+    }
+    .empty .title {
+      font-size: 15px;
+      color: #475569;
+      margin-bottom: 6px;
+    }
+    .footer {
+      position: absolute;
+      left: 0;
+      right: 0;
+      bottom: 0;
+      padding: 10px 12px calc(10px + var(--safe-bottom));
+      background: rgba(255,255,255,.96);
+      border-top: 1px solid var(--line);
+      display: flex;
+      flex-direction: column;
+      gap: 8px;
+      backdrop-filter: blur(8px);
+    }
+    .btn {
+      height: 44px;
+      border-radius: 999px;
+      border: 0;
+      font-size: 15px;
+      font-weight: 600;
+      cursor: pointer;
+      display: inline-flex;
+      align-items: center;
+      justify-content: center;
+      gap: 6px;
+    }
+    .btn-primary {
+      background: var(--green);
+      color: #fff;
+    }
+    .btn-primary:active { background: var(--green-deep); }
+    .btn-ghost {
+      background: #fff;
+      color: var(--green);
+      border: 1px solid #a7f3d0;
+    }
+    .btn-ghost:active { background: var(--green-soft); }
+    .btn-row {
+      display: grid;
+      grid-template-columns: 1fr 1fr;
+      gap: 8px;
+    }
+    .filters {
+      background: var(--card);
+      border-radius: var(--radius);
+      padding: 12px;
+      margin-bottom: 12px;
+      border: 1px solid #eef2f7;
+    }
+    .filter-row {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+      margin-bottom: 10px;
+      flex-wrap: wrap;
+    }
+    .filter-row:last-child { margin-bottom: 0; }
+    .chip {
+      display: inline-flex;
+      align-items: center;
+      gap: 6px;
+      height: 32px;
+      padding: 0 12px;
+      border-radius: 999px;
+      border: 1px solid var(--line);
+      background: #fff;
+      font-size: 13px;
+      color: var(--text);
+      cursor: pointer;
+      user-select: none;
+    }
+    .chip.on {
+      background: var(--green-soft);
+      border-color: #6ee7b7;
+      color: var(--green-deep);
+      font-weight: 600;
+    }
+    .chip input { display: none; }
+    .date-inputs {
+      display: grid;
+      grid-template-columns: 1fr auto 1fr;
+      gap: 8px;
+      align-items: center;
+      width: 100%;
+    }
+    .date-inputs input {
+      height: 36px;
+      border: 1px solid var(--line);
+      border-radius: 8px;
+      padding: 0 10px;
+      font-size: 13px;
+      color: var(--text);
+      background: #fff;
+      width: 100%;
+    }
+    .date-inputs span { color: var(--muted); font-size: 12px; }
+    .hint {
+      font-size: 12px;
+      color: var(--muted);
+      margin-top: 8px;
+    }
+    .record {
+      background: var(--card);
+      border-radius: var(--radius);
+      border: 1px solid #eef2f7;
+      padding: 14px;
+      margin-bottom: 10px;
+      cursor: pointer;
+      transition: border-color .15s, box-shadow .15s;
+    }
+    .record:active, .record:hover {
+      border-color: #6ee7b7;
+      box-shadow: 0 0 0 3px rgba(16,185,129,.12);
+    }
+    .record-top {
+      display: flex;
+      justify-content: space-between;
+      gap: 8px;
+      align-items: flex-start;
+      margin-bottom: 8px;
+    }
+    .record-no {
+      font-size: 15px;
+      font-weight: 700;
+    }
+    .tag {
+      display: inline-flex;
+      align-items: center;
+      height: 22px;
+      padding: 0 8px;
+      border-radius: 6px;
+      font-size: 12px;
+      font-weight: 600;
+      color: #fff;
+      flex-shrink: 0;
+    }
+    .tag.ok { background: var(--green); }
+    .tag.arch { background: #64748b; }
+    .tag.void { background: var(--danger); }
+    .tag.warn { background: var(--warn); }
+    .record-meta {
+      font-size: 13px;
+      color: var(--muted);
+      line-height: 1.55;
+    }
+    .record-meta strong { color: var(--text); font-weight: 500; }
+    .mini-tabs {
+      display: flex;
+      gap: 8px;
+      margin-bottom: 12px;
+      flex-wrap: wrap;
+    }
+    .mini-tabs button {
+      border: 1px solid #334155;
+      background: #0f172a;
+      color: #94a3b8;
+      border-radius: 999px;
+      padding: 8px 12px;
+      font-size: 12px;
+      cursor: pointer;
+    }
+    .mini-tabs button.active {
+      background: var(--green);
+      border-color: var(--green);
+      color: #fff;
+      font-weight: 600;
+    }
+    .note {
+      margin-top: 18px;
+      padding: 14px 16px;
+      background: #1e293b;
+      border: 1px solid #334155;
+      border-radius: 12px;
+      color: #cbd5e1;
+      font-size: 13px;
+      line-height: 1.7;
+    }
+    .note strong { color: #f8fafc; }
+    .screen { display: none; flex: 1; flex-direction: column; min-height: 0; }
+    .screen.active { display: flex; }
+    .product {
+      padding: 12px 14px;
+      border-top: 1px solid #f1f5f9;
+    }
+    .product-name { font-weight: 600; margin-bottom: 6px; }
+    .product-meta { font-size: 13px; color: var(--muted); }
+  </style>
+</head>
+<body>
+  <div class="stage">
+    <div class="stage-head">
+      <h1>司机链接 ·「运输记录」交互 Demo</h1>
+      <p>
+        列表默认<strong>今天</strong>。筛选为:单日日期框 +「今天 / 明天 / 后天」快捷按钮。
+        命中:取货日或送货日等于所选日。按<strong>主车车牌</strong>聚合。
+      </p>
+      <div class="mini-tabs" id="jumpTabs">
+        <button type="button" class="active" data-jump="A">A · 正常详情</button>
+        <button type="button" data-jump="B">B · 已归档详情</button>
+        <button type="button" data-jump="C">C · 运输记录列表</button>
+        <button type="button" data-jump="D">D · 作废详情</button>
+      </div>
+    </div>
+
+    <div class="phones">
+      <!-- Phone interactive -->
+      <div class="phone-wrap">
+        <div class="phone-label">可交互 Demo(点底部按钮 / 点记录)</div>
+        <div class="phone" id="phone">
+          <!-- Screen: active detail -->
+          <div class="screen active" data-screen="detail-active">
+            <div class="nav">运输计划</div>
+            <div class="scroll">
+              <div class="card">
+                <div class="row"><span class="k">计划编号</span><span class="v">YS20260821001</span></div>
+                <div class="row"><span class="k">计划日期</span><span class="v">2026-08-21</span></div>
+                <div class="row"><span class="k">车牌</span><span class="v">苏A·D12345</span></div>
+                <div class="row"><span class="k">司机</span><span class="v">张师傅</span></div>
+              </div>
+              <div class="card">
+                <div class="card-title">取货信息</div>
+                <div class="row"><span class="k">取货地址</span><span class="v">南京市江宁区××仓库</span></div>
+                <div class="row"><span class="k">取货时间</span><span class="v">2026-08-21 09:00</span></div>
+              </div>
+              <div class="card">
+                <div class="card-title">产品回传数量</div>
+                <div class="product">
+                  <div class="product-name">硫酸</div>
+                  <div class="product-meta">计划 28 吨 · 实际待填</div>
+                </div>
+              </div>
+            </div>
+            <div class="footer">
+              <div class="btn-row">
+                <button class="btn btn-ghost" type="button" data-go="list">运输记录</button>
+                <button class="btn btn-primary" type="button">保存回执</button>
+              </div>
+            </div>
+          </div>
+
+          <!-- Screen: archived detail (from link or list) -->
+          <div class="screen" data-screen="detail-archived">
+            <div class="nav">运输计划</div>
+            <div class="scroll no-footer-pad">
+              <div class="empty">
+                <div class="icon">📦</div>
+                <div class="title">运输计划已归档完成</div>
+                <div>业务详情不可查看</div>
+              </div>
+            </div>
+            <div class="footer">
+              <button class="btn btn-ghost" type="button" data-go="list">运输记录</button>
+            </div>
+          </div>
+
+          <!-- Screen: voided detail -->
+          <div class="screen" data-screen="detail-voided">
+            <div class="nav">运输计划</div>
+            <div class="scroll no-footer-pad">
+              <div class="empty">
+                <div class="icon">⛔</div>
+                <div class="title">运输计划已作废</div>
+                <div>业务详情不可查看</div>
+              </div>
+            </div>
+            <div class="footer">
+              <button class="btn btn-ghost" type="button" data-go="list">运输记录</button>
+            </div>
+          </div>
+
+          <!-- Screen: history list -->
+          <div class="screen" data-screen="list">
+            <div class="nav">
+              <button class="back" type="button" data-go="detail-active">‹ 返回</button>
+              运输记录
+            </div>
+            <div class="scroll" style="padding-bottom:16px">
+              <div class="filters">
+                <div class="filter-row" style="gap:8px">
+                  <button type="button" class="chip on" data-quick="0">今天</button>
+                  <button type="button" class="chip" data-quick="1">明天</button>
+                  <button type="button" class="chip" data-quick="2">后天</button>
+                </div>
+                <div class="filter-row">
+                  <input type="date" id="pickDate" value="2026-08-21" style="height:36px;border:1px solid var(--line);border-radius:8px;padding:0 10px;font-size:13px;width:100%;background:#fff" />
+                </div>
+                <div class="hint" id="filterHint">当前:今天 2026-08-21 · 取货或送货命中</div>
+              </div>
+
+              <div id="recordList"></div>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <!-- Static reference: footer always -->
+      <div class="phone-wrap">
+        <div class="phone-label">对照:任意状态底部都有「运输记录」</div>
+        <div class="phone">
+          <div class="nav">运输计划</div>
+          <div class="scroll no-footer-pad">
+            <div class="empty">
+              <div class="icon">📦</div>
+              <div class="title">运输计划已归档完成</div>
+              <div>与现状一致,详情不展示</div>
+            </div>
+          </div>
+          <div class="footer">
+            <button class="btn btn-ghost" type="button">运输记录</button>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <div class="note">
+      <strong>Demo 说明(已确认部分):</strong><br />
+      1)「运输记录」按<strong>主车车牌号</strong>聚合历史与后续安排。<br />
+      2)任意状态底部仅保留「运输记录」;不再提供「返回列表」。<br />
+      3)筛选:单日日期框 +「今天/明天/后天」快捷;<strong>默认今天</strong>。<br />
+      4)命中:取货日或送货日<strong>任一等于</strong>所选日。
+    </div>
+  </div>
+
+  <script>
+    const records = [
+      {
+        id: "r1",
+        planNo: "YS20260821001",
+        pickupDate: "2026-08-21",
+        deliveryDate: "2026-08-21",
+        route: "江宁仓库 → 镇江厂区",
+        status: "arranged",
+        statusLabel: "进行中",
+        tag: "ok",
+        screen: "detail-active"
+      },
+      {
+        id: "r2",
+        planNo: "YS20260821002",
+        pickupDate: "2026-08-21",
+        deliveryDate: "2026-08-22",
+        route: "仪征码头 → 南京港",
+        status: "arranged",
+        statusLabel: "进行中",
+        tag: "ok",
+        screen: "detail-active"
+      },
+      {
+        id: "r3",
+        planNo: "YS20260818008",
+        pickupDate: "2026-08-18",
+        deliveryDate: "2026-08-18",
+        route: "徐州仓 → 宿迁站",
+        status: "archived",
+        statusLabel: "已归档",
+        tag: "arch",
+        screen: "detail-archived"
+      },
+      {
+        id: "r4",
+        planNo: "YS20260815003",
+        pickupDate: "2026-08-15",
+        deliveryDate: "2026-08-16",
+        route: "盐城库 → 南通厂",
+        status: "voided",
+        statusLabel: "已作废",
+        tag: "void",
+        screen: "detail-voided"
+      },
+      {
+        id: "r5",
+        planNo: "YS20260822001",
+        pickupDate: "2026-08-22",
+        deliveryDate: "2026-08-22",
+        route: "连云港 → 淮安仓",
+        status: "arranged",
+        statusLabel: "后续安排",
+        tag: "warn",
+        screen: "detail-active"
+      }
+    ];
+
+    const todayBase = new Date(2026, 7, 21); // demo fixed "today": 2026-08-21
+
+    function fmt(d) {
+      const y = d.getFullYear();
+      const m = String(d.getMonth() + 1).padStart(2, "0");
+      const day = String(d.getDate()).padStart(2, "0");
+      return `${y}-${m}-${day}`;
+    }
+
+    function addDays(base, n) {
+      const d = new Date(base.getTime());
+      d.setDate(d.getDate() + n);
+      return d;
+    }
+
+    const today = fmt(todayBase);
+    const phone = document.getElementById("phone");
+    const listEl = document.getElementById("recordList");
+    const pickDate = document.getElementById("pickDate");
+    const filterHint = document.getElementById("filterHint");
+    const quickBtns = document.querySelectorAll("[data-quick]");
+
+    pickDate.value = today;
+
+    function showScreen(name) {
+      phone.querySelectorAll(".screen").forEach((el) => {
+        el.classList.toggle("active", el.dataset.screen === name);
+      });
+      document.querySelectorAll("#jumpTabs button").forEach((btn) => {
+        const map = {
+          A: "detail-active",
+          B: "detail-archived",
+          C: "list",
+          D: "detail-voided"
+        };
+        btn.classList.toggle("active", map[btn.dataset.jump] === name);
+      });
+    }
+
+    function matchesDate(r, day) {
+      return r.pickupDate === day || r.deliveryDate === day;
+    }
+
+    function syncQuickActive(day) {
+      quickBtns.forEach((btn) => {
+        const offset = Number(btn.dataset.quick);
+        const target = fmt(addDays(todayBase, offset));
+        btn.classList.toggle("on", target === day);
+      });
+    }
+
+    function labelFor(day) {
+      if (day === fmt(addDays(todayBase, 0))) return `今天 ${day}`;
+      if (day === fmt(addDays(todayBase, 1))) return `明天 ${day}`;
+      if (day === fmt(addDays(todayBase, 2))) return `后天 ${day}`;
+      return day;
+    }
+
+    function renderList() {
+      const day = (pickDate.value || today).trim();
+      syncQuickActive(day);
+      const filtered = records.filter((r) => matchesDate(r, day));
+      filterHint.textContent = `当前:${labelFor(day)} · 取货或送货命中 · 共 ${filtered.length} 条`;
+
+      if (!filtered.length) {
+        listEl.innerHTML = `<div class="empty"><div class="icon">🗂️</div><div class="title">暂无运输记录</div><div>可点「明天/后天」或另选日期</div></div>`;
+        return;
+      }
+
+      listEl.innerHTML = filtered
+        .map(
+          (r) => `
+        <div class="record" data-screen="${r.screen}" role="button" tabindex="0">
+          <div class="record-top">
+            <div class="record-no">${r.planNo}</div>
+            <span class="tag ${r.tag}">${r.statusLabel}</span>
+          </div>
+          <div class="record-meta">
+            <div>取货:<strong>${r.pickupDate}</strong> · 送货:<strong>${r.deliveryDate}</strong></div>
+            <div>线路:${r.route}</div>
+          </div>
+        </div>`
+        )
+        .join("");
+
+      listEl.querySelectorAll(".record").forEach((el) => {
+        el.addEventListener("click", () => showScreen(el.dataset.screen));
+      });
+    }
+
+    phone.addEventListener("click", (e) => {
+      const btn = e.target.closest("[data-go]");
+      if (!btn) return;
+      showScreen(btn.dataset.go);
+      if (btn.dataset.go === "list") renderList();
+    });
+
+    quickBtns.forEach((btn) => {
+      btn.addEventListener("click", () => {
+        pickDate.value = fmt(addDays(todayBase, Number(btn.dataset.quick)));
+        renderList();
+      });
+    });
+    pickDate.addEventListener("change", renderList);
+
+    document.getElementById("jumpTabs").addEventListener("click", (e) => {
+      const btn = e.target.closest("button[data-jump]");
+      if (!btn) return;
+      const map = {
+        A: "detail-active",
+        B: "detail-archived",
+        C: "list",
+        D: "detail-voided"
+      };
+      showScreen(map[btn.dataset.jump]);
+      if (btn.dataset.jump === "C") renderList();
+    });
+
+    renderList();
+  </script>
+</body>
+</html>

+ 176 - 0
docs/superpowers/specs/2026-08-21-pl-driver-transport-history-design.md

@@ -0,0 +1,176 @@
+# 司机链接 · 运输记录(同主车车牌)设计
+
+**日期:** 2026-08-21  
+**状态:** 待实现  
+**Demo:** `docs/demo-driver-transport-history.html`  
+**推荐方案:** 入口 token 鉴权 + 按主车车牌列历史 + 详情跳转目标单自有 token(方案 1)
+
+## 1. 背景与目标
+
+当前司机免登录页(`/#/public/pl/transport/:token`)仅绑定**单次运输单**。现场希望同一辆车(主车号)能查看历史与后续安排的计划,并在进行中计划间互通填写回执。
+
+**目标:**
+
+- 每个司机详情页(含归档/作废空状态)底部提供「运输记录」入口。
+- 列表按**主车车牌号**(`license_plate`,不含挂车号)聚合同租户记录。
+- 默认展示**今天**命中的记录;提供「今天 / 明天 / 后天」快捷 + 单日日期选择。
+- 点击记录进入对应司机详情;进行中可填写/保存;归档/作废与现状一致(仅空状态文案)。
+- 不单独提供「返回列表」——统一用「运输记录」进出列表。
+
+## 2. 已确认需求
+
+| 项 | 结论 |
+|---|---|
+| 聚合维度 | 主车车牌号(`license_plate` / 主车号),不含 `trailer_no` |
+| 互通操作 | 持有任一有效司机链接,可打开并**填写/保存**同车牌其他进行中计划回执 |
+| 日期命中 | `DATE(pickup_time) = 所选日` **OR** `DATE(delivery_time) = 所选日` |
+| 筛选 UI | 单日日期框 + 今天/明天/后天;**默认今天**(不再提供「全部记录」默认态) |
+| 底部按钮 | 仅「运输记录」;取消原设想的「返回列表」 |
+| 归档/作废 | 详情仍只显示「运输计划已归档完成」/「运输计划已作废」;底部仍有「运输记录」 |
+
+## 3. 非目标
+
+- 不引入车辆级长期 token,不改造安排运输时的 token 签发模型(除列表返回既有 `driver_access_token`)。
+- 不改现场链接(入库/出库公共页)行为。
+- 不做管理端(web-admin 登录态)运输记录页;本能力仅司机免登录 H5。
+- 不按挂车号、司机人员 ID 聚合。
+
+## 4. 架构(方案 1)
+
+```text
+司机详情 token_A
+    │
+    ├─「运输记录」→ /public/pl/transport/{token_A}/history?date=今天
+    │                      │
+    │                      └─ GET /api/public/pl/transport/{token_A}/history?date=
+    │                            → 解析 token_A → tenant + license_plate
+    │                            → 同车牌计划列表(含各单 driverAccessToken)
+    │
+    └─ 点击记录 B → /#/public/pl/transport/{token_B}
+                         → 现有 GET/POST feedback(token_B)
+```
+
+**安全边界:** 持有有效司机链接即视为可操作该主车车牌下同租户运输回执。列表会返回同车牌其他单的 `driverAccessToken`;与「同车牌互通填写」产品结论一致。调整运输轮换旧 token 后,旧链接与其 history 入口一并失效(与现状一致)。
+
+## 5. 后端设计
+
+### 5.1 新增接口
+
+`GET /api/public/pl/transport/{token}/history?date=YYYY-MM-DD`
+
+- **鉴权:** 与现有公共司机 API 相同,按 `PlTransport.driver_access_token` 查找;无效 → 404。
+- **`date`:** 必填;非法格式 → 400。
+- **解析:** 入口运输单 → `tenant_no`、主车 `license_plate`(trim)。车牌为空 → 返回空列表 `records: []`(不报错,避免脏数据阻断入口)。
+- **查询条件(同时满足):**
+  - `tenant_no` = 入口单租户
+  - 计划 / 运输未删除(`del_flag = 0`)
+  - 计划已安排运输(如 `PlPlan.transport_status = 'arranged'`,且关联有效 `PlTransport`)
+  - 运输单(或计划快照)主车车牌 = 入口主车车牌(建议以 `PlTransport.license_plate` 为准,空则回退 `PlPlan.license_plate`)
+  - `DATE(plan.pickup_time) = date OR DATE(plan.delivery_time) = date`  
+    (两端皆空则不命中)
+- **排序:** `pickup_time DESC`,空值靠后;再按 `plan_date` / `create_time` DESC。
+- **入口计划已归档/作废:** 仍允许调用本接口(详情页底部按钮始终可用)。
+
+### 5.2 响应字段(camelCase)
+
+列表包装建议:
+
+```json
+{
+  "licensePlate": "苏AD12345",
+  "date": "2026-08-21",
+  "records": [
+    {
+      "planId": "...",
+      "planNo": "YS...",
+      "transportId": "...",
+      "driverAccessToken": "...",
+      "driverUrl": "https://.../#/public/pl/transport/...",
+      "licensePlate": "苏AD12345",
+      "pickupTime": "2026-08-21T09:00:00",
+      "deliveryTime": "2026-08-21T18:00:00",
+      "pickupAddress": "...",
+      "destinationAddress": "...",
+      "planVoided": false,
+      "planArchived": false
+    }
+  ]
+}
+```
+
+- `planVoided` / `planArchived`:复用现有 `_resolve_driver_link_flags` 语义(计划作废/运输取消 → voided;计划归档 → archived)。
+- `statusLabel` 可由前端根据上述 flag 计算:已作废 > 已归档 > 进行中;也可后端直接返回,二选一,实现时保持单一来源。
+
+### 5.3 不变接口
+
+- `GET /api/public/pl/transport/{token}`
+- `POST /api/public/pl/transport/{token}/feedback`
+- `POST /api/public/pl/transport/{token}/attachments`
+
+互通填写**不**扩展「入口 token + targetId」双参;一律跳转目标单自有 token。
+
+### 5.4 实现落点(建议)
+
+- 路由:`backend/app/api/pl/public_transport.py`
+- Schema:`backend/app/schemas/pl/transport.py`(新增 History 读模型)
+- 车牌规范化:trim;比较前可统一去除内部空格(若存量有空格差异,实现时与列表筛车牌逻辑对齐)
+
+## 6. 前端设计
+
+### 6.1 路由
+
+| 路径 | 说明 |
+|---|---|
+| `/public/pl/transport/:token` | 现有司机详情(改底部栏) |
+| `/public/pl/transport/:token/history` | 新增运输记录列表;`token` 为**进入列表时**所用司机 token |
+
+静态路由注册于 `web-admin/src/router/routes/staticRoutes.ts`。
+
+### 6.2 详情页
+
+文件:`web-admin/src/views/public/pl/transport/index.vue`
+
+- 底部固定「运输记录」:`router.push({ name/path: history, params: { token } })`。
+- **可编辑:** 「运输记录」+「保存回执」并排。
+- **`planVoided` / `planArchived`:** 仅空状态 +「运输记录」。
+- **链接无效 error:** 不展示「运输记录」(无有效 token 可查)。
+
+### 6.3 列表页(新)
+
+- Shell:`PublicMobileShell`;风格对齐现有公共运输页(绿顶栏、卡片、圆角底栏)。
+- 筛选:快捷「今天」「明天」「后天」+ `<input type="date">`(或 Vant 等价);进入默认今天并立即请求。
+- 卡片:计划号、状态 Tag、取货/送货日、线路(取货地址 → 送货地址摘要)。
+- 点击:`router.push(/public/pl/transport/${item.driverAccessToken})`。
+- 顶栏返回:回到 `/public/pl/transport/${列表路由上的 token}`(即进入列表时的那一单)。
+
+### 6.4 API 封装
+
+`web-admin/src/api/pl/public-transport.ts` 增加:
+
+`getHistory(token: string, date: string)` → `GET /public/pl/transport/${token}/history?date=`
+
+## 7. 错误与边界
+
+| 场景 | 行为 |
+|---|---|
+| token 无效/已轮换 | 404;详情与 history 均不可用 |
+| 车牌为空 | history 返回空列表 |
+| 所选日无命中 | 空状态「暂无运输记录」 |
+| 取送货时间皆空 | 该行永不被任何 date 命中 |
+| 同主车不同挂车 | 仍聚合(只比主车号) |
+| 跨租户同车牌 | 不聚合(强制入口 `tenant_no`) |
+
+## 8. 测试要点
+
+1. 默认今天;快捷明天/后天;手选其他日;命中取货日或送货日任一等于所选日。
+2. 同主车多计划:从 A 链进列表再进 B,可保存 B 回执。
+3. 归档/作废详情:空状态 +「运输记录」可进列表。
+4. 调整运输轮换 token 后旧链接 404。
+5. 主车不同不出现;仅挂车不同仍出现。
+6. 入口单已归档仍可打开 history。
+
+## 9. 参考
+
+- 现有公共 API:`backend/app/api/pl/public_transport.py`
+- 现有详情页:`web-admin/src/views/public/pl/transport/index.vue`
+- 交互 Demo:`docs/demo-driver-transport-history.html`