docs: add transport feedback brand qty design and demo

Capture the approved design for site/driver brand quantity feedback,
independent storage from archive, and view-tab/button layout changes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-07-31 05:00:36 +08:00
1 parent dd4aa734ee
commit 4e6e06a603
2 files changed
+978

No files matched your search

@@ -0,0 +1,764 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>运输计划回传 · 按品牌填量 Demo</title>
<style>
:root {
--mint: #059669;
--mint-soft: #ecfdf5;
--mint-line: #a7f3d0;
--slate: #64748b;
--ink: #0f172a;
--warn: #d97706;
--danger: #dc2626;
--bg: #f1f5f9;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
background: linear-gradient(165deg, #ecfdf5 0%, #f8fafc 42%, #e2e8f0 100%);
color: var(--ink);
min-height: 100vh;
}
.page {
max-width: 1180px;
margin: 0 auto;
padding: 28px 20px 48px;
}
h1 {
margin: 0 0 6px;
font-size: 22px;
font-weight: 700;
}
.lead {
margin: 0 0 20px;
color: var(--slate);
font-size: 14px;
line-height: 1.6;
}
.layout-picker {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin-bottom: 24px;
}
.layout-opt {
border: 2px solid #e2e8f0;
background: #fff;
border-radius: 12px;
padding: 14px 14px 12px;
cursor: pointer;
text-align: left;
transition: border-color .15s, box-shadow .15s;
}
.layout-opt:hover { border-color: var(--mint-line); }
.layout-opt.active {
border-color: var(--mint);
box-shadow: 0 0 0 3px rgb(16 185 129 / 18%);
}
.layout-opt .tag {
display: inline-block;
font-size: 11px;
font-weight: 700;
color: var(--mint);
background: var(--mint-soft);
padding: 2px 8px;
border-radius: 999px;
margin-bottom: 8px;
}
.layout-opt h3 {
margin: 0 0 4px;
font-size: 15px;
}
.layout-opt p {
margin: 0;
font-size: 12px;
color: var(--slate);
line-height: 1.5;
}
.phones {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 28px;
align-items: start;
}
.phone-wrap h2 {
margin: 0 0 10px;
font-size: 15px;
color: var(--slate);
font-weight: 600;
}
.phone {
width: 100%;
max-width: 390px;
margin: 0 auto;
background: #0f172a;
border-radius: 36px;
padding: 12px;
box-shadow: 0 24px 48px rgb(15 23 42 / 18%);
}
.phone-screen {
background: var(--bg);
border-radius: 28px;
overflow: hidden;
min-height: 640px;
display: flex;
flex-direction: column;
}
.nav {
background: var(--mint);
color: #fff;
text-align: center;
font-weight: 600;
font-size: 16px;
padding: 14px 16px 16px;
border-radius: 0 0 14px 14px;
}
.scroll {
flex: 1;
overflow: auto;
padding: 12px 0 80px;
}
.card {
margin: 0 12px 10px;
background: #fff;
border-radius: 12px;
overflow: hidden;
border: 1px solid #e2e8f0;
}
.card-title {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
font-size: 12px;
font-weight: 600;
color: #047857;
background: linear-gradient(90deg, #ecfdf5 0%, transparent 72%);
}
.card-title::before {
content: "";
width: 3px;
height: 12px;
border-radius: 2px;
background: var(--mint);
}
.cell {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 10px 14px;
font-size: 14px;
border-top: 1px solid #f1f5f9;
}
.cell:first-child { border-top: 0; }
.cell .k { color: var(--slate); }
.cell .v { font-weight: 600; text-align: right; }
.product {
margin: 10px 12px;
padding: 14px;
border-radius: 12px;
background: linear-gradient(180deg, #f0fdf4 0%, #fff 48%);
border: 1px solid #d1fae5;
}
.product-head {
display: flex;
justify-content: space-between;
gap: 8px;
margin-bottom: 10px;
}
.product-name { font-size: 15px; font-weight: 700; }
.badge {
flex-shrink: 0;
font-size: 10px;
font-weight: 600;
padding: 2px 8px;
border-radius: 999px;
background: #d1fae5;
color: #047857;
height: fit-content;
}
.badge.warn { background: #fef3c7; color: #b45309; }
.meta {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-bottom: 12px;
}
.meta-item {
background: rgb(255 255 255 / 85%);
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 8px 10px;
}
.meta-item.span { grid-column: 1 / -1; }
.meta-label { font-size: 11px; color: var(--slate); margin-bottom: 2px; }
.meta-value { font-size: 13px; font-weight: 600; }
.meta-value.plan { color: var(--mint); font-size: 16px; }
.actual-box {
background: #fff;
border: 1px solid var(--mint-line);
border-radius: 10px;
padding: 10px 12px;
}
.actual-box__label {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 8px;
margin-bottom: 6px;
}
.actual-box__label strong { font-size: 13px; }
.actual-box__label span { font-size: 11px; color: var(--slate); }
.actual-row {
display: flex;
align-items: center;
gap: 8px;
}
.actual-row input {
flex: 1;
border: 0;
outline: none;
font-size: 22px;
font-weight: 700;
color: var(--ink);
width: 0;
background: transparent;
}
.unit { color: var(--slate); font-size: 13px; font-weight: 600; }
.brand-block {
margin-top: 10px;
border-top: 1px dashed #bbf7d0;
padding-top: 10px;
}
.brand-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.brand-head h4 {
margin: 0;
font-size: 13px;
color: #047857;
}
.brand-sum {
font-size: 11px;
color: var(--slate);
}
.brand-sum.bad { color: var(--danger); font-weight: 600; }
.brand-sum.ok { color: var(--mint); font-weight: 600; }
.brand-row {
display: grid;
grid-template-columns: 1fr 88px 28px;
gap: 6px;
align-items: center;
margin-bottom: 6px;
}
.brand-row select,
.brand-row input {
height: 36px;
border: 1px solid #cbd5e1;
border-radius: 8px;
padding: 0 8px;
font-size: 13px;
background: #fff;
width: 100%;
}
.brand-row input { text-align: right; font-weight: 600; }
.icon-btn {
width: 28px;
height: 28px;
border: 0;
border-radius: 8px;
background: #fee2e2;
color: var(--danger);
font-size: 16px;
cursor: pointer;
line-height: 1;
}
.add-brand {
width: 100%;
margin-top: 4px;
height: 34px;
border: 1px dashed var(--mint);
border-radius: 8px;
background: var(--mint-soft);
color: var(--mint);
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 8px;
}
.chip {
border: 1px solid #cbd5e1;
background: #fff;
border-radius: 999px;
padding: 6px 10px;
font-size: 12px;
cursor: pointer;
}
.chip.on {
border-color: var(--mint);
background: var(--mint-soft);
color: #047857;
font-weight: 600;
}
.chip .q {
margin-left: 4px;
color: var(--slate);
font-weight: 500;
}
.footer {
position: sticky;
bottom: 0;
padding: 12px 16px 16px;
background: linear-gradient(180deg, transparent, #f1f5f9 28%);
}
.submit {
width: 100%;
height: 44px;
border: 0;
border-radius: 999px;
background: var(--mint);
color: #fff;
font-size: 16px;
font-weight: 600;
cursor: pointer;
}
.note {
margin-top: 20px;
padding: 14px 16px;
background: #fff;
border-radius: 12px;
border: 1px solid #e2e8f0;
font-size: 13px;
color: var(--slate);
line-height: 1.6;
}
.note strong { color: var(--ink); }
@media (max-width: 900px) {
.phones, .layout-picker { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="page">
<h1>运输计划回传 · 按品牌填量 Demo</h1>
<p class="lead">
左侧模拟<strong>现场链接</strong>(入/出库单回执),右侧模拟<strong>司机链接</strong>。
有品牌的产品(烧碱)可填 1~N 条「品牌 + 数量」;无品牌产品(盐酸)只填总实际数量。
请先选择下方交互方案,再在手机框内试用。
</p>
<div class="layout-picker" id="layoutPicker">
<button type="button" class="layout-opt active" data-layout="a" onclick="setLayout('a')">
<span class="tag">方案 A · 推荐</span>
<h3>下拉选品牌 + 数量行</h3>
<p>可增减行;合计自动回写实际数量。与归档弹窗逻辑一致,适合现场快速录入。</p>
</button>
<button type="button" class="layout-opt" data-layout="b" onclick="setLayout('b')">
<span class="tag">方案 B</span>
<h3>品牌芯片 + 点选填量</h3>
<p>先点选参与品牌,再为每个已选品牌填数量。品牌少时更直观。</p>
</button>
<button type="button" class="layout-opt" data-layout="c" onclick="setLayout('c')">
<span class="tag">方案 C</span>
<h3>仅现场填品牌;司机只填总量</h3>
<p>司机链接保持现状(一个总实际数量);品牌明细只在现场链接填写。</p>
</button>
</div>
<div class="phones">
<!-- 现场 -->
<div class="phone-wrap">
<h2>现场链接 · 入库单回执</h2>
<div class="phone">
<div class="phone-screen">
<div class="nav">入库单 · 现场回执</div>
<div class="scroll">
<div class="card">
<div class="cell"><span class="k">业务类型</span><span class="v">采购入库</span></div>
<div class="cell"><span class="k">往来单位</span><span class="v">君正化工</span></div>
<div class="cell"><span class="k">车牌</span><span class="v">皖A·12X34</span></div>
<div class="cell"><span class="k">计划号</span><span class="v">JH20260731001</span></div>
</div>
<div class="card">
<div class="card-title">产品实际数量</div>
</div>
<!-- 有品牌:烧碱 -->
<div class="product" id="siteBranded">
<div class="product-head">
<div class="product-name">烧碱</div>
<span class="badge warn">不限品牌</span>
</div>
<div class="meta">
<div class="meta-item">
<div class="meta-label">规格型号</div>
<div class="meta-value">32%</div>
</div>
<div class="meta-item">
<div class="meta-label">包装规格</div>
<div class="meta-value">散装</div>
</div>
<div class="meta-item span">
<div class="meta-label">计划数量</div>
<div class="meta-value plan">30 吨</div>
</div>
</div>
<div class="actual-box">
<div class="actual-box__label">
<strong>实际数量</strong>
<span class="layout-a-only layout-b-only">由品牌明细合计</span>
<span class="layout-c-only" style="display:none">请按现场实装填写</span>
</div>
<div class="actual-row">
<input id="siteActualCaustic" type="number" value="28" step="0.001" readonly />
<span class="unit">吨</span>
</div>
</div>
<div class="brand-block layout-a-only" id="siteBrandRowsA">
<div class="brand-head">
<h4>按品牌填量</h4>
<span class="brand-sum ok" id="siteSumA">合计 28 = 实际数量 ✓</span>
</div>
<div id="siteRowsA"></div>
<button type="button" class="add-brand" onclick="addSiteRowA()">+ 添加品牌</button>
</div>
<div class="brand-block layout-b-only" id="siteBrandRowsB" style="display:none">
<div class="brand-head">
<h4>选择品牌并填量</h4>
<span class="brand-sum ok" id="siteSumB">合计 28 = 实际数量 ✓</span>
</div>
<div class="chips" id="siteChips"></div>
<div id="siteRowsB"></div>
</div>
</div>
<!-- 无品牌:盐酸 -->
<div class="product">
<div class="product-head">
<div class="product-name">盐酸</div>
<span class="badge">产品 2/2</span>
</div>
<div class="meta">
<div class="meta-item">
<div class="meta-label">规格型号</div>
<div class="meta-value">31%</div>
</div>
<div class="meta-item">
<div class="meta-label">包装规格</div>
<div class="meta-value">吨袋</div>
</div>
<div class="meta-item span">
<div class="meta-label">计划数量</div>
<div class="meta-value plan">10 吨</div>
</div>
</div>
<div class="actual-box">
<div class="actual-box__label">
<strong>实际数量</strong>
<span>无品牌产品,直接填写总量</span>
</div>
<div class="actual-row">
<input type="number" value="9.8" step="0.001" />
<span class="unit">吨</span>
</div>
</div>
</div>
</div>
<div class="footer">
<button type="button" class="submit" onclick="alert('Demo:现场回执已提交(含品牌明细)')">提交</button>
</div>
</div>
</div>
</div>
<!-- 司机 -->
<div class="phone-wrap">
<h2>司机链接 · 运输回传</h2>
<div class="phone">
<div class="phone-screen">
<div class="nav">运输计划</div>
<div class="scroll">
<div class="card">
<div class="cell"><span class="k">计划编号</span><span class="v">JH20260731001</span></div>
<div class="cell"><span class="k">车牌</span><span class="v">皖A·12X34</span></div>
<div class="cell"><span class="k">司机</span><span class="v">张师傅 138****0001</span></div>
</div>
<div class="card">
<div class="card-title">产品明细</div>
<div class="cell"><span class="k">烧碱 32% · 散装</span><span class="v">计划 30 吨</span></div>
<div class="cell"><span class="k">盐酸 31% · 吨袋</span><span class="v">计划 10 吨</span></div>
</div>
<div class="card layout-c-only" style="display:none">
<div class="card-title">回传数量</div>
<div style="padding:12px 14px 14px">
<div class="actual-box">
<div class="actual-box__label">
<strong>实际数量</strong>
<span>方案 C:司机只填总量</span>
</div>
<div class="actual-row">
<input type="number" value="37.8" step="0.001" />
<span class="unit">吨</span>
</div>
</div>
</div>
</div>
<div class="layout-a-only layout-b-only" id="driverProducts">
<div class="card"><div class="card-title">回传数量(按产品)</div></div>
<div class="product">
<div class="product-head">
<div class="product-name">烧碱</div>
<span class="badge warn">不限品牌</span>
</div>
<div class="actual-box">
<div class="actual-box__label">
<strong>实际数量</strong>
<span>由品牌明细合计</span>
</div>
<div class="actual-row">
<input id="driverActualCaustic" type="number" value="28" step="0.001" readonly />
<span class="unit">吨</span>
</div>
</div>
<div class="brand-block">
<div class="brand-head">
<h4>按品牌填量</h4>
<span class="brand-sum ok" id="driverSum">合计 28 = 实际数量 ✓</span>
</div>
<div id="driverRows"></div>
<button type="button" class="add-brand" onclick="addDriverRow()">+ 添加品牌</button>
</div>
</div>
<div class="product">
<div class="product-head">
<div class="product-name">盐酸</div>
<span class="badge">无品牌</span>
</div>
<div class="actual-box">
<div class="actual-box__label">
<strong>实际数量</strong>
<span>无品牌,直接填总量</span>
</div>
<div class="actual-row">
<input type="number" value="9.8" step="0.001" />
<span class="unit">吨</span>
</div>
</div>
</div>
</div>
</div>
<div class="footer">
<button type="button" class="submit" onclick="alert('Demo:司机回执已保存')">保存回执</button>
</div>
</div>
</div>
</div>
</div>
<div class="note">
<strong>规则预览:</strong>
① 产品有品牌概念(brandMode = any/required)时展示品牌明细;无品牌则隐藏。
② 可填 1 个或多个品牌行,数量 &gt; 0 才计入。
③ 各品牌数量合计 = 该产品实际数量;不限品牌时可多选品牌,指定品牌时仅允许该品牌一行。
④ 现场回传写入后,归档弹窗可预填品牌明细(与现有归档按品牌库存衔接)。
<br /><br />
点击上方方案卡片选择偏好(会写入选择事件)。本地文件也可直接打开:
<code>.superpowers/brainstorm/.../content/site-driver-brand-qty-demo.html</code>
</div>
</div>
<script>
const BRANDS = [
{ id: 'b1', name: '君正' },
{ id: 'b2', name: '红四方' },
{ id: 'b3', name: '中盐' }
]
let layout = 'a'
let siteRows = [
{ brandId: 'b1', qty: 16 },
{ brandId: 'b2', qty: 12 }
]
let driverRows = [
{ brandId: 'b1', qty: 16 },
{ brandId: 'b2', qty: 12 }
]
let selectedChips = new Set(['b1', 'b2'])
function setLayout(key) {
layout = key
document.querySelectorAll('.layout-opt').forEach(el => {
el.classList.toggle('active', el.dataset.layout === key)
})
document.querySelectorAll('.layout-a-only').forEach(el => {
el.style.display = key === 'a' || (el.classList.contains('layout-b-only') && key === 'b') ? '' : 'none'
})
document.querySelectorAll('.layout-b-only').forEach(el => {
if (el.classList.contains('layout-a-only')) {
el.style.display = (key === 'a' || key === 'b') ? '' : 'none'
} else {
el.style.display = key === 'b' ? '' : 'none'
}
})
document.querySelectorAll('.layout-c-only').forEach(el => {
el.style.display = key === 'c' ? '' : 'none'
})
// fix dual-class elements
document.querySelectorAll('.layout-a-only.layout-b-only').forEach(el => {
el.style.display = (key === 'a' || key === 'b') ? '' : 'none'
})
document.getElementById('siteBrandRowsA').style.display = key === 'a' ? '' : 'none'
document.getElementById('siteBrandRowsB').style.display = key === 'b' ? '' : 'none'
const actual = document.getElementById('siteActualCaustic')
actual.readOnly = key !== 'c'
if (key === 'c') actual.value = '28'
renderAll()
if (typeof toggleSelect === 'function') {
const opt = document.querySelector(`.layout-opt[data-layout="${key}"]`)
if (opt) toggleSelect(opt)
}
}
function sumRows(rows) {
return rows.reduce((s, r) => s + (Number(r.qty) || 0), 0)
}
function brandOptionsHtml(selectedId) {
return BRANDS.map(b =>
`<option value="${b.id}" ${b.id === selectedId ? 'selected' : ''}>${b.name}</option>`
).join('')
}
function updateSumLabel(el, total, actual) {
const ok = Math.abs(total - actual) < 0.0005
el.className = 'brand-sum ' + (ok ? 'ok' : 'bad')
el.textContent = ok
? `合计 ${total} = 实际数量 ✓`
: `合计 ${total} ≠ 实际数量 ${actual}`
}
function renderSiteA() {
const box = document.getElementById('siteRowsA')
box.innerHTML = siteRows.map((r, i) => `
<div class="brand-row">
<select onchange="siteRows[${i}].brandId=this.value; renderAll()">
${brandOptionsHtml(r.brandId)}
</select>
<input type="number" step="0.001" min="0" value="${r.qty}"
oninput="siteRows[${i}].qty=Number(this.value)||0; syncSiteActual()" />
<button type="button" class="icon-btn" onclick="siteRows.splice(${i},1); renderAll()" title="删除">×</button>
</div>
`).join('')
syncSiteActual()
}
function syncSiteActual() {
const total = Number(sumRows(siteRows).toFixed(3))
document.getElementById('siteActualCaustic').value = total
updateSumLabel(document.getElementById('siteSumA'), total, total)
updateSumLabel(document.getElementById('siteSumB'), total, total)
}
function addSiteRowA() {
const used = new Set(siteRows.map(r => r.brandId))
const next = BRANDS.find(b => !used.has(b.id)) || BRANDS[0]
siteRows.push({ brandId: next.id, qty: 0 })
renderAll()
}
function renderSiteB() {
const chips = document.getElementById('siteChips')
chips.innerHTML = BRANDS.map(b => {
const on = selectedChips.has(b.id)
const row = siteRows.find(r => r.brandId === b.id)
const q = row ? row.qty : 0
return `<button type="button" class="chip ${on ? 'on' : ''}" onclick="toggleChip('${b.id}')">
${b.name}${on ? `<span class="q">${q || ''}</span>` : ''}
</button>`
}).join('')
const box = document.getElementById('siteRowsB')
const active = BRANDS.filter(b => selectedChips.has(b.id))
box.innerHTML = active.map(b => {
let row = siteRows.find(r => r.brandId === b.id)
if (!row) {
row = { brandId: b.id, qty: 0 }
siteRows.push(row)
}
const i = siteRows.indexOf(row)
return `<div class="brand-row" style="grid-template-columns: 72px 1fr">
<div style="font-size:13px;font-weight:600;padding-left:4px">${b.name}</div>
<input type="number" step="0.001" min="0" value="${row.qty}"
oninput="siteRows[${i}].qty=Number(this.value)||0; syncSiteActual(); renderSiteB()" />
</div>`
}).join('')
siteRows = siteRows.filter(r => selectedChips.has(r.brandId))
syncSiteActual()
}
function toggleChip(id) {
if (selectedChips.has(id)) selectedChips.delete(id)
else selectedChips.add(id)
renderAll()
}
function renderDriver() {
const box = document.getElementById('driverRows')
box.innerHTML = driverRows.map((r, i) => `
<div class="brand-row">
<select onchange="driverRows[${i}].brandId=this.value; renderAll()">
${brandOptionsHtml(r.brandId)}
</select>
<input type="number" step="0.001" min="0" value="${r.qty}"
oninput="driverRows[${i}].qty=Number(this.value)||0; syncDriverActual()" />
<button type="button" class="icon-btn" onclick="driverRows.splice(${i},1); renderAll()">×</button>
</div>
`).join('')
syncDriverActual()
}
function syncDriverActual() {
const total = Number(sumRows(driverRows).toFixed(3))
document.getElementById('driverActualCaustic').value = total
updateSumLabel(document.getElementById('driverSum'), total, total)
}
function addDriverRow() {
const used = new Set(driverRows.map(r => r.brandId))
const next = BRANDS.find(b => !used.has(b.id)) || BRANDS[0]
driverRows.push({ brandId: next.id, qty: 0 })
renderAll()
}
function renderAll() {
renderSiteA()
renderSiteB()
renderDriver()
}
renderAll()
setLayout('a')
</script>
</body>
</html>