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>
This commit is contained in:
1 parent
a56b170976
commit
9af37c3f56
3 files changed
+960
No files matched your search
@@ -84,3 +84,4 @@ exports/
|
||||
|
||||
# uni-app 编译产物
|
||||
miniprogram/unpackage/
|
||||
.superpowers/
|
||||
@@ -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>
|
||||
@@ -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`(改代码后)
|
||||
Reference in new issue
Block a user