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:
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 个或多个品牌行,数量 > 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>
|
||||
@@ -0,0 +1,214 @@
|
||||
# 运输计划回传按品牌填量设计
|
||||
|
||||
**日期:** 2026-07-31
|
||||
**状态:** 已确认待实现
|
||||
**Demo:** `docs/demos/pl-site-driver-brand-qty-demo.html`
|
||||
|
||||
## 背景与目标
|
||||
|
||||
运输计划安排后生成现场链接(入/出库单公共页)与司机链接。当前回传只记录产品(或全单)总实际数量,无法区分同一产品不同品牌(如烧碱:君正 / 红四方)的实装量。
|
||||
|
||||
本变更要求:
|
||||
|
||||
1. 现场链接、司机链接均可按产品回传品牌数量(交互:品牌芯片点选再填量)。
|
||||
2. 产品无品牌概念时,不展示品牌明细,只填总量。
|
||||
3. 现场与司机数据各自独立保存;归档弹窗「产品实际数量」由归档人独立填写最终量并入账(沿用现有 `pl_plan_product_brand_qty`)。
|
||||
4. 运输计划 / 归档 / 危化品证上传的查看弹窗「运输信息」Tab 展示品牌数量。
|
||||
5. 归档嵌入查看中的两个「补充」弹窗支持品牌数量录入;标题栏按钮布局按下文调整。
|
||||
|
||||
## 决策摘要
|
||||
|
||||
| 项 | 决策 |
|
||||
|----|------|
|
||||
| 交互 | 方案 B:品牌芯片点选 → 填数量;可多选 |
|
||||
| 范围 | 现场链接 + 司机链接都按产品填品牌明细 |
|
||||
| 权威 | 三套独立;仅归档最终量触发库存入账 |
|
||||
| 存储 | 独立明细子表(不与归档表混用 source) |
|
||||
| 归档预填 | 不用现场/司机品牌量自动覆盖归档最终量 |
|
||||
|
||||
## 数据模型
|
||||
|
||||
### 权威边界
|
||||
|
||||
| 来源 | 数量落点 | 用途 |
|
||||
|------|----------|------|
|
||||
| 现场 | 入/出库明细 `actual_qty` + 现场品牌子表 | 留痕、查看展示 |
|
||||
| 司机 | 按计划产品行回传总量 + 司机品牌子表;保留 `pl_transport.feedback_qty` 作全单合计兼容 | 留痕、查看展示 |
|
||||
| 归档 | `pl_plan_product.actual_qty` + `pl_plan_product_brand_qty` | **唯一**库存入账来源 |
|
||||
|
||||
三套互不覆盖。归档人员在归档弹窗「产品实际数量」区域独立确认最终信息。
|
||||
|
||||
### 新表
|
||||
|
||||
#### 1. 现场:入/出库明细品牌量
|
||||
|
||||
采用 **入出库各一张表**(与现有 `pl_inbound_order_item` / `pl_outbound_order_item` 对称),结构相同:
|
||||
|
||||
- `pl_inbound_order_item_brand_qty`
|
||||
- `pl_outbound_order_item_brand_qty`
|
||||
|
||||
字段(业务):
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `id` | UUID |
|
||||
| `order_id` | 入/出库单 ID |
|
||||
| `order_item_id` | 明细 ID |
|
||||
| `plan_product_id` | 计划产品行 ID(冗余,便于按计划聚合展示) |
|
||||
| `brand_id` | 品牌 ID,空串表示无品牌键(本场景有品牌时非空) |
|
||||
| `brand_name` | 品牌名快照 |
|
||||
| `qty` | 本品牌数量 |
|
||||
|
||||
其后接公共字段:`tenant_no`、`order_num`、`remark`、`create_by`、`create_dept_id`、`create_time`、`update_by`、`update_time`、`del_flag`。
|
||||
|
||||
软删旧行后按提交结果重插(与归档品牌量持久化模式一致)。
|
||||
|
||||
#### 2. 司机:按产品回传
|
||||
|
||||
- `pl_transport_product_feedback`
|
||||
- `transport_id`、`plan_id`、`plan_product_id`
|
||||
- `actual_qty`(该产品回传总量)
|
||||
- 公共字段 + 软删
|
||||
- `pl_transport_product_brand_qty`
|
||||
- `transport_id`、`plan_product_id`、`feedback_id`(可选 FK)
|
||||
- `brand_id`、`brand_name`、`qty`
|
||||
- 公共字段 + 软删
|
||||
|
||||
兼容:保存司机回传时,用各产品 `actual_qty` 之和回写 `pl_transport.feedback_qty`,供旧展示/报表过渡。
|
||||
|
||||
### 只读辅助字段(API)
|
||||
|
||||
公开页与查看接口对有品牌的产品附带:
|
||||
|
||||
- `brandMode`、`brandId`、`brandName`(计划行约定)
|
||||
- `brandOptions[]`:`{ brandId, brandName }`(来自 CRM 产品品牌)
|
||||
- `brandQtys[]`:已填 `{ brandId, brandName, qty }`
|
||||
|
||||
无 `brandMode` 时不返回品牌录入区所需 options。
|
||||
|
||||
## 录入交互与校验
|
||||
|
||||
### 何时展示品牌区
|
||||
|
||||
- 计划产品行存在 `brandMode`(`any` / `required`)→ 展示芯片 + 数量。
|
||||
- 否则 → 仅总量输入,不展示品牌区。
|
||||
|
||||
### 交互(方案 B)
|
||||
|
||||
1. 以芯片展示可选品牌;点选启用,再为已选品牌填数量。
|
||||
2. `required`:仅预选指定品牌,不可改选其他;数量可编辑。
|
||||
3. 已选品牌数量合计自动回写该产品实际数量(合计只读)。
|
||||
4. 未选中或 `qty ≤ 0` 的品牌不落库。
|
||||
5. 支持 1 个或多个品牌。
|
||||
|
||||
### 校验
|
||||
|
||||
- 有品牌且提交完成态:至少 1 个品牌 `qty > 0`,且各品牌合计等于该产品实际数量。
|
||||
- 无品牌:只校验总量规则(与现网入出库/司机回传一致)。
|
||||
- 归档弹窗继续使用现有 `validate_brand_qty_rows` + `persist_plan_product_brand_qty`;**不得**用现场/司机数据自动覆盖归档最终量(允许 UI 旁路展示对照,默认不写入表单最终值)。
|
||||
|
||||
## API 变更(要点)
|
||||
|
||||
### 现场公共 API
|
||||
|
||||
- `GET /public/pl/{inbound|outbound}/{token}`:items 增加 `brandMode`、`brandOptions`、`brandQtys` 等。
|
||||
- `POST .../feedback`:items 元素增加可选 `brandQtys[]`;服务端校验并持久化子表,同时更新 `actual_qty`。
|
||||
|
||||
### 司机公共 API
|
||||
|
||||
- 页面数据:products 增加品牌字段与已填 `brandQtys`;支持按产品 `actualQty`。
|
||||
- 保存回传:接收 `products: [{ planProductId, actualQty, brandQtys? }]`(及原有备注/附件);写产品回传表 + 品牌子表,并回写 `feedback_qty` 合计。
|
||||
|
||||
### 管理端补充
|
||||
|
||||
- `PATCH .../driver-feedback`:扩展为可提交按产品数量与 `brandQtys`(与公开页同源服务逻辑)。
|
||||
- 入/出库现场补充(现有编辑入出库回传路径):同样支持 `brandQtys`。
|
||||
|
||||
### 查看
|
||||
|
||||
- 计划详情 / 运输信息 Tab 所用聚合接口返回司机按产品回传、入出库明细品牌摘要(如 `君正 16 / 红四方 12`),与现有归档 `brandSummary` 展示风格一致。
|
||||
|
||||
## 界面改动
|
||||
|
||||
### 公开页
|
||||
|
||||
- `PublicIoOrderPage`:产品卡增加芯片填量区。
|
||||
- `views/public/pl/transport`:由「全单一总数量」改为「按产品填量」;有品牌则芯片区。
|
||||
|
||||
### 管理端补充弹窗
|
||||
|
||||
- `PlPlanDriverFeedbackDialog`:按产品列表 + 芯片填量(不再仅一个 `feedbackQty`)。
|
||||
- 入/出库回传补充/编辑弹窗:产品行支持品牌芯片填量。
|
||||
- 归档弹窗「产品实际数量」:最终量仍独立填写;交互统一为芯片方案(替换或并存于当前表格填量,以实现时统一芯片为准)。
|
||||
|
||||
### 查看 · 运输信息 Tab
|
||||
|
||||
共用组件:`PlPlanViewTabs`(运输计划查看、归档弹窗嵌入、危化品证上传查看)。
|
||||
|
||||
- 司机回传:按产品展示回传量 + 品牌摘要。
|
||||
- 入/出库明细表:增加品牌数量列或摘要列。
|
||||
|
||||
### 标题栏按钮布局(`feedbackEditable` 时)
|
||||
|
||||
**入/出库回传**标题栏右侧(从左到右):
|
||||
|
||||
1. `补充` / `编辑` — `type="primary"` 高亮
|
||||
2. `复制现场链接`
|
||||
|
||||
(原正文底部的补充按钮移除。)
|
||||
|
||||
**司机回传**标题栏右侧(从左到右):
|
||||
|
||||
1. `补充` / `编辑` — `type="primary"` 高亮
|
||||
2. `复制司机链接`
|
||||
|
||||
两个补充按钮均高亮展示。
|
||||
|
||||
## 组件与复用
|
||||
|
||||
建议抽取公共「产品品牌数量录入」块(芯片 + 数量 + 合计回写),供:
|
||||
|
||||
- 公开现场页、公开司机页
|
||||
- 司机补充弹窗、入出库补充弹窗
|
||||
- 归档弹窗产品实际数量区
|
||||
|
||||
校验逻辑与后端 `validate_brand_qty_rows` 语义对齐(前端先拦,后端再验)。
|
||||
|
||||
## 错误处理
|
||||
|
||||
- 公开页校验失败:Toast 提示具体产品与原因(合计不符 / 未选品牌等)。
|
||||
- 管理端:`ElMessage` / 表单项级提示;提交接口 422 展示后端 detail。
|
||||
- 链接失效、计划作废:保持现有只读/拦截行为。
|
||||
|
||||
## 测试要点
|
||||
|
||||
1. 无品牌产品:现场/司机仅总量,子表无行。
|
||||
2. `any`:多品牌芯片,合计回写实际数量,落库多行。
|
||||
3. `required`:仅指定品牌可选,数量可改。
|
||||
4. 现场与司机分别保存不同品牌量后,查看 Tab 两侧独立展示;归档表单不被自动覆盖。
|
||||
5. 归档提交仍只写 `pl_plan_product_brand_qty` 并按该表入账。
|
||||
6. 标题栏:补充高亮、复制现场在补充右侧、司机区复制司机链接在补充右侧。
|
||||
7. 旧数据:无品牌子表时查看展示「—」或仅总量;司机旧 `feedback_qty` 仍可读。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不以现场/司机回传自动驱动库存记账。
|
||||
- 不改造地磅数量权威链路(地磅预填 actual_qty 规则保持;品牌量仍由人工在现场/归档填写)。
|
||||
- 不在本变更中统一 CRM 品牌主数据维护(沿用现有产品品牌)。
|
||||
|
||||
## 实现顺序建议
|
||||
|
||||
1. SQL 子表 + models/schemas
|
||||
2. 现场公共读写 + PublicIoOrderPage
|
||||
3. 司机公共读写 + 司机公开页
|
||||
4. 管理端补充弹窗 + 查看 Tab 展示与标题栏按钮
|
||||
5. 归档弹窗芯片交互对齐(最终量仍独立)
|
||||
6. 回归测试与 `docs/sql` 同步
|
||||
|
||||
## 参考
|
||||
|
||||
- 现有归档品牌量:`backend/app/services/pl_brand_archive.py`、`pl_plan_product_brand_qty`
|
||||
- 现场公开页:`web-admin/src/views/public/pl/_shared/PublicIoOrderPage.vue`
|
||||
- 司机公开页:`web-admin/src/views/public/pl/transport/index.vue`
|
||||
- 查看 Tab:`web-admin/src/components/business/pl-plan-view-tabs/index.vue`
|
||||
- 交互 Demo:`docs/demos/pl-site-driver-brand-qty-demo.html`
|
||||
Reference in new issue
Block a user