Jelajahi Sumber

docs: add vehicle/personnel photo field design and UI demo

Capture the approved photo_urls approach (list thumbnail A, optional upload) for pl_vehicle and pl_personnel before implementation.

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 2 bulan lalu
induk
melakukan
9af37c3f56

+ 1 - 0
.gitignore

@@ -84,3 +84,4 @@ exports/
 
 # uni-app 编译产物
 miniprogram/unpackage/
+.superpowers/

+ 759 - 0
docs/fresh-mint-pl-vehicle-personnel-photo-demo.html

@@ -0,0 +1,759 @@
+<!doctype html>
+<html lang="zh-CN">
+<head>
+  <meta charset="UTF-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+  <title>Fresh Mint · 车辆/驾押人员照片字段 Demo</title>
+  <link rel="preconnect" href="https://fonts.googleapis.com" />
+  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
+  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
+  <style>
+    :root {
+      --primary: #10b981;
+      --primary-dark: #059669;
+      --primary-bg: #ecfdf5;
+      --text: #1e293b;
+      --text-muted: #64748b;
+      --border: #d9e8e2;
+      --surface: #ffffff;
+      --table-head: #f8fafc;
+      --bg-page: #f8fafc;
+      --shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
+      --radius: 10px;
+      --danger: #ef4444;
+    }
+
+    * { box-sizing: border-box; }
+    body {
+      margin: 0;
+      min-height: 100vh;
+      font-family: Inter, -apple-system, BlinkMacSystemFont, sans-serif;
+      font-size: 14px;
+      line-height: 1.5;
+      color: var(--text);
+      background: var(--bg-page);
+    }
+
+    .demo-page { max-width: 1180px; margin: 0 auto; padding: 32px 24px 96px; }
+    .demo-head { margin-bottom: 28px; }
+    .demo-head h1 {
+      margin: 0 0 8px;
+      font-size: 24px;
+      font-weight: 700;
+      letter-spacing: -0.02em;
+    }
+    .demo-head h1 span { color: var(--primary); }
+    .demo-head p { margin: 0; color: var(--text-muted); max-width: 820px; }
+
+    .toc {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 8px;
+      margin: 20px 0 0;
+    }
+    .toc a {
+      display: inline-flex;
+      align-items: center;
+      height: 32px;
+      padding: 0 12px;
+      border-radius: 8px;
+      border: 1px solid var(--border);
+      background: var(--surface);
+      color: var(--text);
+      text-decoration: none;
+      font-size: 13px;
+      font-weight: 500;
+    }
+    .toc a:hover,
+    .toc a.rec {
+      border-color: var(--primary);
+      color: var(--primary-dark);
+      background: var(--primary-bg);
+    }
+
+    .section { margin-top: 40px; }
+    .section-head { margin-bottom: 14px; }
+    .section-head h2 {
+      margin: 0 0 4px;
+      font-size: 18px;
+      font-weight: 700;
+    }
+    .section-head p { margin: 0; color: var(--text-muted); font-size: 13px; max-width: 760px; }
+
+    .badge {
+      display: inline-flex;
+      align-items: center;
+      height: 24px;
+      padding: 0 10px;
+      border-radius: 999px;
+      font-size: 12px;
+      font-weight: 600;
+      background: var(--primary-bg);
+      color: var(--primary-dark);
+      margin-left: 8px;
+      vertical-align: middle;
+    }
+
+    .panel {
+      background: var(--surface);
+      border: 1px solid var(--border);
+      border-radius: var(--radius);
+      box-shadow: var(--shadow);
+      overflow: hidden;
+    }
+    .panel-top {
+      padding: 16px 20px 12px;
+      border-bottom: 1px solid var(--border);
+    }
+    .title-bar-heading {
+      margin: 0;
+      font-size: 16px;
+      font-weight: 700;
+      display: flex;
+      align-items: center;
+      gap: 8px;
+    }
+    .title-bar-heading::before {
+      content: "";
+      width: 3px;
+      height: 16px;
+      border-radius: 2px;
+      background: var(--primary);
+    }
+
+    .list-toolbar {
+      padding: 12px 20px;
+      display: flex;
+      gap: 8px;
+      align-items: center;
+      border-bottom: 1px solid var(--border);
+    }
+    .btn {
+      display: inline-flex;
+      align-items: center;
+      gap: 6px;
+      height: 36px;
+      padding: 0 14px;
+      border-radius: 8px;
+      font-size: 14px;
+      font-weight: 500;
+      border: 1px solid var(--border);
+      background: #fff;
+      color: var(--text);
+      cursor: default;
+    }
+    .btn-main {
+      background: var(--primary);
+      border-color: var(--primary);
+      color: #fff;
+    }
+    .btn-ghost { background: #fff; }
+
+    table.list-table {
+      width: 100%;
+      border-collapse: collapse;
+    }
+    table.list-table th,
+    table.list-table td {
+      padding: 12px 14px;
+      border-bottom: 1px solid #eef2f7;
+      text-align: center;
+      font-size: 13px;
+    }
+    table.list-table th {
+      background: var(--table-head);
+      color: var(--text-muted);
+      font-weight: 600;
+      font-size: 12px;
+    }
+    table.list-table tr:nth-child(even) td { background: #fbfdff; }
+    .link { color: var(--primary-dark); font-weight: 600; text-decoration: none; }
+    .tag {
+      display: inline-flex;
+      align-items: center;
+      height: 22px;
+      padding: 0 8px;
+      border-radius: 4px;
+      font-size: 12px;
+      font-weight: 600;
+      background: var(--primary-bg);
+      color: var(--primary-dark);
+    }
+    .tag.muted { background: #f1f5f9; color: var(--text-muted); }
+
+    /* 方案 A:列表缩略图 */
+    .photo-thumb {
+      width: 48px;
+      height: 48px;
+      border-radius: 8px;
+      object-fit: cover;
+      border: 1px solid var(--border);
+      display: inline-block;
+      vertical-align: middle;
+      background: #f1f5f9;
+    }
+    .photo-thumb.placeholder {
+      display: inline-flex;
+      align-items: center;
+      justify-content: center;
+      color: #94a3b8;
+      font-size: 11px;
+      background: #f8fafc;
+    }
+
+    /* 方案 B:附件卡片紧凑态 */
+    .attach-compact {
+      width: 56px;
+      height: 40px;
+      border-radius: 6px;
+      object-fit: cover;
+      border: 1px solid var(--border);
+      display: inline-block;
+      vertical-align: middle;
+    }
+
+    .compare {
+      display: grid;
+      grid-template-columns: 1fr 1fr;
+      gap: 16px;
+    }
+    @media (max-width: 900px) {
+      .compare { grid-template-columns: 1fr; }
+    }
+    .option-card {
+      border: 2px solid var(--border);
+      border-radius: var(--radius);
+      background: var(--surface);
+      overflow: hidden;
+      cursor: pointer;
+      transition: border-color 0.15s, box-shadow 0.15s;
+    }
+    .option-card:hover { border-color: #6ee7b7; }
+    .option-card.selected {
+      border-color: var(--primary);
+      box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15);
+    }
+    .option-card-head {
+      padding: 12px 16px;
+      border-bottom: 1px solid var(--border);
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      gap: 8px;
+    }
+    .option-card-head h3 { margin: 0; font-size: 15px; }
+    .option-card-head p { margin: 2px 0 0; font-size: 12px; color: var(--text-muted); }
+    .option-card-body { padding: 16px; background: #fbfdff; }
+    .pick {
+      width: 18px;
+      height: 18px;
+      border-radius: 50%;
+      border: 2px solid var(--border);
+      flex-shrink: 0;
+      position: relative;
+    }
+    .option-card.selected .pick {
+      border-color: var(--primary);
+      background: var(--primary);
+    }
+    .option-card.selected .pick::after {
+      content: "";
+      position: absolute;
+      left: 5px;
+      top: 2px;
+      width: 4px;
+      height: 8px;
+      border: solid #fff;
+      border-width: 0 2px 2px 0;
+      transform: rotate(45deg);
+    }
+
+    /* 弹窗表单 */
+    .dialog-mask {
+      border: 1px solid var(--border);
+      border-radius: var(--radius);
+      background: rgba(15, 23, 42, 0.28);
+      padding: 28px 20px;
+      display: flex;
+      justify-content: center;
+    }
+    .dialog {
+      width: min(720px, 100%);
+      background: #fff;
+      border-radius: 10px;
+      box-shadow: 0 20px 40px rgba(15, 23, 42, 0.18);
+      overflow: hidden;
+    }
+    .dialog-header {
+      padding: 16px 20px;
+      border-bottom: 1px solid var(--border);
+      font-weight: 700;
+      font-size: 16px;
+    }
+    .dialog-body { padding: 20px; }
+    .form-grid-2 {
+      display: grid;
+      grid-template-columns: 1fr 1fr;
+      gap: 16px;
+    }
+    .form-item label {
+      display: block;
+      font-size: 13px;
+      font-weight: 600;
+      margin-bottom: 6px;
+      color: var(--text);
+    }
+    .form-item .control {
+      height: 36px;
+      border: 1px solid var(--border);
+      border-radius: 8px;
+      background: #fff;
+      padding: 0 12px;
+      display: flex;
+      align-items: center;
+      color: var(--text-muted);
+      font-size: 13px;
+    }
+    .form-col-span-2 { grid-column: 1 / -1; }
+    .form-tip {
+      margin-top: 6px;
+      font-size: 12px;
+      color: var(--text-muted);
+    }
+
+    .upload-row {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 12px;
+      align-items: flex-start;
+    }
+    .upload-card {
+      width: 140px;
+      height: 96px;
+      border: 1px dashed #a7f3d0;
+      border-radius: 8px;
+      background: #f8fffb;
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      justify-content: center;
+      gap: 6px;
+      color: var(--primary-dark);
+      font-size: 13px;
+      font-weight: 500;
+    }
+    .upload-card .plus {
+      width: 28px;
+      height: 28px;
+      border-radius: 50%;
+      background: var(--primary-bg);
+      color: var(--primary);
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      font-size: 20px;
+      line-height: 1;
+    }
+    .attachment-item {
+      width: 140px;
+      border: 1px solid var(--border);
+      border-radius: 8px;
+      overflow: hidden;
+      background: #fff;
+    }
+    .attachment-preview {
+      height: 96px;
+      background: #f1f5f9;
+      position: relative;
+    }
+    .attachment-preview img {
+      width: 100%;
+      height: 100%;
+      object-fit: cover;
+      display: block;
+    }
+    .attachment-footer {
+      padding: 6px 8px;
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      gap: 4px;
+      border-top: 1px solid var(--border);
+      font-size: 12px;
+      color: var(--text-muted);
+    }
+    .attachment-footer .name {
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+      flex: 1;
+    }
+    .del {
+      color: var(--danger);
+      font-size: 12px;
+      cursor: default;
+    }
+
+    .dialog-footer {
+      padding: 12px 20px;
+      border-top: 1px solid var(--border);
+      display: flex;
+      justify-content: flex-end;
+      gap: 8px;
+    }
+
+    .view-block {
+      display: grid;
+      grid-template-columns: 120px 1fr;
+      gap: 16px;
+      align-items: start;
+      padding: 16px;
+      border: 1px solid var(--border);
+      border-radius: 8px;
+      background: #fff;
+    }
+    .view-photo {
+      width: 120px;
+      height: 120px;
+      border-radius: 10px;
+      object-fit: cover;
+      border: 1px solid var(--border);
+      background: #f1f5f9;
+    }
+    .kv { display: grid; grid-template-columns: 88px 1fr; gap: 8px 12px; font-size: 13px; }
+    .kv dt { color: var(--text-muted); }
+    .kv dd { margin: 0; font-weight: 500; }
+
+    .note {
+      margin-top: 12px;
+      padding: 12px 14px;
+      border-radius: 8px;
+      background: var(--primary-bg);
+      color: var(--primary-dark);
+      font-size: 13px;
+    }
+    .note strong { font-weight: 700; }
+
+    .scope-grid {
+      display: grid;
+      grid-template-columns: repeat(4, 1fr);
+      gap: 12px;
+    }
+    @media (max-width: 900px) {
+      .scope-grid { grid-template-columns: 1fr 1fr; }
+      .form-grid-2 { grid-template-columns: 1fr; }
+    }
+    .scope-item {
+      border: 1px solid var(--border);
+      border-radius: 8px;
+      padding: 14px;
+      background: #fff;
+      text-align: center;
+    }
+    .scope-item .label { font-weight: 700; margin-bottom: 4px; }
+    .scope-item .field { font-size: 12px; color: var(--text-muted); }
+    .scope-item .field code {
+      font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+      font-size: 11px;
+      color: var(--primary-dark);
+      background: var(--primary-bg);
+      padding: 1px 6px;
+      border-radius: 4px;
+    }
+
+    .result-bar {
+      position: sticky;
+      bottom: 0;
+      margin-top: 32px;
+      padding: 14px 18px;
+      border: 1px solid var(--border);
+      border-radius: 10px;
+      background: #fff;
+      box-shadow: 0 -4px 20px rgba(15, 23, 42, 0.06);
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      gap: 12px;
+      flex-wrap: wrap;
+    }
+    .result-bar .summary { font-size: 13px; color: var(--text-muted); }
+    .result-bar .summary b { color: var(--text); }
+  </style>
+</head>
+<body>
+  <div class="demo-page">
+    <header class="demo-head">
+      <h1><span>Fresh Mint</span> · 车辆 / 驾押人员照片字段</h1>
+      <p>
+        覆盖四个列表页:三方车辆、山峡自有车辆、三方驾押人员、山峡驾押人员。
+        车辆与人员各增加一个照片字段,默认只存 <strong>1 张</strong>图片(MinIO URL)。
+        请点击下方方案卡片选择偏好,便于后续落地实现。
+      </p>
+      <nav class="toc">
+        <a href="#scope">覆盖范围</a>
+        <a href="#list-style" class="rec">列表展示</a>
+        <a href="#form">新增/编辑表单</a>
+        <a href="#view">查看弹窗</a>
+      </nav>
+    </header>
+
+    <section class="section" id="scope">
+      <div class="section-head">
+        <h2>1. 覆盖范围 <span class="badge">两表四页</span></h2>
+        <p>底层两张表各加一字段;四个页面共用同一套上传控件(AttachmentUploadField,limit=1,accept=image/*)。</p>
+      </div>
+      <div class="scope-grid">
+        <div class="scope-item">
+          <div class="label">三方车辆</div>
+          <div class="field">表 <code>pl_vehicle</code><br />字段建议 <code>photo_urls</code></div>
+        </div>
+        <div class="scope-item">
+          <div class="label">山峡自有车辆</div>
+          <div class="field">同表 <code>pl_vehicle</code><br />车辆照片</div>
+        </div>
+        <div class="scope-item">
+          <div class="label">三方驾押人员</div>
+          <div class="field">表 <code>pl_personnel</code><br />字段建议 <code>photo_urls</code></div>
+        </div>
+        <div class="scope-item">
+          <div class="label">山峡驾押人员</div>
+          <div class="field">同表 <code>pl_personnel</code><br />人员照片</div>
+        </div>
+      </div>
+      <div class="note">
+        <strong>存储约定:</strong>与现有附件一致,库字段存逗号分隔 URL;前端 limit=1,实际默认只保留一张。上传 folder 建议:
+        <code>pl/vehicle-photo</code> / <code>pl/personnel-photo</code>。
+      </div>
+    </section>
+
+    <section class="section" id="list-style">
+      <div class="section-head">
+        <h2>2. 列表展示形态 <span class="badge">请点选</span></h2>
+        <p>照片列放在主标识列(车牌号 / 姓名)附近。请选择更偏好的列表展示方式。</p>
+      </div>
+      <div class="compare">
+        <div class="option-card selected" data-group="list" data-value="A" onclick="pick(this)">
+          <div class="option-card-head">
+            <div>
+              <h3>方案 A · 方形缩略图(推荐)</h3>
+              <p>48×48,object-fit: cover,无图显示「未上传」</p>
+            </div>
+            <div class="pick"></div>
+          </div>
+          <div class="option-card-body">
+            <div class="panel">
+              <div class="panel-top"><h2 class="title-bar-heading">三方车辆</h2></div>
+              <div class="list-toolbar">
+                <span class="btn btn-main">+ 新增车辆</span>
+                <span class="btn btn-ghost">编辑</span>
+              </div>
+              <table class="list-table">
+                <thead>
+                  <tr>
+                    <th style="width:72px">照片</th>
+                    <th>车牌号</th>
+                    <th>状态</th>
+                    <th>承运商</th>
+                  </tr>
+                </thead>
+                <tbody>
+                  <tr>
+                    <td>
+                      <img class="photo-thumb" alt="车辆照片"
+                        src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='96' height='96'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop stop-color='%2394a3b8'/%3E%3Cstop offset='1' stop-color='%23cbd5e1'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect fill='url(%23g)' width='96' height='96'/%3E%3Crect x='18' y='42' width='60' height='28' rx='4' fill='%23334155'/%3E%3Ccircle cx='32' cy='70' r='8' fill='%231e293b'/%3E%3Ccircle cx='64' cy='70' r='8' fill='%231e293b'/%3E%3C/svg%3E" />
+                    </td>
+                    <td><a class="link" href="#">皖H12345 / 皖H12346</a></td>
+                    <td><span class="tag">可用</span></td>
+                    <td>承运商1</td>
+                  </tr>
+                  <tr>
+                    <td><span class="photo-thumb placeholder">未上传</span></td>
+                    <td><a class="link" href="#">皖P09872 / 皖S90921</a></td>
+                    <td><span class="tag">可用</span></td>
+                    <td>隆鑫运输</td>
+                  </tr>
+                </tbody>
+              </table>
+            </div>
+          </div>
+        </div>
+
+        <div class="option-card" data-group="list" data-value="B" onclick="pick(this)">
+          <div class="option-card-head">
+            <div>
+              <h3>方案 B · 附件卡片紧凑态</h3>
+              <p>对齐证书附件列样式,略扁的预览卡</p>
+            </div>
+            <div class="pick"></div>
+          </div>
+          <div class="option-card-body">
+            <div class="panel">
+              <div class="panel-top"><h2 class="title-bar-heading">山峡驾押人员</h2></div>
+              <div class="list-toolbar">
+                <span class="btn btn-main">+ 新增人员</span>
+                <span class="btn btn-ghost">编辑</span>
+              </div>
+              <table class="list-table">
+                <thead>
+                  <tr>
+                    <th style="width:80px">照片</th>
+                    <th>姓名</th>
+                    <th>类型</th>
+                    <th>状态</th>
+                  </tr>
+                </thead>
+                <tbody>
+                  <tr>
+                    <td>
+                      <img class="attach-compact" alt="人员照片"
+                        src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='112' height='80'%3E%3Crect fill='%23e2e8f0' width='112' height='80'/%3E%3Ccircle cx='56' cy='30' r='14' fill='%2394a3b8'/%3E%3Cpath d='M28 68c4-16 20-22 28-22s24 6 28 22' fill='%2394a3b8'/%3E%3C/svg%3E" />
+                    </td>
+                    <td><a class="link" href="#">王驾驶</a></td>
+                    <td><span class="tag">驾驶员</span></td>
+                    <td><span class="tag">在职</span></td>
+                  </tr>
+                  <tr>
+                    <td><span class="tag muted">—</span></td>
+                    <td><a class="link" href="#">李押运</a></td>
+                    <td><span class="tag muted">押运员</span></td>
+                    <td><span class="tag">在职</span></td>
+                  </tr>
+                </tbody>
+              </table>
+            </div>
+          </div>
+        </div>
+      </div>
+    </section>
+
+    <section class="section" id="form">
+      <div class="section-head">
+        <h2>3. 新增 / 编辑弹窗</h2>
+        <p>沿用协议附件卡片化布局:上传触发卡片 140×96,虚线边框;limit=1,仅图片;与业务字段同表单。</p>
+      </div>
+      <div class="dialog-mask">
+        <div class="dialog">
+          <div class="dialog-header">新增自有车辆</div>
+          <div class="dialog-body">
+            <div class="form-grid-2">
+              <div class="form-item">
+                <label>车牌号</label>
+                <div class="control">请输入车牌号</div>
+              </div>
+              <div class="form-item">
+                <label>挂车号</label>
+                <div class="control">请输入挂车号</div>
+              </div>
+              <div class="form-item">
+                <label>承运商</label>
+                <div class="control">请选择承运商</div>
+              </div>
+              <div class="form-item">
+                <label>载重(吨)</label>
+                <div class="control">如 32</div>
+              </div>
+              <div class="form-item form-col-span-2">
+                <label>车辆照片</label>
+                <div class="upload-row">
+                  <div class="upload-card" title="已上传 1 张时隐藏此触发区">
+                    <div class="plus">+</div>
+                    <span>上传附件</span>
+                  </div>
+                  <!-- 演示:已上传一张时的状态(实际 limit=1 时上传卡可隐藏) -->
+                  <div class="attachment-item" style="display:none" id="demo-filled">
+                    <div class="attachment-preview">
+                      <img alt="车辆照片"
+                        src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='96'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop stop-color='%2394a3b8'/%3E%3Cstop offset='1' stop-color='%23cbd5e1'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect fill='url(%23g)' width='140' height='96'/%3E%3Crect x='30' y='40' width='80' height='32' rx='4' fill='%23334155'/%3E%3Ccircle cx='50' cy='72' r='9' fill='%231e293b'/%3E%3Ccircle cx='90' cy='72' r='9' fill='%231e293b'/%3E%3C/svg%3E" />
+                    </div>
+                    <div class="attachment-footer">
+                      <span class="name">vehicle-front.jpg</span>
+                      <span class="del">删除</span>
+                    </div>
+                  </div>
+                </div>
+                <div class="form-tip">仅支持图片,最多 1 张;上传后立即保存到对象存储。</div>
+              </div>
+            </div>
+          </div>
+          <div class="dialog-footer">
+            <span class="btn btn-ghost">✕ 取消</span>
+            <span class="btn btn-main">✓ 确定</span>
+          </div>
+        </div>
+      </div>
+
+      <div class="compare" style="margin-top:16px">
+        <div class="option-card selected" data-group="form" data-value="optional" onclick="pick(this)">
+          <div class="option-card-head">
+            <div>
+              <h3>照片非必填(推荐)</h3>
+              <p>可先建档后补图,与当前证书附件策略一致</p>
+            </div>
+            <div class="pick"></div>
+          </div>
+        </div>
+        <div class="option-card" data-group="form" data-value="required" onclick="pick(this)">
+          <div class="option-card-head">
+            <div>
+              <h3>照片必填</h3>
+              <p>新增/编辑提交时校验必须有 1 张图</p>
+            </div>
+            <div class="pick"></div>
+          </div>
+        </div>
+      </div>
+    </section>
+
+    <section class="section" id="view">
+      <div class="section-head">
+        <h2>4. 查看弹窗中的照片</h2>
+        <p>只读预览:左侧大图,右侧基础信息;无图时显示占位。</p>
+      </div>
+      <div class="panel" style="padding:20px">
+        <div class="view-block">
+          <img class="view-photo" alt="人员照片"
+            src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Crect fill='%23e2e8f0' width='120' height='120'/%3E%3Ccircle cx='60' cy='44' r='22' fill='%2394a3b8'/%3E%3Cpath d='M22 108c8-28 28-38 38-38s30 10 38 38' fill='%2394a3b8'/%3E%3C/svg%3E" />
+          <dl class="kv">
+            <dt>姓名</dt><dd>王驾驶</dd>
+            <dt>手机号</dt><dd>13089012212</dd>
+            <dt>人员类型</dt><dd><span class="tag">驾驶员</span></dd>
+            <dt>归属</dt><dd>山峡驾押人员</dd>
+            <dt>状态</dt><dd><span class="tag">在职</span></dd>
+          </dl>
+        </div>
+      </div>
+    </section>
+
+    <div class="result-bar">
+      <div class="summary">
+        当前选择:列表 <b id="sel-list">方案 A · 方形缩略图</b> · 校验 <b id="sel-form">非必填</b>
+      </div>
+      <div style="font-size:12px;color:var(--text-muted)">文件:docs/fresh-mint-pl-vehicle-personnel-photo-demo.html</div>
+    </div>
+  </div>
+
+  <script>
+    const labels = {
+      list: { A: '方案 A · 方形缩略图', B: '方案 B · 附件卡片紧凑态' },
+      form: { optional: '非必填', required: '必填' }
+    }
+    const state = { list: 'A', form: 'optional' }
+
+    function pick(el) {
+      const group = el.dataset.group
+      const value = el.dataset.value
+      document.querySelectorAll('.option-card[data-group="' + group + '"]').forEach(function (card) {
+        card.classList.toggle('selected', card === el)
+      })
+      state[group] = value
+      document.getElementById('sel-list').textContent = labels.list[state.list]
+      document.getElementById('sel-form').textContent = labels.form[state.form]
+      if (window.parent !== window) {
+        try {
+          window.parent.postMessage({ type: 'demo-select', group: group, value: value, state: state }, '*')
+        } catch (e) {}
+      }
+    }
+  </script>
+</body>
+</html>

+ 200 - 0
docs/superpowers/specs/2026-08-02-pl-vehicle-personnel-photo-design.md

@@ -0,0 +1,200 @@
+# 运输计划 · 车辆/驾押人员照片字段 — 设计文档
+
+> **日期**: 2026-08-02  
+> **状态**: 待审阅  
+> **Demo**: `docs/fresh-mint-pl-vehicle-personnel-photo-demo.html`  
+> **页面**:  
+> - 三方车辆 `web-admin` `/#/pl/vehicle` → `src/views/pl/vehicle/index.vue`  
+> - 山峡自有车辆 `/#/pl/self-vehicle` → `src/views/pl/self-vehicle/index.vue`  
+> - 三方/山峡驾押人员 `/#/pl/personnel*` → `src/views/pl/personnel/index.vue`(归属模式共用)
+
+## 目标
+
+为车辆档案与驾押人员档案各增加一个**照片**字段,默认只存 **1 张**图片(MinIO URL),用于列表识别与档案查看。
+
+覆盖范围:
+
+1. 三方车辆、山峡自有车辆(同一张表 `pl_vehicle`)  
+2. 三方驾押人员、山峡驾押人员(同一张表 `pl_personnel`)
+
+## 已确认业务规则
+
+| 规则 | 决策 |
+|------|------|
+| 存储形态 | 路径 2:`photo_urls`(逗号分隔 URL),前端 `limit=1` |
+| 列表展示 | 方案 A:48×48 方形缩略图;无图显示「未上传」 |
+| 是否必填 | **非必填**(可先建档后补图) |
+| 文件类型 | 仅图片(`accept="image/*"`) |
+| 数量 | 默认 1 张;前端硬限制 1;后端不强制必填 |
+| 上传组件 | 复用 `AttachmentUploadField`(卡片化布局) |
+| 上传目录 | 车辆 `pl/vehicle-photo`;人员 `pl/personnel-photo` |
+
+## 非目标(本期不做)
+
+- 多图相册、主图/附图切换  
+- 照片 OCR / 人脸比对  
+- 强制历史数据补图  
+- MinIO 孤儿文件清理  
+- 小程序端同步改造(除非后续单独需求)
+
+---
+
+## 方案选型
+
+**采用路径 2:`photo_urls` 字段 + 前端 limit=1**
+
+| 对比项 | `photo_url` 单 URL | `photo_urls`(采用) | 挂到证书附件 |
+|--------|-------------------|----------------------|--------------|
+| 与附件组件一致性 | 弱 | 强 | 职责混淆 |
+| 扩展多图 | 需改字段 | 仅调 limit | 不合适 |
+| 语义 | 更直观 | 字段名带 s | — |
+
+---
+
+## 数据模型变更
+
+### `pl_vehicle` 新增列
+
+放在 `vehicle_status` 与 `tenant_no` 之间:
+
+```sql
+ALTER TABLE `pl_vehicle`
+  ADD COLUMN `photo_urls` varchar(2000) DEFAULT NULL COMMENT '车辆照片URL,逗号分隔(默认1张)' AFTER `vehicle_status`;
+```
+
+### `pl_personnel` 新增列
+
+放在 `status` 与 `tenant_no` 之间:
+
+```sql
+ALTER TABLE `pl_personnel`
+  ADD COLUMN `photo_urls` varchar(2000) DEFAULT NULL COMMENT '人员照片URL,逗号分隔(默认1张)' AFTER `status`;
+```
+
+同步更新:
+
+- `docs/sql/shanxia2.0.sql`  
+- 迁移脚本:`backend/scripts/migrate_20260802_pl_vehicle_personnel_photo_urls.sql`  
+- ORM:`backend/app/models/pl_models.py` 中 `PlVehicle` / `PlPersonnel`
+
+---
+
+## 后端设计
+
+### Schema(`backend/app/schemas/pl/base_data.py`)
+
+| Schema | 变更 |
+|--------|------|
+| `VehicleBase` | 增加可选 `photo_urls: Optional[str]`,alias `photoUrls`,`max_length=2000` |
+| `VehicleUpdate` | 同上(optional) |
+| `PersonnelBase` | 同上 |
+| `PersonnelUpdate` | 同上 |
+
+`VehicleRead` / `PersonnelRead` 经 Base 或显式字段带出 `photoUrls`;`model_validate(vehicle)` 即可自动映射 ORM 列。
+
+### API(`backend/app/api/pl/base_data.py`)
+
+| 接口 | 变更 |
+|------|------|
+| `POST /transport/vehicle` | `PlVehicle(..., photo_urls=payload.photo_urls)` |
+| `PUT /transport/vehicle/{id}` | `model_dump(exclude_unset=True)` 已含 `photo_urls` 时 setattr |
+| `GET` list/detail vehicle | Read 自动带 `photoUrls` |
+| `POST /transport/personnel` | 写入 `photo_urls` |
+| `PUT /transport/personnel/{id}` | 同上 |
+| `GET` list/detail personnel | Read 自动带 `photoUrls` |
+
+**校验**
+
+- 不校验非空(非必填)  
+- 不在后端截断多 URL;前端保证最多 1 张。若未来需要,可在 service 层取第一段 URL(本期不做)
+
+**字段一致性**
+
+Create/Update Body 与前端 `formData` / Payload 均包含 `photoUrls`,与 Read 对齐;实现后可跑 `python scripts/check_field_consistency.py`(若该检查已覆盖 PL 实体)。
+
+---
+
+## 前端设计
+
+### 类型(`web-admin/src/api/pl/index.ts`)
+
+`Vehicle` / `VehiclePayload` / `Personnel` / `PersonnelPayload` 增加:
+
+```ts
+photoUrls?: string
+```
+
+### 列表(方案 A)
+
+四页(车辆两页 + 人员一页双模式)在主标识列附近增加「照片」列:
+
+- 有 URL:`<img>` 48×48,`object-fit: cover`,圆角 8px;取 `photoUrls` 逗号分隔的第一段  
+- 无 URL:占位「未上传」  
+- 列表缩略图**仅展示**,不单独开预览;预览在查看弹窗(`AttachmentUploadField` disabled)完成  
+- 列定义加入 `listColumnDefs`,**默认可见**,支持列设置隐藏  
+- 列持久化 key 如有版本号需升版(避免旧缓存无新列)
+
+### 新增 / 编辑弹窗
+
+在表单合适位置(建议业务基础字段之后、证书区块之前)增加:
+
+```vue
+<el-form-item label="车辆照片" class="form-col-span-2"> <!-- 或「人员照片」 -->
+  <AttachmentUploadField
+    ref="photoUploadRef"
+    v-model="formData.photoUrls"
+    :limit="1"
+    accept="image/*"
+    upload-folder="pl/vehicle-photo" <!-- 人员:pl/personnel-photo -->
+  />
+</el-form-item>
+```
+
+- 非必填,无 form rules  
+- 提交前:`photoUploadRef.hasUploading?.()` 为 true 时 `ElMessage.warning('附件上传中,请稍候')`  
+- 重置/打开弹窗时同步 `photoUrls`
+
+### 查看弹窗(`SystemViewDialog`)
+
+- 在详情字段中增加照片只读展示(可用 `AttachmentUploadField` `disabled` + `limit=1`,或 hero 区缩略图)  
+- 无图时展示「—」或「未上传」
+
+### UI 规范对齐
+
+- Fresh Mint;附件卡片 140×96、虚线边框 `#a7f3d0`(组件已实现)  
+- 弹窗 `label-position="top"`、`dialog-form-layout`  
+- 列表缩略图风格见 Demo
+
+---
+
+## 错误处理与边界
+
+| 场景 | 行为 |
+|------|------|
+| 未上传照片保存 | 允许,`photo_urls` 为 null/空 |
+| 上传中点确定 | 前端拦截提示 |
+| 上传失败 | 组件内提示,不写入 form |
+| 删除照片后保存 | `photoUrls` 置空字符串或 undefined,后端写 null/空 |
+| 旧数据无列值 | 列表显示「未上传」,功能正常 |
+
+---
+
+## 测试要点
+
+1. 迁移后四页列表出现「照片」列,默认可见  
+2. 车辆/人员新增上传 1 张图 → 列表缩略图可见 → 查看弹窗可见  
+3. 编辑替换/删除照片后列表与详情同步  
+4. 不上传可成功保存  
+5. `limit=1` 无法选第二张  
+6. 三方与自有车辆、三方与山峡人员行为一致  
+7. 字段一致性检查通过(若适用)
+
+---
+
+## 实施顺序建议
+
+1. DB 迁移 + ORM + Schema  
+2. API create/update 透传  
+3. 前端类型 + 车辆两页  
+4. 前端人员页  
+5. 自测上述要点 + `graphify` / `./scripts/update-graph.sh`(改代码后)