docs: record miniprogram WebView template and transport plan design.
Capture the approved card-list, ticket-based login, and reusable shell so later modules can follow the same path. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
b7d0d4a4f8
commit
da7476df5a
2 files changed
+792
No files matched your search
@@ -0,0 +1,563 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>山峡化工 · 运输计划(WebView H5 Demo)</title>
|
||||
<style>
|
||||
:root {
|
||||
--brand: #0a9f6d;
|
||||
--brand-dark: #087f57;
|
||||
--brand-soft: #e9f9f2;
|
||||
--barley: #d6a343;
|
||||
--barley-soft: #fff7e8;
|
||||
--bg: #f5f8f7;
|
||||
--surface: #ffffff;
|
||||
--text: #1f2a26;
|
||||
--muted: #6b7b74;
|
||||
--placeholder: #9aa8a1;
|
||||
--border: #dcebe4;
|
||||
--danger: #d14343;
|
||||
--warning: #cf8a19;
|
||||
--success: #189b65;
|
||||
--radius: 14px;
|
||||
--shadow: 0 6px 16px rgba(10, 159, 109, 0.08);
|
||||
--header: linear-gradient(145deg, #087f57 0%, #0a9f6d 72%, #d6a343 165%);
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
background: #dfe8e3;
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.stage {
|
||||
max-width: 980px;
|
||||
margin: 0 auto;
|
||||
padding: 20px 16px 40px;
|
||||
}
|
||||
.banner {
|
||||
background: #1f2a26;
|
||||
color: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.banner strong { color: #9fe3c4; }
|
||||
.banner code {
|
||||
background: rgba(255,255,255,.12);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
|
||||
gap: 18px;
|
||||
align-items: start;
|
||||
}
|
||||
.phone {
|
||||
max-width: 390px;
|
||||
margin: 0 auto;
|
||||
background: var(--bg);
|
||||
border-radius: 28px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 18px 40px rgba(0,0,0,.14);
|
||||
border: 8px solid #24312c;
|
||||
min-height: 760px;
|
||||
position: relative;
|
||||
}
|
||||
.phone-label {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #4d5c56;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.status-bar {
|
||||
height: 28px;
|
||||
background: var(--brand-dark);
|
||||
color: rgba(255,255,255,.85);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.header {
|
||||
background: var(--header);
|
||||
color: #fff;
|
||||
padding: 10px 14px 14px;
|
||||
}
|
||||
.header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.back { opacity: .92; font-size: 13px; }
|
||||
.header h1 { font-size: 20px; font-weight: 700; margin-top: 8px; }
|
||||
.header .hint {
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
opacity: .85;
|
||||
}
|
||||
.search {
|
||||
margin-top: 12px;
|
||||
background: rgba(255,255,255,.95);
|
||||
color: var(--placeholder);
|
||||
border-radius: 999px;
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.search .dot {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: var(--brand);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.date-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding: 10px 12px 0;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.chip {
|
||||
flex: 0 0 auto;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.chip.active {
|
||||
background: var(--brand-soft);
|
||||
border-color: #b7ebce;
|
||||
color: var(--brand-dark);
|
||||
font-weight: 600;
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
padding: 10px 12px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.tab {
|
||||
flex: 0 0 auto;
|
||||
padding: 7px 12px;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tab.active {
|
||||
background: var(--brand);
|
||||
border-color: var(--brand);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 0 12px 10px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.btn {
|
||||
flex: 0 0 auto;
|
||||
height: 34px;
|
||||
padding: 0 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--brand);
|
||||
border-color: var(--brand);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-danger {
|
||||
color: var(--danger);
|
||||
border-color: #f0c2c2;
|
||||
background: #fff7f7;
|
||||
}
|
||||
.list {
|
||||
padding: 0 12px 88px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 12px 12px 10px;
|
||||
border: 1px solid rgba(220,235,228,.8);
|
||||
cursor: pointer;
|
||||
}
|
||||
.card:active { transform: scale(.995); }
|
||||
.card-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.plan-no {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--brand-dark);
|
||||
}
|
||||
.sub {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.tag {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
.tag-warn { background: #d97706; }
|
||||
.tag-ok { background: #059669; }
|
||||
.tag-mute { background: #64748b; }
|
||||
.tag-wait { background: #cf8a19; }
|
||||
.meta {
|
||||
margin-top: 8px;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.meta b { color: var(--text); font-weight: 500; }
|
||||
.products {
|
||||
margin-top: 8px;
|
||||
background: var(--brand-soft);
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--brand-dark);
|
||||
}
|
||||
.card-foot {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.fab-bar {
|
||||
position: absolute;
|
||||
left: 0; right: 0; bottom: 0;
|
||||
padding: 10px 12px calc(12px + env(safe-area-inset-bottom, 0px));
|
||||
background: linear-gradient(180deg, transparent, var(--bg) 28%);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.fab-bar .btn { flex: 1; justify-content: center; height: 42px; font-size: 13px; }
|
||||
.fab-bar .btn-primary { flex: 1.35; }
|
||||
|
||||
/* detail */
|
||||
.detail-body { padding: 12px; padding-bottom: 96px; }
|
||||
.section {
|
||||
background: #fff;
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
margin-bottom: 10px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.section h3 {
|
||||
font-size: 13px;
|
||||
margin-bottom: 8px;
|
||||
color: var(--brand-dark);
|
||||
}
|
||||
.kv { display: flex; justify-content: space-between; gap: 12px; padding: 6px 0; font-size: 13px; border-bottom: 1px dashed var(--border); }
|
||||
.kv:last-child { border-bottom: none; }
|
||||
.kv span:first-child { color: var(--muted); flex-shrink: 0; }
|
||||
.kv span:last-child { text-align: right; }
|
||||
.actions-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
.actions-grid .btn { height: 40px; justify-content: center; }
|
||||
.note {
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
background: var(--barley-soft);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.hidden { display: none !important; }
|
||||
.choice-hint {
|
||||
margin-top: 18px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid #cfe3d9;
|
||||
font-size: 13px;
|
||||
color: #355248;
|
||||
}
|
||||
.choice-hint ol { margin: 8px 0 0 18px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="stage">
|
||||
<div class="banner">
|
||||
<strong>运输计划 · 小程序 WebView H5 Demo</strong><br />
|
||||
对齐管理端 <code>/#/pl/plan</code> 核心能力;小程序侧仅壳页 + <code>web-view</code> 加载本 H5,业务改动发 H5 即可,无需反复提审。<br />
|
||||
文件路径:<code>bak/mobile-ui/shanxia-mp-pl-plan-webview-demo.html</code>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<!-- LIST -->
|
||||
<div>
|
||||
<div class="phone-label">① 列表页(默认)</div>
|
||||
<div class="phone" id="phone-list">
|
||||
<div class="status-bar"><span>09:41</span><span>WebView</span></div>
|
||||
<header class="header">
|
||||
<div class="header-row">
|
||||
<span class="back">← 工作台</span>
|
||||
<button class="btn btn-primary" type="button" style="height:30px">+ 新增</button>
|
||||
</div>
|
||||
<h1>运输计划</h1>
|
||||
<div class="hint">登录态由小程序注入 Token · H5 调同一套 /api</div>
|
||||
<div class="search"><span class="dot"></span>计划编号 / 供方 / 需方 / 产品 / 车牌号</div>
|
||||
</header>
|
||||
|
||||
<div class="date-row">
|
||||
<button class="chip active" type="button">今天</button>
|
||||
<button class="chip" type="button">明天</button>
|
||||
<button class="chip" type="button">本周</button>
|
||||
<button class="chip" type="button">本月</button>
|
||||
<button class="chip" type="button">自定义</button>
|
||||
</div>
|
||||
|
||||
<div class="tabs" id="list-tabs">
|
||||
<button class="tab active" type="button" data-tab="pending">待安排</button>
|
||||
<button class="tab" type="button" data-tab="arranged">已安排</button>
|
||||
<button class="tab" type="button" data-tab="draft">草稿中</button>
|
||||
<button class="tab" type="button" data-tab="all">全部</button>
|
||||
<button class="tab" type="button" data-tab="inbound">入库单</button>
|
||||
<button class="tab" type="button" data-tab="outbound">出库单</button>
|
||||
<button class="tab" type="button" data-tab="direct">直销单</button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<button class="btn" type="button">复制</button>
|
||||
<button class="btn" type="button">安排运输</button>
|
||||
<button class="btn" type="button">等通知</button>
|
||||
<button class="btn" type="button">打印</button>
|
||||
<button class="btn btn-danger" type="button">作废</button>
|
||||
</div>
|
||||
|
||||
<div class="list" id="plan-list">
|
||||
<!-- cards injected -->
|
||||
</div>
|
||||
|
||||
<div class="fab-bar">
|
||||
<button class="btn" type="button">三方人车</button>
|
||||
<button class="btn btn-primary" type="button" onclick="showDetail()">查看选中计划</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DETAIL -->
|
||||
<div>
|
||||
<div class="phone-label">② 详情 / 操作(点卡片进入)</div>
|
||||
<div class="phone" id="phone-detail">
|
||||
<div class="status-bar"><span>09:41</span><span>WebView</span></div>
|
||||
<header class="header">
|
||||
<div class="header-row">
|
||||
<span class="back" style="cursor:pointer" onclick="showList()">← 列表</span>
|
||||
<span class="tag tag-warn">待安排</span>
|
||||
</div>
|
||||
<h1>JH20260914003</h1>
|
||||
<div class="hint">出库单 · 计划日期 2026-09-14</div>
|
||||
</header>
|
||||
|
||||
<div class="detail-body">
|
||||
<div class="section">
|
||||
<h3>基本信息</h3>
|
||||
<div class="kv"><span>跟进状态</span><span><b>待安排</b></span></div>
|
||||
<div class="kv"><span>送货时间</span><span>09-14 14:00</span></div>
|
||||
<div class="kv"><span>车牌号</span><span>皖H·D1234 / 挂 皖H·G88</span></div>
|
||||
<div class="kv"><span>人员</span><span>司机 张伟 · 押运 李强</span></div>
|
||||
<div class="kv"><span>跟进人</span><span>王调度</span></div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>产品摘要</h3>
|
||||
<div class="products" style="margin:0">
|
||||
甲醇 32 吨 · 包装吨袋<br />
|
||||
供方:安庆山峡 · 需方:合肥某化
|
||||
</div>
|
||||
<div class="note">移动端以卡片摘要为主;完整编辑走「编辑计划」全屏表单(同管理端字段,分步/分区展示)。</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>快捷操作(对齐管理端工具栏)</h3>
|
||||
<div class="actions-grid">
|
||||
<button class="btn btn-primary" type="button">安排运输</button>
|
||||
<button class="btn" type="button">编辑计划</button>
|
||||
<button class="btn" type="button">复制计划</button>
|
||||
<button class="btn" type="button">等客户通知</button>
|
||||
<button class="btn" type="button">打印单据</button>
|
||||
<button class="btn btn-danger" type="button">作废</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fab-bar">
|
||||
<button class="btn" type="button" onclick="showList()">返回列表</button>
|
||||
<button class="btn btn-primary" type="button">安排运输</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="choice-hint">
|
||||
<strong>请确认 Demo 方向(回复编号即可)</strong>
|
||||
<ol>
|
||||
<li><b>A</b> — 列表用「卡片流」如左图(推荐,适合手机一屏信息)</li>
|
||||
<li><b>B</b> — 列表尽量仿桌面表格(横滑多列,信息更全但操作更重)</li>
|
||||
<li><b>C</b> — 先只做列表+查看+安排运输,其余打印/作废/三方人车二期再上</li>
|
||||
</ol>
|
||||
也可直接说要改哪里(Tab、按钮优先级、字段展示等)。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const plans = [
|
||||
{
|
||||
no: 'JH20260914003',
|
||||
type: '出库单',
|
||||
status: '待安排',
|
||||
tag: 'tag-warn',
|
||||
date: '2026-09-14',
|
||||
plate: '未派车',
|
||||
people: '—',
|
||||
product: '甲醇 32 吨 · 安庆山峡 → 合肥某化',
|
||||
tab: 'pending'
|
||||
},
|
||||
{
|
||||
no: 'JH20260914001',
|
||||
type: '入库单',
|
||||
status: '已安排',
|
||||
tag: 'tag-ok',
|
||||
date: '2026-09-14',
|
||||
plate: '皖H·D1234',
|
||||
people: '张伟 / 李强',
|
||||
product: '乙醇 28 吨 · 张家港 → 安庆仓',
|
||||
tab: 'arranged'
|
||||
},
|
||||
{
|
||||
no: 'JH20260913018',
|
||||
type: '直销单',
|
||||
status: '等通知',
|
||||
tag: 'tag-wait',
|
||||
date: '2026-09-13',
|
||||
plate: '苏E·5M892',
|
||||
people: '陈师傅',
|
||||
product: '冰醋酸 20 吨 · 南通 → 芜湖',
|
||||
tab: 'pending'
|
||||
},
|
||||
{
|
||||
no: 'JH20260912007',
|
||||
type: '出库单',
|
||||
status: '草稿中',
|
||||
tag: 'tag-mute',
|
||||
date: '2026-09-12',
|
||||
plate: '—',
|
||||
people: '—',
|
||||
product: '待完善产品行',
|
||||
tab: 'draft'
|
||||
}
|
||||
]
|
||||
|
||||
const listEl = document.getElementById('plan-list')
|
||||
let activeTab = 'pending'
|
||||
|
||||
function renderList() {
|
||||
const rows = activeTab === 'all'
|
||||
? plans
|
||||
: plans.filter((p) => {
|
||||
if (activeTab === 'pending') return p.status === '待安排' || p.status === '等通知'
|
||||
if (activeTab === 'arranged') return p.status === '已安排'
|
||||
if (activeTab === 'draft') return p.status === '草稿中'
|
||||
if (activeTab === 'inbound') return p.type === '入库单'
|
||||
if (activeTab === 'outbound') return p.type === '出库单'
|
||||
if (activeTab === 'direct') return p.type === '直销单'
|
||||
return true
|
||||
})
|
||||
|
||||
listEl.innerHTML = rows.map((p) => `
|
||||
<article class="card" onclick="showDetail()">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<div class="plan-no">${p.no}</div>
|
||||
<div class="sub">${p.type} · ${p.date}</div>
|
||||
</div>
|
||||
<span class="tag ${p.tag}">${p.status}</span>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<div>车牌:<b>${p.plate}</b></div>
|
||||
<div>人员:<b>${p.people}</b></div>
|
||||
</div>
|
||||
<div class="products">${p.product}</div>
|
||||
<div class="card-foot">
|
||||
<button class="btn" type="button" onclick="event.stopPropagation()">复制</button>
|
||||
<button class="btn btn-primary" type="button" onclick="event.stopPropagation()">安排运输</button>
|
||||
</div>
|
||||
</article>
|
||||
`).join('') || '<div style="padding:24px;text-align:center;color:#6b7b74">该 Tab 暂无数据</div>'
|
||||
}
|
||||
|
||||
document.getElementById('list-tabs').addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.tab')
|
||||
if (!btn) return
|
||||
document.querySelectorAll('#list-tabs .tab').forEach((t) => t.classList.remove('active'))
|
||||
btn.classList.add('active')
|
||||
activeTab = btn.dataset.tab
|
||||
renderList()
|
||||
})
|
||||
|
||||
document.querySelectorAll('.date-row .chip').forEach((chip) => {
|
||||
chip.addEventListener('click', () => {
|
||||
document.querySelectorAll('.date-row .chip').forEach((c) => c.classList.remove('active'))
|
||||
chip.classList.add('active')
|
||||
})
|
||||
})
|
||||
|
||||
function showDetail() {
|
||||
document.getElementById('phone-list').classList.add('hidden')
|
||||
document.getElementById('phone-detail').classList.remove('hidden')
|
||||
// on dual layout keep both; on narrow, scroll to detail
|
||||
document.getElementById('phone-detail').scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
function showList() {
|
||||
document.getElementById('phone-list').classList.remove('hidden')
|
||||
document.getElementById('phone-list').scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
|
||||
renderList()
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,229 @@
|
||||
# 小程序 WebView 嵌入模板 + 运输计划首发 — 设计文档
|
||||
|
||||
> **日期**: 2026-09-14
|
||||
> **状态**: 待评审
|
||||
> **方案**: 小程序通用 `web-view` 壳 + `web-admin` 移动端路由 `/#/m/*`;首个业务为管理端「运输计划」全量能力
|
||||
> **Demo**: `bak/mobile-ui/shanxia-mp-pl-plan-webview-demo.html`(卡片流列表,已选方案 A)
|
||||
|
||||
## 1. 目标
|
||||
|
||||
在微信小程序中提供与 Web 管理端 **运输管理 → 运输计划**(`/#/pl/plan-module/plan`)同等业务能力,并以 **WebView 加载 H5** 落地,使业务改动只发门户静态资源,不必每次提审小程序。
|
||||
|
||||
本次同时沉淀 **可复用模板**:后续其它管理端页面按同一骨架接入,不再复制鉴权、壳页与布局。
|
||||
|
||||
## 2. 已确认决策
|
||||
|
||||
| # | 项 | 决策 |
|
||||
|---|-----|------|
|
||||
| D1 | 列表形态 | 卡片流(Demo A),不做桌面表格横滑 |
|
||||
| D2 | 功能范围 | **全量对齐**管理端:新增/编辑、安排运输、复制、等通知、打印、作废、三方人车快速创建 |
|
||||
| D3 | H5 落点 | 在 `web-admin` 新增 `/#/m/...` 移动端路由,复用现有 API 与业务逻辑;不为 WebView 新建独立工程 |
|
||||
| D4 | 登录态 | **必须使用小程序已登录用户的 JWT**;H5 禁止再走 `/auth/login` |
|
||||
| D5 | Token 传递 | **一次性短时 ticket** 换票;URL **不得**长期携带 accessToken |
|
||||
| D6 | 模板化 | 小程序只维护一个通用 WebView 壳;后续模块只加 H5 路由 + 工作台入口 |
|
||||
| D7 | 与现网小程序页 | **并存**:现有 `pages/pl/transport-plan/*`(现场记录/现场回传)不替换、不改名混用 |
|
||||
| D8 | 视觉 | WebView 内 H5 使用小程序三墙 Token(`#0a9f6d` 等),**不**套管理端 Fresh Mint 侧栏/WorkTab;这是「嵌在小程序里的 H5」,不是管理端皮肤 |
|
||||
| D9 | 打印 | WebView 内先做单据 **预览 / 存图**;不承诺调用手机系统打印机或 Lodop |
|
||||
|
||||
## 3. 非目标
|
||||
|
||||
- 不把桌面 `el-table` / `el-dialog` 原样塞进 WebView
|
||||
- 不改运输计划后端业务规则、状态机、租户隔离与数据权限
|
||||
- 不下线现场人员原生「运输计划单」与公开 H5 免登链接
|
||||
- 不在本变更中把其它模块(入库、加油、CRM 等)迁入 WebView(只定模板,首发仅运输计划)
|
||||
- 不把 ticket 或 JWT 写入可被中间日志长期保留的 query(使用 hash 片段携带 ticket)
|
||||
|
||||
## 4. 架构(模板)
|
||||
|
||||
```text
|
||||
小程序工作台
|
||||
→ pages/common/mp-webview?path=/m/pl/plan
|
||||
1) POST /api/web/auth/mp-webview-ticket (小程序 Bearer)
|
||||
2) web-view src = {WEB_ORIGIN}/#/m/pl/plan?mpTicket={ticket}
|
||||
(Hash 路由下 query 在 # 之后,不会进 Nginx 的 path/query 日志;见 §5)
|
||||
3) H5 启动:POST /api/web/auth/mp-webview-exchange
|
||||
→ accessToken + refreshToken 写入 web-admin userStore
|
||||
4) 之后与管理端相同:Authorization: Bearer + 现有 /api/transport/plan/...
|
||||
|
||||
后续模块
|
||||
→ 同一壳页 ?path=/m/<module>
|
||||
→ 只需新增 web-admin 页面,不必再提审小程序
|
||||
```
|
||||
|
||||
```text
|
||||
┌─────────────┐ ticket ┌──────────────────┐ JWT ┌────────────┐
|
||||
│ miniprogram│ ──────────────► │ web-admin /#/m/* │ ───────────► │ FastAPI │
|
||||
│ web-view壳 │ ◄────────────── │ MpWebviewShell │ ◄─────────── │ 既有计划API │
|
||||
└─────────────┘ 401→reLaunch └──────────────────┘ 租户/权限 └────────────┘
|
||||
```
|
||||
|
||||
**边界**
|
||||
|
||||
| 单元 | 职责 | 依赖 |
|
||||
|------|------|------|
|
||||
| `pages/common/mp-webview` | 取票、拼 URL、承载 `<web-view>`、接收 H5 回登录指令 | 小程序 token、`WEB_ORIGIN`、业务域名白名单 |
|
||||
| `MpWebviewShell` | 无侧栏布局、安全区、401 桥接、禁止桌面壳 | 换票结果、`wx.miniProgram` |
|
||||
| `/#/m/pl/plan*` | 运输计划移动端 UI | 现有 `planApi` / composable / 校验 |
|
||||
| ticket API | 签发与一次性兑换 | Redis + 当前用户 session |
|
||||
|
||||
微信后台 **业务域名** 配置一次:`w.shanxiachem.com`(测试环境另配对应 IP/域名)。开发者工具本地联调使用 `WEB_ORIGIN`(如 `127.0.0.1:3006`),需在开发者工具中关闭/放宽域名校验。
|
||||
|
||||
## 5. 登录与 Token
|
||||
|
||||
### 5.1 为何不能直接复用 Web 登录
|
||||
|
||||
`web-view` 是独立浏览上下文:读不到 `uni.setStorageSync`,H5 的 `localStorage` 初始为空。两端虽都走 `/web/auth/login` 发的同一套 JWT,但 **不会自动共享**。
|
||||
|
||||
禁止:H5 弹出管理端登录页;禁止 URL 明文长期挂 `accessToken`。
|
||||
|
||||
### 5.2 Ticket 协议
|
||||
|
||||
**签发** `POST /api/web/auth/mp-webview-ticket`
|
||||
|
||||
- 鉴权:小程序当前 `Authorization: Bearer`
|
||||
- 成功:`{ ticket, expiresIn: 60 }`
|
||||
- Redis:`mp_wv_ticket:{ticket}` → `{ userId, sessionId, refreshJti? }`,TTL **60 秒**,读后删除(一次性)
|
||||
|
||||
**兑换** `POST /api/web/auth/mp-webview-exchange` body `{ ticket }`
|
||||
|
||||
- 无 Bearer(ticket 即凭证)
|
||||
- 校验 Redis 后 **复用该用户当前 session** 签发一对新的 access/refresh(或返回与小程序相同 session 下的新 access jti)
|
||||
- 成功体与登录接口对齐:`token`、`refreshToken`、`expiresIn`、`userInfo`
|
||||
- 失败:401,H5 通知小程序壳 `reLaunch` 登录页
|
||||
|
||||
**加密排除**:`mp-webview-exchange` 须加入 `API_ENCRYPT_EXCLUDE_PATHS`(与 login 同类,此时 H5 尚未持有会话密钥)。签发接口走已登录加密通道。
|
||||
|
||||
**URL 形态(约定)**
|
||||
|
||||
Hash 路由下 ticket 放在 hash 内,避免出现在 Nginx access log 的 path/query:
|
||||
|
||||
```text
|
||||
https://w.shanxiachem.com/#/m/pl/plan?mpTicket=<ticket>
|
||||
```
|
||||
|
||||
实现时 H5 启动立刻 `replace` 掉 `mpTicket`,只留干净路由。若必须进一步降低泄露,可将 ticket 仅通过 `wx.miniProgram.postMessage` 不可行(加载前 H5 收不到);因此短 TTL + 一次性是主要防护。
|
||||
|
||||
### 5.3 路由守卫(关键)
|
||||
|
||||
`/#/m/*` **不能**走现有「未登录 → `/auth/login`」逻辑,也 **不能**在换票后走桌面动态菜单注册(会挂侧栏、WorkTab)。
|
||||
|
||||
约定 `meta.isMpWebview: true`:
|
||||
|
||||
1. 允许无 `userStore.isLogin` 进入页面
|
||||
2. 页面内完成 exchange → `setLoginInfo`(或等价写入 token)
|
||||
3. **跳过** `handleDynamicRoutes` 的管理端菜单注入
|
||||
4. 401 / ticket 失败 → `wx.miniProgram.reLaunch({ url: '/pages/login/login' })`,永不 `next({ name: 'Login' })`
|
||||
|
||||
### 5.4 与「司机/现场免登 token」的区别
|
||||
|
||||
`driver_access_token` / `site_access_token` 是单据级公开链接,**禁止**用于本模板。本模板始终是员工登录态。
|
||||
|
||||
## 6. 权限与菜单
|
||||
|
||||
### 6.1 小程序工作台(入口)
|
||||
|
||||
现网「运输计划单」perms = `mini:pl:transportPlan:list`,对应 **现场回传**,保持不变。
|
||||
|
||||
新增菜单(名称与管理端对齐,避免再叫「运输计划单」):
|
||||
|
||||
| 字段 | 值 |
|
||||
|------|-----|
|
||||
| menu_name | `运输计划` |
|
||||
| path | `mp-webview` 或工作台 code `pl-plan-webview` |
|
||||
| perms | `mini:pl:plan:manage` |
|
||||
| 父级 | 运输管理(`MINI_PL_ROOT`) |
|
||||
| order_num | 排在现有「运输计划单」之后,避免打乱现场人员入口顺序 |
|
||||
|
||||
工作台配置:`enabled` + `code` → 打开通用壳 `PAGE_PATHS.mpWebview + '?path=' + encodeURIComponent('/m/pl/plan')`。
|
||||
|
||||
### 6.2 H5 / API(能力)
|
||||
|
||||
运输计划接口继续校验 **Web 菜单码** `pl:plan:*`(及现有按钮权限),不另写一套后端。
|
||||
|
||||
因此:能在小程序里办计划的角色,必须同时拥有:
|
||||
|
||||
- 小程序入口 `mini:pl:plan:manage`
|
||||
- 与桌面相同的 `pl:plan:list` 等(否则 H5 调接口 403)
|
||||
|
||||
无 Web 计划权限时:H5 展示「无权限」空态,不跳桌面 403 页。
|
||||
|
||||
## 7. 运输计划移动端(首发业务)
|
||||
|
||||
视觉与结构对齐 Demo:`bak/mobile-ui/shanxia-mp-pl-plan-webview-demo.html`。规范:`docs/miniprogram-ui-spec-sanqiang.md`。
|
||||
|
||||
| 能力 | 移动端形态 | 逻辑来源 |
|
||||
|------|------------|----------|
|
||||
| 列表 | 日期快捷 + 状态/单据 Tab + 检索 + 卡片 | `planApi` 列表筛选(`PL_PLAN_LIST_TABS`) |
|
||||
| 查看 | 全屏详情 | 现有查看字段/状态 Tag |
|
||||
| 新增/编辑 | 全屏分区表单(非桌面弹窗) | `PlPlanFormDialog` 校验与提交,拆成移动分区 |
|
||||
| 安排/调整运输 | 全屏安排页 | `usePlPlanArrangeActions` 等 |
|
||||
| 复制 | 详情/列表操作 + 确认 | 现有复制 API |
|
||||
| 等客户通知 | 同上 | 现有 wait-notice |
|
||||
| 作废 | 危险操作确认 | 现有 void |
|
||||
| 打印单据 | 预览页(入库单/送提货单/分装单/空白单) | 现有 print API;无系统打印则引导长按存图 |
|
||||
| 三方人车快速创建 | 独立全屏页 | 现有 paste-create 逻辑 |
|
||||
|
||||
列表卡片主信息:计划编号、单据类型、计划日期、跟进状态 Tag、车牌、人员摘要、产品/供需摘要。点卡片进详情。
|
||||
|
||||
**代码复用原则**:抽 composable / 纯函数,避免复制 `index.vue` 整页。桌面弹窗组件可按「移动全屏包装」复用表单子块,不在 WebView 里渲染 `el-dialog` 作为主路径。
|
||||
|
||||
建议路由(均在 `/#/m` 下):
|
||||
|
||||
```text
|
||||
/m/pl/plan 列表
|
||||
/m/pl/plan/new 新增
|
||||
/m/pl/plan/:id 详情
|
||||
/m/pl/plan/:id/edit 编辑
|
||||
/m/pl/plan/:id/arrange 安排运输
|
||||
/m/pl/plan/paste 三方人车
|
||||
/m/pl/plan/:id/print 打印预览
|
||||
```
|
||||
|
||||
## 8. 后续模块接入清单(模板)
|
||||
|
||||
下一个模块只需:
|
||||
|
||||
1. `web-admin`:`views/m/<module>/...` + 路由 `meta.isMpWebview`
|
||||
2. 套 `MpWebviewShell`
|
||||
3. 小程序工作台增加一项:同一 `mp-webview` 壳 + `path=/m/...`
|
||||
4. 角色同时具备对应 **mini 入口 perms** 与 **Web 业务 perms**
|
||||
5. 微信业务域名已配则 **不必提审**(仅改 H5)
|
||||
|
||||
禁止:每个模块再做一个 `web-view` 页面、再做一套登录注入。
|
||||
|
||||
## 9. 错误处理
|
||||
|
||||
| 情况 | 行为 |
|
||||
|------|------|
|
||||
| 未登录打开壳 | 小程序拦截,先去登录页 |
|
||||
| ticket 过期/已用 | H5 提示后 `reLaunch` 登录或重新进入工作台再开页 |
|
||||
| API 401 | 清 H5 token,通知小程序登录 |
|
||||
| API 403 | 页内无权限 |
|
||||
| 网络失败 | Toast,可重试 |
|
||||
| 非小程序打开 `/#/m/*` | 无 ticket 则提示「请从山峡化工小程序打开」,不提供 Web 登录入口(防把移动页当第二套后台) |
|
||||
|
||||
## 10. 测试要点
|
||||
|
||||
- ticket 60s 过期;第二次 exchange 失败
|
||||
- 换票后列表能拉到当前租户计划,数据范围与桌面同一账号一致
|
||||
- 全量操作在窄屏可走通(至少:新增草稿、安排、复制、等通知、作废、打开打印预览、三方人车)
|
||||
- 401 回到小程序登录,不到 `/#/auth/login`
|
||||
- 只发 `web-admin` 静态资源、不发包:卡片文案或样式变更在小程序内可见
|
||||
- 现场「运输计划单」原生页回归不受影响
|
||||
- 生产 API 加密开启时,exchange 明文、后续业务请求加密均正常
|
||||
|
||||
## 11. 实现落点(供计划拆任务,非本次编码)
|
||||
|
||||
| 层 | 路径(预期) |
|
||||
|----|----------------|
|
||||
| 后端 | `backend/app/api/public.py` 增加 ticket/exchange;Redis TTL |
|
||||
| H5 壳 | `web-admin/src/layouts/MpWebviewShell.vue`、`src/views/m/**`、守卫分支 |
|
||||
| 小程序壳 | `miniprogram/pages/common/mp-webview.vue`、`pages.json`、`config/api.js`、`workbench-menus.js` |
|
||||
| 菜单 SQL | `docs/sql/migrations/2026-09-14-mini-pl-plan-webview-menus.sql` |
|
||||
| Demo | 已有 `bak/mobile-ui/shanxia-mp-pl-plan-webview-demo.html` |
|
||||
|
||||
## 12. 风险
|
||||
|
||||
- 运输计划桌面表单极重,移动全屏分区工作量大;模板先行(壳 + ticket + 列表 + 详情),表单可按同一变更内分批提交,但 **范围仍是全量能力**,不把打印/三方人车划出本期。
|
||||
- 微信 `web-view` 同一页面只能有一个组件、分享/原生导航受限;打印与文件下载体验弱于桌面。
|
||||
- 本地 `WEB_ORIGIN` 不在业务域名内,真机必须指向已配置的测试/生产门户。
|
||||
Reference in new issue
Block a user