Files
shanxia2.0/案例html/index(1).html
T
2026-10-02 09:28:00 +08:00

1871 lines
107 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>山峡代工一体化平台</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=Noto+Sans+SC:wght@400;500;600;700&display=swap" rel="stylesheet" />
<style>
:root {
--green: #148a3c;
--green-dark: #0f7331;
--green-soft: #e8f6ec;
--green-line: #b7e0c3;
--ink: #1c2430;
--muted: #6a7380;
--line: #e4e8ee;
--paper: #f3f5f7;
--warn: #e24b4a;
--amber: #c2410c;
--blue: #1d4f91;
--shadow: 0 1px 2px rgba(28, 36, 48, 0.06), 0 8px 24px rgba(28, 36, 48, 0.04);
--sidebar: 200px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
font-family: "Noto Sans SC", "PingFang SC", "Hiragino Sans GB", sans-serif;
color: var(--ink); background: var(--paper); font-size: 13px; line-height: 1.5;
}
button, input, select, textarea { font-family: inherit; font-size: 13px; }
button { cursor: pointer; border: 0; background: none; }
.app { min-height: 100%; display: flex; flex-direction: column; }
.topbar {
height: 48px; background: linear-gradient(90deg, #117a36, #17a045 60%, #1bb34d);
color: #fff; display: flex; align-items: center; padding: 0 16px 0 12px; gap: 16px;
}
.brand { display: flex; align-items: center; gap: 8px; font-weight: 600; font-size: 14px; min-width: 210px; }
.logo { width: 28px; height: 28px; border-radius: 7px; background: rgba(255,255,255,.18); display: grid; place-items: center; }
.crumb { opacity: .92; font-size: 12px; }
.crumb b { font-weight: 500; }
.top-search {
margin-left: auto; width: 240px; height: 30px; border-radius: 15px; color: #fff; outline: none; padding: 0 12px 0 32px;
border: 1px solid rgba(255,255,255,.22); background: rgba(255,255,255,.16) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' fill='none' stroke='%23fff' stroke-width='2'%3E%3Ccircle cx='6' cy='6' r='4.5'/%3E%3Cpath d='M9.5 9.5 L12 12'/%3E%3C/svg%3E") 10px center no-repeat;
}
.top-search::placeholder { color: rgba(255,255,255,.75); }
.avatar { width: 28px; height: 28px; border-radius: 50%; background: #fff; color: var(--green); font-weight: 700; display: grid; place-items: center; }
.shell { display: flex; flex: 1; min-height: 0; }
.sidebar { width: var(--sidebar); background: #fff; border-right: 1px solid var(--line); padding: 12px 8px; flex-shrink: 0; }
.side-item { display: flex; align-items: center; gap: 8px; height: 36px; padding: 0 10px; border-radius: 8px; color: #3d4654; width: 100%; text-align: left; }
.side-item:hover { background: #f4f7f5; }
.side-item.active { background: var(--green-soft); color: var(--green-dark); font-weight: 600; }
.side-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; opacity: .4; }
.side-item.active .side-dot { opacity: 1; background: var(--green); }
.badge-n { margin-left: auto; min-width: 18px; height: 16px; padding: 0 5px; border-radius: 8px; background: var(--warn); color: #fff; font-size: 10px; display: grid; place-items: center; font-weight: 600; }
.main { flex: 1; padding: 16px 20px 28px; overflow: auto; }
.subnav { display: flex; gap: 4px; background: #fff; border: 1px solid var(--line); border-radius: 10px; padding: 4px; margin-bottom: 14px; box-shadow: var(--shadow); flex-wrap: wrap; }
.subnav button { height: 32px; padding: 0 12px; border-radius: 8px; color: #4b5563; font-weight: 500; white-space: nowrap; }
.subnav button.on { background: var(--green-soft); color: var(--green-dark); font-weight: 650; }
.page-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 12px; margin-bottom: 14px; flex-wrap: wrap; }
.page-head h1 { font-size: 18px; font-weight: 650; }
.page-head p { color: var(--muted); margin-top: 4px; }
.actions { display: flex; gap: 8px; flex-wrap: wrap; }
.btn { height: 32px; padding: 0 12px; border-radius: 8px; font-weight: 500; border: 1px solid var(--line); background: #fff; }
.btn:hover { border-color: #c9d2cc; }
.btn-primary { background: var(--green); color: #fff; border-color: var(--green); }
.btn-primary:hover { background: var(--green-dark); }
.btn-sm { height: 26px; padding: 0 8px; font-size: 12px; }
.alerts { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; }
.alert { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 8px; background: #fff7ed; color: var(--amber); border: 1px solid #fed7aa; font-size: 12px; }
.alert.red { background: #fef2f2; color: #b91c1c; border-color: #fecaca; }
.alert.ok { background: var(--green-soft); color: var(--green-dark); border-color: var(--green-line); }
.stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 14px; }
.stat { background: #fff; border: 1px solid var(--line); border-radius: 12px; padding: 14px 16px; box-shadow: var(--shadow); }
.stat .k { color: var(--muted); font-size: 12px; }
.stat .v { font-size: 22px; font-weight: 700; margin-top: 4px; letter-spacing: -.03em; }
.stat .s { font-size: 11px; color: var(--muted); margin-top: 2px; }
.grid { display: grid; grid-template-columns: 1.35fr .9fr; gap: 12px; align-items: start; }
.stack { display: flex; flex-direction: column; gap: 12px; }
.card { background: #fff; border: 1px solid var(--line); border-radius: 12px; box-shadow: var(--shadow); overflow: hidden; }
.card-h { display: flex; align-items: center; justify-content: space-between; padding: 12px 14px; border-bottom: 1px solid var(--line); }
.card-h h2 { font-size: 14px; font-weight: 650; }
.card-b { padding: 12px 14px; }
.quick { display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 8px; }
.qbtn { text-align: left; border: 1px solid var(--line); border-radius: 10px; padding: 12px; background: #fbfcfd; min-height: 92px; }
.qbtn:hover { border-color: var(--green-line); background: #fff; }
.qbtn .ico { width: 28px; height: 28px; border-radius: 8px; display: grid; place-items: center; margin-bottom: 8px; font-weight: 700; font-size: 12px; background: var(--green-soft); color: var(--green-dark); }
.qbtn h3 { font-size: 13px; }
.qbtn p { color: var(--muted); font-size: 12px; margin-top: 4px; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 8px 6px; border-bottom: 1px solid #eef1f4; font-size: 12px; }
th { color: var(--muted); font-weight: 500; }
tr:hover td { background: #f8faf9; }
.st { display: inline-flex; align-items: center; height: 20px; padding: 0 7px; border-radius: 99px; font-size: 11px; font-weight: 600; }
.st-run { background: var(--green-soft); color: var(--green-dark); }
.st-fix { background: #fff7ed; color: var(--amber); }
.st-stop { background: #f3f4f6; color: #4b5563; }
.st-due { background: #fef2f2; color: #b91c1c; }
.timeline { display: flex; flex-direction: column; gap: 0; }
.tl { display: grid; grid-template-columns: 72px 1fr; gap: 8px; padding: 8px 0; border-bottom: 1px solid #eef1f4; }
.tl:last-child { border: 0; }
.tl .t { color: var(--muted); font-size: 11px; }
.tl b { font-weight: 600; }
.tl .d { color: var(--muted); }
.bars { display: flex; flex-direction: column; gap: 8px; }
.bar-row { display: grid; grid-template-columns: 88px 1fr 64px; gap: 8px; align-items: center; }
.bar-track { height: 8px; background: #eef1f4; border-radius: 99px; overflow: visible; position: relative; }
.bar-fill { height: 100%; background: linear-gradient(90deg, #148a3c, #3cb464); border-radius: 99px; }
.bar-fill.warn { background: linear-gradient(90deg, #e24b4a, #f97316); }
.bar-mark { position: absolute; top: -3px; height: 14px; width: 2px; background: var(--warn); border-radius: 1px; }
.bar-row .n { color: var(--muted); text-align: right; font-variant-numeric: tabular-nums; }
.rate-over { color: #b91c1c; font-weight: 650; }
.filters { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; }
.filters input, .filters select, .form input, .form select, .form textarea {
height: 32px; border: 1px solid var(--line); border-radius: 8px; padding: 0 10px; background: #fff; outline: none;
}
.form textarea { height: 64px; padding: 8px 10px; resize: vertical; }
.form { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.form label { display: flex; flex-direction: column; gap: 4px; color: var(--muted); font-size: 12px; }
.form .full { grid-column: 1 / -1; }
.form .span2 { grid-column: span 2; }
.file-box {
border: 1px dashed var(--green-line); border-radius: 8px; background: #f7fbf8;
padding: 8px 10px; min-height: 64px; display: flex; flex-direction: column; gap: 6px;
}
.file-box input[type=file] { height: auto; padding: 0; border: 0; background: transparent; }
.file-hint { color: var(--amber); font-size: 11px; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chip {
display: inline-flex; align-items: center; height: 22px; padding: 0 8px; border-radius: 99px;
background: var(--green-soft); color: var(--green-dark); font-size: 11px; font-weight: 600; border: 0;
}
.chip.ghost { background: #f3f4f6; color: #4b5563; cursor: default; }
.chip:disabled { opacity: .65; cursor: default; }
dialog {
border: 1px solid var(--line); border-radius: 12px; padding: 0; box-shadow: var(--shadow);
max-width: min(720px, 92vw); width: 720px;
}
dialog.apply-dlg { max-width: min(760px, 94vw); width: 760px; }
dialog::backdrop { background: rgba(28, 36, 48, .35); }
.dlg-h { display: flex; align-items: center; justify-content: space-between; padding: 12px 14px; border-bottom: 1px solid var(--line); }
.dlg-h.green { background: #eef8f1; }
.dlg-h h3 { font-size: 14px; }
.dlg-x { width: 28px; height: 28px; border-radius: 6px; color: #5b6572; font-size: 18px; line-height: 1; }
.dlg-x:hover { background: #e4eee7; }
.dlg-b { padding: 14px; max-height: 70vh; overflow: auto; }
.dlg-b img { max-width: 100%; border-radius: 8px; display: block; }
.dlg-f { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 16px; border-top: 1px solid var(--line); }
.apply { display: grid; grid-template-columns: 1fr 1fr; gap: 16px 28px; }
.apply .row { display: grid; grid-template-columns: 88px 1fr; gap: 10px; align-items: start; }
.apply .full { grid-column: 1 / -1; }
.apply .lab { color: var(--ink); padding-top: 7px; text-align: right; }
.apply input, .apply select, .apply textarea {
width: 100%; height: 32px; border: 1px solid var(--line); border-radius: 8px; padding: 0 10px; background: #fff; outline: none;
}
.apply textarea { height: 88px; padding: 8px 10px; resize: vertical; }
.star { color: #e24b4a; margin-right: 2px; }
.stepper { display: flex; height: 32px; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; background: #fff; }
.stepper button { width: 36px; background: #f7f8fa; color: #6a7380; }
.stepper button:hover { background: #eef1f4; }
.stepper input { flex: 1; border: 0; border-radius: 0; border-left: 1px solid var(--line); border-right: 1px solid var(--line); text-align: center; height: 32px; width: auto; }
.upload {
width: 132px; height: 108px; border: 1px dashed var(--green-line); border-radius: 10px;
background: #f4faf6; display: flex; flex-direction: column; align-items: center; justify-content: center;
color: var(--green); gap: 4px; cursor: pointer; font-weight: 500;
}
.ocr-box .upload { width: 100%; height: 72px; }
.upload:hover { background: #ebf7ef; }
.upload input { display: none; }
.upload .plus { font-size: 22px; line-height: 1; font-weight: 400; }
.hint { color: var(--muted); font-size: 11px; margin-top: 8px; }
.hidden { display: none !important; }
.toast {
position: fixed; right: 20px; bottom: 20px; background: #1c2430; color: #fff;
padding: 10px 14px; border-radius: 8px; display: none; z-index: 50;
}
.toast.show { display: block; }
.pie-wrap { display: flex; align-items: center; gap: 16px; }
.legend { display: flex; flex-direction: column; gap: 6px; }
.legend span { display: flex; align-items: center; gap: 6px; }
.sw { width: 8px; height: 8px; border-radius: 2px; }
.cell-no { color: var(--green-dark); font-weight: 600; }
.muted { color: var(--muted); }
.row-link { cursor: pointer; }
.need-tag { display: inline-flex; height: 18px; padding: 0 6px; border-radius: 4px; background: #fef2f2; color: #b91c1c; font-size: 10px; font-weight: 700; margin-left: 4px; }
.need-tag.bao { background: #fff7ed; color: var(--amber); }
.remain { font-variant-numeric: tabular-nums; font-weight: 650; }
.remain.warn { color: var(--amber); }
.remain.bad { color: #b91c1c; }
.ocr-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 12px; }
.ocr-box {
border: 1px dashed var(--green-line); border-radius: 12px; background: #f7fbf8; padding: 12px;
min-height: 148px; display: flex; flex-direction: column; gap: 8px;
}
.ocr-box h4 { font-size: 13px; }
.ocr-preview { width: 100%; max-height: 120px; object-fit: contain; border-radius: 8px; background: #fff; border: 1px solid var(--line); }
.ocr-status { font-size: 12px; color: var(--amber); min-height: 18px; }
.ocr-status.ok { color: var(--green-dark); }
.split-h { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 4px; }
.split-h h4 { font-size: 13px; margin-bottom: 8px; padding-bottom: 6px; border-bottom: 1px solid var(--line); }
.kv { display: grid; grid-template-columns: 108px 1fr; gap: 6px 10px; font-size: 12px; }
.kv .lab { color: var(--muted); text-align: right; }
.kv .val { font-weight: 500; }
.sheet { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.qty-bar { height: 8px; background: #eef1f4; border-radius: 99px; overflow: hidden; margin-top: 8px; }
.qty-fill { height: 100%; background: linear-gradient(90deg, #148a3c, #3cb464); }
.qty-fill.warn { background: linear-gradient(90deg, #e24b4a, #f97316); }
.plan-tabs { display: flex; gap: 6px; margin-bottom: 10px; flex-wrap: wrap; }
.plan-tabs button { height: 28px; padding: 0 10px; border-radius: 8px; border: 1px solid var(--line); background: #fff; }
.plan-tabs button.on { background: var(--green-soft); border-color: var(--green-line); color: var(--green-dark); font-weight: 650; }
.plan-table tr.row-wait td { background: #fdecec; }
.plan-foot { display: flex; justify-content: space-between; align-items: center; padding: 10px 14px; border-top: 1px solid var(--line); }
.empty { color: var(--muted); padding: 28px 0; text-align: center; }
dialog.wide-dlg { max-width: min(1080px, 96vw); width: 1080px; }
@media (max-width: 1100px) {
.stats, .grid, .quick, .form, .apply, .ocr-pair, .split-h, .sheet { grid-template-columns: 1fr; }
.sidebar { display: none; }
.form .span2 { grid-column: auto; }
}
</style>
</head>
<body>
<div class="app">
<header class="topbar">
<div class="brand">
<div class="logo" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"><path d="M4 16c4-9 12-9 16 0" stroke="#fff" stroke-width="2"/><circle cx="12" cy="9" r="2.2" fill="#fff"/></svg>
</div>
山峡代工一体化平台
</div>
<div class="crumb">首页 / <b id="crumbMod">车辆管理</b> / <span id="crumb">总览</span></div>
<input class="top-search" id="globalSearch" placeholder="搜索车牌、单号、证号" />
<div class="avatar">王</div>
</header>
<div class="shell">
<aside class="sidebar">
<button class="side-item" type="button">收藏</button>
<button class="side-item" type="button">财务发票</button>
<button class="side-item" type="button">CRM 管理</button>
<button class="side-item" type="button">采购管理</button>
<button class="side-item" type="button">仓储管理</button>
<button class="side-item" type="button"><span class="side-dot"></span>运营管理</button>
<button class="side-item active" type="button" data-mod="fleet"><span class="side-dot"></span>车辆管理 <span class="badge-n" id="sideBadge">4</span></button>
<button class="side-item" type="button">质检管理</button>
<button class="side-item" type="button" data-mod="plan"><span class="side-dot"></span>规划管理 <span class="badge-n" id="planBadge">0</span></button>
<button class="side-item" type="button" data-mod="cert"><span class="side-dot"></span>购买证管理 <span class="badge-n" id="certBadge">0</span></button>
</aside>
<main class="main">
<div id="mod-fleet">
<nav class="subnav" id="subnav">
<button class="on" type="button" data-view="dash">总览</button>
<button type="button" data-view="fleet">车辆台账</button>
<button type="button" data-view="fuel">加油登记</button>
<button type="button" data-view="fuelwarn">油耗预警</button>
<button type="button" data-view="etc">ETC 申请登记</button>
<button type="button" data-view="maint">维修保养</button>
<button type="button" data-view="stats">统计</button>
</nav>
<section id="view-dash">
<div class="page-head">
<div>
<h1>危化品车辆作业</h1>
<p>公司自有危货车辆的加油、ETC 申请登记、维修保养,以及油耗与费用统计。</p>
</div>
<div class="actions">
<button class="btn" type="button" onclick="showView('fuel')">登记加油</button>
<button class="btn" type="button" onclick="showView('etc')">ETC 申请</button>
<button class="btn btn-primary" type="button" onclick="showView('maint')">登记维保</button>
</div>
</div>
<div class="alerts" id="dashAlerts"></div>
<div class="stats" id="dashStats"></div>
<div class="grid">
<div class="stack">
<article class="card">
<div class="card-h"><h2>日常登记</h2><span style="color:var(--muted);font-size:12px">同一车辆贯穿三类业务</span></div>
<div class="card-b">
<div class="quick">
<button class="qbtn" type="button" onclick="showView('fuel')"><div class="ico">油</div><h3>加油</h3><p>油品、升数、金额、里程、附件</p></button>
<button class="qbtn" type="button" onclick="showView('fuelwarn')"><div class="ico">警</div><h3>油耗预警</h3><p>超过 0.33 L/km 即预警</p></button>
<button class="qbtn" type="button" onclick="showView('etc')"><div class="ico">E</div><h3>ETC 申请</h3><p>自有车牌、期初公里、附件</p></button>
<button class="qbtn" type="button" onclick="showView('maint')"><div class="ico">保</div><h3>维修保养</h3><p>进厂、项目、费用、下次保养</p></button>
</div>
</div>
</article>
<article class="card">
<div class="card-h"><h2>车辆运行状态</h2><button class="btn" type="button" onclick="showView('fleet')">台账</button></div>
<div class="card-b" style="padding-top:4px"><div id="dashFleet"></div></div>
</article>
</div>
<div class="stack">
<article class="card">
<div class="card-h"><h2>最近登记</h2></div>
<div class="card-b"><div class="timeline" id="dashTl"></div></div>
</article>
<article class="card">
<div class="card-h"><h2>本月单车油耗(升)</h2><button class="btn" type="button" onclick="showView('fuelwarn')">油耗预警</button></div>
<div class="card-b"><div class="bars" id="dashBars"></div></div>
</article>
</div>
</div>
</section>
<section id="view-fleet" class="hidden">
<div class="page-head">
<div><h1>车辆台账</h1><p>危货牵引车 / 罐车基础信息与资质到期。</p></div>
</div>
<div class="filters">
<input id="fleetQ" placeholder="车牌 / 驾驶员" oninput="renderFleet()" />
<select id="fleetSt" onchange="renderFleet()">
<option value="">全部状态</option>
<option>运行</option>
<option>维保中</option>
<option>停运</option>
</select>
</div>
<article class="card"><div class="card-b" style="overflow:auto" id="fleetTable"></div></article>
</section>
<section id="view-fuel" class="hidden">
<div class="page-head">
<div><h1>加油登记</h1><p>按车记录加油,须上传加油小票或发票,并登记当前里程以便核对油耗。</p></div>
</div>
<article class="card" style="margin-bottom:12px">
<div class="card-h"><h2>新增加油</h2></div>
<div class="card-b">
<form class="form" id="fuelForm" onsubmit="return addFuel(event)">
<label>车辆<select name="plate" required></select></label>
<label>日期<input name="date" type="date" required /></label>
<label>加油站<input name="station" required placeholder="如 中石化猇亭站" /></label>
<label>油品<select name="oil"><option>0# 柴油</option><option>-10# 柴油</option><option>LNG</option></select></label>
<label>升数<input name="liters" type="number" step="0.01" required /></label>
<label>金额(元)<input name="amount" type="number" step="0.01" required /></label>
<label>当前里程(km)<input name="km" type="number" required /></label>
<label class="span2">备注<input name="note" placeholder="可选" /></label>
<label class="full">附件(必传)
<div class="file-box">
<input name="attach" type="file" required multiple accept="image/*,.pdf,application/pdf" onchange="previewFuelFiles(this)" />
<span class="file-hint">须上传加油小票、发票或加油凭证(图片或 PDF,可多选)</span>
<div class="chips" id="fuelFileChips"></div>
</div>
</label>
<label style="justify-content:flex-end"><span>&nbsp;</span><button class="btn btn-primary" type="submit">保存登记</button></label>
</form>
</div>
</article>
<article class="card"><div class="card-h"><h2>加油记录</h2></div><div class="card-b" id="fuelTable"></div></article>
</section>
<section id="view-fuelwarn" class="hidden">
<div class="page-head">
<div>
<h1>油耗预警</h1>
<p>按相邻两次加油核对每公里油耗。标准:33 L / 100 km = <b>0.33 L/km</b>,超过即预警。</p>
</div>
</div>
<div class="stats" id="fuelwarnKpis"></div>
<div class="filters">
<select id="fuelwarnSt" onchange="renderFuelWarn()">
<option value="">全部区间</option>
<option value="预警">仅超标预警</option>
<option value="正常">仅正常</option>
<option value="不足">数据不足 / 异常</option>
</select>
<input id="fuelwarnQ" placeholder="车牌" oninput="renderFuelWarn()" />
</div>
<article class="card" style="margin-bottom:12px">
<div class="card-h"><h2>单车油耗对照(L/km)</h2><span style="color:var(--muted);font-size:12px">红线为 0.33</span></div>
<div class="card-b"><div class="bars" id="fuelwarnBars"></div></div>
</article>
<article class="card">
<div class="card-h"><h2>区间明细</h2></div>
<div class="card-b" id="fuelwarnTable"></div>
</article>
</section>
<section id="view-etc" class="hidden">
<div class="page-head">
<div><h1>ETC 申请登记</h1><p>自有车辆开通 / 充值 ETC,须选择车牌、填写期初公里并上传附件。</p></div>
<div class="actions">
<button class="btn btn-primary" type="button" onclick="openEtcDlg()">新增申请</button>
</div>
</div>
<article class="card"><div class="card-h"><h2>申请记录</h2></div><div class="card-b" id="etcTable"></div></article>
</section>
<section id="view-maint" class="hidden">
<div class="page-head">
<div><h1>维修保养登记</h1><p>维修、定期保养、年审、罐检进厂出厂一并登记。</p></div>
</div>
<article class="card" style="margin-bottom:12px">
<div class="card-h"><h2>新增维保</h2></div>
<div class="card-b">
<form class="form" id="maintForm" onsubmit="return addMaint(event)">
<label>车辆<select name="plate" required></select></label>
<label>类型<select name="type"><option>定期保养</option><option>故障维修</option><option>年审</option><option>罐体检验</option></select></label>
<label>进厂日期<input name="inDate" type="date" required /></label>
<label>出厂日期<input name="outDate" type="date" /></label>
<label>承修单位<input name="shop" required placeholder="如 宜昌安达汽修" /></label>
<label>费用(元)<input name="amount" type="number" step="0.01" required /></label>
<label>下次保养里程<input name="nextKm" type="number" placeholder="km" /></label>
<label class="span2">项目说明<input name="item" required placeholder="更换机油、刹车片等" /></label>
<label><span>&nbsp;</span><button class="btn btn-primary" type="submit">保存登记</button></label>
</form>
</div>
</article>
<article class="card"><div class="card-h"><h2>维保记录</h2></div><div class="card-b" id="maintTable"></div></article>
</section>
<section id="view-stats" class="hidden">
<div class="page-head">
<div><h1>统计</h1><p>本月油耗、维保费用与 ETC 申请笔数,辅助单车成本对照。</p></div>
</div>
<div class="stats" id="statKpis"></div>
<div class="grid">
<article class="card">
<div class="card-h"><h2>单车加油量</h2></div>
<div class="card-b"><div class="bars" id="statBars"></div></div>
</article>
<article class="card">
<div class="card-h"><h2>本月费用构成</h2></div>
<div class="card-b">
<div class="pie-wrap">
<svg width="140" height="140" viewBox="0 0 32 32" id="pie"></svg>
<div class="legend" id="pieLegend"></div>
</div>
</div>
</article>
</div>
<article class="card" style="margin-top:12px">
<div class="card-h"><h2>资质到期一览</h2></div>
<div class="card-b" id="certTable"></div>
</article>
</section>
</div>
<div id="mod-plan" class="hidden">
<nav class="subnav" id="planSubnav">
<button class="on" type="button" data-view="plan">运输计划</button>
</nav>
<section id="view-plan">
<div class="page-head">
<div>
<h1>运输计划</h1>
<p>易制毒 / 易制爆单据须先关联购买证与运输证;计划完成后按实发数量扣减购买证余量。</p>
</div>
<div class="actions">
<button class="btn" type="button" onclick="showMod('cert')">购买证管理</button>
<button class="btn btn-primary" type="button" onclick="openPlanDlg()">新增计划</button>
</div>
</div>
<div class="alerts" id="planAlerts"></div>
<div class="stats" id="planStats"></div>
<div class="plan-tabs" id="planTabs">
<button class="on" type="button" data-tab="全部">全部</button>
<button type="button" data-tab="出库单">出库单</button>
<button type="button" data-tab="入直单">入直单</button>
<button type="button" data-tab="直销单">直销单</button>
<button type="button" data-tab="易制毒">易制毒</button>
<button type="button" data-tab="易制爆">易制爆</button>
</div>
<div class="filters">
<input id="planQ" placeholder="单号 / 品名 / 客户 / 车牌" oninput="renderPlans()" />
<select id="planSt" onchange="renderPlans()">
<option value="">全部状态</option>
<option>待安排</option>
<option>已安排</option>
<option>已完成</option>
</select>
</div>
<article class="card">
<div class="card-h"><h2>计划列表</h2><span class="muted" id="planCount"></span></div>
<div class="card-b" style="overflow:auto" id="planTable"></div>
</article>
</section>
</div>
<div id="mod-cert" class="hidden">
<nav class="subnav" id="certSubnav">
<button class="on" type="button" data-view="list">证件列表</button>
<button type="button" data-view="detail" id="navDetail" disabled>证件信息</button>
</nav>
<section id="view-cert-list">
<div class="page-head">
<div>
<h1>购买证 / 运输证</h1>
<p>上传识别购买备案证明与运输备案证明。按购买证数量给同一运输公司车辆分派运量;运输证余量 ≤ 64 吨预警。</p>
</div>
<div class="actions">
<button class="btn" type="button" onclick="openCertDlg(true)">演示识别样例</button>
<button class="btn btn-primary" type="button" onclick="openCertDlg(false)">识别购买证 + 运输证</button>
</div>
</div>
<div class="alerts" id="listAlerts"></div>
<div class="stats" id="listStats"></div>
<div class="filters">
<input id="certQ" placeholder="证号 / 品名 / 购买单位 / 车号" oninput="renderCertList()" />
<select id="certSt" onchange="renderCertList()">
<option value="">全部状态</option>
<option>有余量</option>
<option>余量预警</option>
<option>运输证预警</option>
<option>已用尽</option>
<option>即将到期</option>
</select>
</div>
<article class="card">
<div class="card-h"><h2>识别生成记录</h2><span class="muted" id="listCount"></span></div>
<div class="card-b" style="overflow:auto" id="certListTable"></div>
</article>
</section>
<section id="view-cert-detail" class="hidden">
<div class="page-head">
<div>
<h1 id="detailTitle">证件信息</h1>
<p id="detailSub">由购买证与运输证识别合并生成</p>
</div>
<div class="actions">
<button class="btn" type="button" onclick="showCertView('list')">返回列表</button>
<button class="btn" type="button" onclick="showMod('plan')">去计划关联</button>
<button class="btn btn-primary" type="button" onclick="openCertDlg(false)">再识别一条</button>
</div>
</div>
<div class="stats" id="detailStats"></div>
<div class="sheet" id="detailSheet"></div>
<article class="card" style="margin-top:12px">
<div class="card-h">
<h2>车辆分派(同一运输公司共用一张运输证)</h2>
<div class="actions">
<button class="btn" type="button" onclick="splitBuyToVehicles()">按购买证量均分</button>
<button class="btn btn-primary" type="button" onclick="openDispatchDlg()">分派车辆</button>
</div>
</div>
<div class="card-b">
<p class="muted" id="dispatchHint" style="margin-bottom:10px"></p>
<div id="detailDispatch"></div>
</div>
</article>
<article class="card" style="margin-top:12px">
<div class="card-h"><h2>已关联计划</h2></div>
<div class="card-b" id="detailLinks"></div>
</article>
</section>
</div>
</main>
</div>
</div>
<div class="toast" id="toast">已保存</div>
<dialog id="attachDialog">
<div class="dlg-h">
<h3 id="attachTitle">附件</h3>
<button class="btn" type="button" onclick="closeAttach()">关闭</button>
</div>
<div class="dlg-b" id="attachBody"></div>
</dialog>
<dialog class="apply-dlg" id="etcDialog">
<div class="dlg-h green">
<h3>新增申请</h3>
<button class="dlg-x" type="button" aria-label="关闭" onclick="closeEtcDlg()">×</button>
</div>
<form id="etcForm" onsubmit="return false">
<div class="dlg-b">
<div class="apply">
<div class="row">
<div class="lab"><span class="star">*</span>申请人</div>
<select name="applicant" required></select>
</div>
<div class="row">
<div class="lab"><span class="star">*</span>车牌号</div>
<select name="plate" required onchange="syncEtcKm()"></select>
</div>
<div class="row">
<div class="lab"><span class="star">*</span>充值类型</div>
<select name="type" required>
<option>ETC</option>
<option>油卡</option>
</select>
</div>
<div class="row">
<div class="lab"><span class="star">*</span>申请日期</div>
<input name="date" type="date" required />
</div>
<div class="row full">
<div class="lab"><span class="star">*</span>期初公里数</div>
<div class="stepper">
<button type="button" onclick="stepKm(-1)">−</button>
<input name="km" type="number" min="0" required placeholder="请输入期初公里数" />
<button type="button" onclick="stepKm(1)">+</button>
</div>
</div>
<div class="row full">
<div class="lab"><span class="star">*</span>附件</div>
<div>
<label class="upload">
<input name="attach" type="file" required multiple accept="image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,application/pdf" onchange="previewEtcFiles(this)" />
<span class="plus">+</span>
<span>上传附件</span>
</label>
<div class="hint">支持图片、视频、PDF、Word、Excel 等文件;可拖拽或在此区域粘贴上传</div>
<div class="chips" id="etcFileChips" style="margin-top:8px"></div>
</div>
</div>
<div class="row full">
<div class="lab">备注</div>
<textarea name="note" placeholder="备注"></textarea>
</div>
</div>
</div>
<div class="dlg-f">
<button class="btn" type="button" onclick="closeEtcDlg()">× 取消</button>
<button class="btn" type="button" onclick="saveEtc(false)">✓ 保存</button>
<button class="btn btn-primary" type="button" onclick="saveEtc(true)">↑ 保存并提交</button>
</div>
</form>
</dialog>
<dialog class="apply-dlg" id="planDialog">
<div class="dlg-h green">
<h3>新增计划</h3>
<button class="dlg-x" type="button" aria-label="关闭" onclick="closePlanDlg()">×</button>
</div>
<form id="planForm" onsubmit="return false">
<div class="dlg-b">
<div class="apply">
<div class="row"><div class="lab"><span class="star">*</span>单据类型</div>
<select name="type" required><option>出库单</option><option>入直单</option><option>直销单</option></select>
</div>
<div class="row"><div class="lab"><span class="star">*</span>计划日期</div><input name="date" type="date" required /></div>
<div class="row"><div class="lab"><span class="star">*</span>品名</div><input name="product" required placeholder="如 硫酸" /></div>
<div class="row"><div class="lab">管控</div>
<select name="ctrl"><option value="">普通危化</option><option>易制毒</option><option>易制爆</option></select>
</div>
<div class="row"><div class="lab"><span class="star">*</span>计划数量</div><input name="planQty" type="number" step="0.01" required /></div>
<div class="row"><div class="lab">单位</div><input name="unit" value="吨" /></div>
<div class="row"><div class="lab">装货地</div><input name="loadFrom" /></div>
<div class="row"><div class="lab">送货地</div><input name="deliverTo" /></div>
<div class="row full"><div class="lab">客户 / 收货</div><input name="customer" /></div>
</div>
</div>
<div class="dlg-f">
<button class="btn" type="button" onclick="closePlanDlg()">取消</button>
<button class="btn btn-primary" type="button" onclick="savePlan()">保存待安排</button>
</div>
</form>
</dialog>
<dialog class="apply-dlg" id="dispatchDialog">
<div class="dlg-h green">
<h3>分派车辆运输量</h3>
<button class="dlg-x" type="button" aria-label="关闭" onclick="closeDispatchDlg()">×</button>
</div>
<form id="dispatchForm" onsubmit="return false">
<div class="dlg-b">
<p class="muted" id="dispatchDlgHint" style="margin-bottom:12px"></p>
<div class="apply">
<div class="row full"><div class="lab">运输公司</div><input name="company" readonly /></div>
<div class="row"><div class="lab"><span class="star">*</span>车辆</div>
<select name="plate" required onchange="syncDispatchDriver()"></select>
</div>
<div class="row"><div class="lab">驾驶员</div><input name="driver" /></div>
<div class="row full"><div class="lab"><span class="star">*</span>分派运量</div>
<input name="qty" type="number" step="0.01" min="0.01" required placeholder="不超过购买证未分派量" />
</div>
</div>
</div>
<div class="dlg-f">
<button class="btn" type="button" onclick="closeDispatchDlg()">取消</button>
<button class="btn btn-primary" type="button" onclick="saveDispatch()">确认分派</button>
</div>
</form>
</dialog>
<dialog class="apply-dlg" id="linkCertDialog">
<div class="dlg-h green">
<h3>关联购买证 / 运输证</h3>
<button class="dlg-x" type="button" aria-label="关闭" onclick="closeLinkCertDlg()">×</button>
</div>
<div class="dlg-b">
<p class="muted" id="linkCertHint" style="margin-bottom:10px"></p>
<div id="linkCertTable"></div>
</div>
<div class="dlg-f">
<button class="btn" type="button" onclick="closeLinkCertDlg()">取消</button>
<button class="btn" type="button" onclick="showMod('cert');closeLinkCertDlg()">去识别新证</button>
</div>
</dialog>
<dialog class="wide-dlg" id="certDialog">
<div class="dlg-h green">
<h3>识别购买证 + 运输证</h3>
<button class="dlg-x" type="button" aria-label="关闭" onclick="closeCertDlg()">×</button>
</div>
<form id="certForm" onsubmit="return false">
<div class="dlg-b">
<div class="ocr-pair">
<div class="ocr-box">
<h4>第二类、第三类易制毒化学品购买备案证明</h4>
<p class="hint">上传购买证照片或扫描件,读取编号、购买单位、品名与数量。</p>
<label class="upload">
<input id="buyFile" type="file" accept="image/*,.pdf" onchange="recognize('buy', this)" />
<span class="plus">+</span>
<span>上传购买证</span>
</label>
<img class="ocr-preview hidden" id="buyPreview" alt="购买证预览" />
<div class="ocr-status" id="buyStatus">待上传</div>
</div>
<div class="ocr-box">
<h4>第三类易制毒化学品运输备案证明</h4>
<p class="hint">上传运输证,读取编号、货主、运输单位、车号、路线与运量。</p>
<label class="upload">
<input id="transFile" type="file" accept="image/*,.pdf" onchange="recognize('trans', this)" />
<span class="plus">+</span>
<span>上传运输证</span>
</label>
<img class="ocr-preview hidden" id="transPreview" alt="运输证预览" />
<div class="ocr-status" id="transStatus">待上传</div>
</div>
</div>
<div class="split-h">
<div>
<h4>购买证信息</h4>
<div class="form" style="grid-template-columns:1fr 1fr">
<label><span><span class="star">*</span>编号</span><input name="buyNo" required /></label>
<label>核发日期<input name="buyDate" type="date" /></label>
<label class="span2"><span><span class="star">*</span>购买单位</span><input name="buyer" required /></label>
<label>法定代表人<input name="buyerLegal" /></label>
<label>电话<input name="buyerTel" /></label>
<label class="span2">住所 / 住址<input name="buyerAddr" /></label>
<label class="span2">销售单位<input name="seller" /></label>
<label>销售方法人<input name="sellerLegal" /></label>
<label>销售方电话<input name="sellerTel" /></label>
<label class="span2">销售方住所<input name="sellerAddr" /></label>
<label><span><span class="star">*</span>购买物品</span><input name="product" required /></label>
<label>用途<input name="usage" /></label>
<label><span><span class="star">*</span>数量</span><input name="buyQty" type="number" step="0.01" required /></label>
<label>单位<input name="unit" value="吨" /></label>
<label>有效次数<input name="buyTimes" /></label>
<label>有效开始<input name="buyFrom" type="date" /></label>
<label>有效截止<input name="buyDue" type="date" /></label>
<label class="span2">公安机关<input name="buyPolice" /></label>
<label>经办人<input name="buyHandler" /></label>
<label>联系电话<input name="buyPoliceTel" /></label>
</div>
</div>
<div>
<h4>运输证信息</h4>
<div class="form" style="grid-template-columns:1fr 1fr">
<label><span><span class="star">*</span>编号</span><input name="transNo" required /></label>
<label>核发日期<input name="transDate" type="date" /></label>
<label class="span2">货主<input name="owner" /></label>
<label>法定代表人<input name="ownerLegal" /></label>
<label>电话<input name="ownerTel" /></label>
<label class="span2">货主住所<input name="ownerAddr" /></label>
<label>包装<input name="pack" /></label>
<label>购买许可/备案证号<input name="permitNo" /></label>
<label class="span2">运输单位<input name="carrier" /></label>
<label>住所/电话<input name="carrierAddr" /></label>
<label>运输方式<input name="mode" /></label>
<label>车号(次)<input name="plates" /></label>
<label class="span2">运输路线<input name="route" /></label>
<label class="span2">收货单位<input name="consignee" /></label>
<label>运输数量<input name="transQty" type="number" step="0.01" /></label>
<label>有效次数<input name="transTimes" /></label>
<label>有效开始<input name="transFrom" type="date" /></label>
<label>有效截止<input name="transDue" type="date" /></label>
<label class="span2">申请单位<input name="applicantUnit" /></label>
<label class="span2">公安机关<input name="transPolice" /></label>
<label>经办人<input name="transHandler" /></label>
</div>
</div>
</div>
</div>
<div class="dlg-f">
<button class="btn" type="button" onclick="closeCertDlg()">取消</button>
<button class="btn btn-primary" type="button" onclick="saveCert()">生成证件信息</button>
</div>
</form>
</dialog>
<script>
const today = "2026-09-29";
const vehicles = [
{ plate: "鄂E·危A123", type: "柴油罐车", media: "柴油", driver: "周建军", escort: "李强", km: 186420, status: "运行", tankDue: "2026-10-12", insDue: "2026-12-01", nextMaint: 188000 },
{ plate: "鄂E·危B618", type: "甲醇罐车", media: "甲醇", driver: "陈伟", escort: "王磊", km: 142880, status: "维保中", tankDue: "2027-03-08", insDue: "2026-10-20", nextMaint: 143000 },
{ plate: "鄂E·危C902", type: "液氨罐车", media: "液氨", driver: "胡明", escort: "赵鹏", km: 97850, status: "运行", tankDue: "2026-11-02", insDue: "2027-01-15", nextMaint: 102000 },
{ plate: "鄂E·危D077", type: "柴油罐车", media: "柴油", driver: "吴涛", escort: "孙斌", km: 210330, status: "运行", tankDue: "2027-06-18", insDue: "2026-09-30", nextMaint: 212000 },
{ plate: "鄂E·危E441", type: "硫酸罐车", media: "硫酸", driver: "郑浩", escort: "刘洋", km: 65420, status: "停运", tankDue: "2026-10-05", insDue: "2026-11-11", nextMaint: 68000 }
];
let fuels = [
{ plate: "鄂E·危A123", date: "2026-09-28", station: "中石化猇亭站", oil: "0# 柴油", liters: 320, amount: 2368, km: 186420, note: "", attachments: [{ name: "猇亭站小票_0928.jpg" }] },
{ plate: "鄂E·危C902", date: "2026-09-27", station: "中石油伍家岗", oil: "0# 柴油", liters: 280, amount: 2072, km: 97850, note: "", attachments: [{ name: "伍家岗发票_0927.pdf" }] },
{ plate: "鄂E·危D077", date: "2026-09-26", station: "公司内部油库", oil: "0# 柴油", liters: 410, amount: 2952, km: 210110, note: "夜班补油", attachments: [{ name: "内库出库单_0926.jpg" }] },
{ plate: "鄂E·危B618", date: "2026-09-22", station: "中石化夷陵站", oil: "0# 柴油", liters: 340, amount: 2516, km: 142200, note: "", attachments: [{ name: "夷陵站小票_0922.jpg" }] },
{ plate: "鄂E·危A123", date: "2026-09-21", station: "中石化猇亭站", oil: "0# 柴油", liters: 300, amount: 2220, km: 185210, note: "", attachments: [{ name: "猇亭站小票_0921.jpg" }] },
{ plate: "鄂E·危C902", date: "2026-09-20", station: "中石油伍家岗", oil: "0# 柴油", liters: 260, amount: 1924, km: 96800, note: "", attachments: [{ name: "伍家岗小票_0920.jpg" }] },
{ plate: "鄂E·危D077", date: "2026-09-18", station: "中石化夷陵站", oil: "0# 柴油", liters: 380, amount: 2736, km: 208900, note: "", attachments: [{ name: "夷陵站小票_0918.jpg" }] },
{ plate: "鄂E·危B618", date: "2026-09-10", station: "公司内部油库", oil: "0# 柴油", liters: 350, amount: 2520, km: 141500, note: "", attachments: [{ name: "内库出库单_0910.jpg" }] }
];
let etcs = [
{ applicant: "吴九华", plate: "鄂E·危B618", type: "ETC", date: "2026-09-25", km: 142880, note: "", status: "已提交", attachments: [{ name: "ETC开通凭证.jpg" }] },
{ applicant: "周建军", plate: "鄂E·危A123", type: "ETC", date: "2026-09-12", km: 185210, note: "新车上牌后开通", status: "草稿", attachments: [{ name: "行驶证.pdf" }] }
];
let maints = [
{ plate: "鄂E·危B618", type: "故障维修", inDate: "2026-09-28", outDate: "", shop: "宜昌安达汽修", amount: 1860, nextKm: 143000, item: "更换刹车片、排查气路" },
{ plate: "鄂E·危A123", type: "定期保养", inDate: "2026-09-08", outDate: "2026-09-08", shop: "公司维修班", amount: 620, nextKm: 188000, item: "机油机滤、检查罐阀" },
{ plate: "鄂E·危E441", type: "罐体检验", inDate: "2026-09-20", outDate: "", shop: "省特检院宜昌分院", amount: 4800, nextKm: 68000, item: "罐体定期检验" }
];
let currentMod = "fleet";
let currentCertId = null;
let certSeq = 3;
let planSeq = 6;
let planTab = "全部";
let linkingPlanId = null;
const certFiles = { buy: null, trans: null, buyUrl: "", transUrl: "" };
const ocrBuySample = {
buyNo: "G36260499487476", buyDate: "2026-09-24",
buyer: "上饶市敏达精细化工有限公司", buyerLegal: "熊伟", buyerTel: "0793-8224115", buyerAddr: "上饶市胜利路96-3号",
seller: "安庆市山峡化工有限公司", sellerLegal: "吴春生", sellerTel: "0556-5348918", sellerAddr: "安徽省安庆市高新区霞虹路5号",
product: "硫酸", usage: "销售", buyQty: 450, unit: "吨", buyTimes: "多次有效",
buyFrom: "2026-09-24", buyDue: "2026-10-23",
buyPolice: "上饶市公安局信州分局禁毒大队", buyHandler: "潘佳", buyPoliceTel: "13755372147"
};
const ocrTransSample = {
transNo: "Y34260211715884", transDate: "2026-09-03",
owner: "铜陵锦帆物资有限责任公司", ownerLegal: "何红燕", ownerTel: "0562-2119658",
ownerAddr: "安徽省铜陵市滨江大道和铜港路交叉口滨江大道停车场",
pack: "槽罐", permitNo: "G34260528735347",
carrier: "安庆宏泰危险货物运输有限公司", carrierAddr: "安徽省安庆市宜秀区中山大道/0556-5593789",
mode: "汽车", plates: "皖HA3690、皖H57837、皖LB1283",
route: "自安徽省铜陵市铜官区经池州市至安徽省安庆市高新",
consignee: "安庆市山峡化工有限公司", transQty: 500,
transTimes: "多次有效", transFrom: "2026-09-03", transDue: "2026-12-02",
applicantUnit: "铜陵锦帆物资有限责任公司",
transPolice: "铜陵市公安局铜官分局环食药(禁毒)大队", transHandler: "桂强"
};
let certs = [
{
id: 1, created: "2026-09-24", shipped: 30, ctrl: "易制毒",
...ocrBuySample, ...ocrTransSample, product: "硫酸", unit: "吨",
buyFile: "购买备案证明样例.png", transFile: "运输备案证明样例.png",
buyUrl: "购买备案证明样例.png", transUrl: "运输备案证明样例.png",
dispatches: [
{ plate: "皖HA3690", driver: "周建军", qty: 150, shipped: 0 },
{ plate: "皖H57837", driver: "陈伟", qty: 150, shipped: 30 },
{ plate: "皖LB1283", driver: "胡明", qty: 150, shipped: 0 }
]
},
{
id: 2, created: "2026-09-20", shipped: 0, ctrl: "易制爆",
buyNo: "G42100311880021", buyDate: "2026-09-20",
buyer: "宜昌爆破器材公司", buyerLegal: "张磊", buyerTel: "0717-8801122", buyerAddr: "宜昌市夷陵区发展大道18号",
seller: "安庆市山峡化工有限公司", sellerLegal: "吴春生", sellerTel: "0556-5348918", sellerAddr: "安徽省安庆市高新区霞虹路5号",
product: "硝酸铵", usage: "生产使用", buyQty: 50, unit: "吨", buyTimes: "多次有效",
buyFrom: "2026-09-20", buyDue: "2026-11-20",
buyPolice: "宜昌市公安局禁毒支队", buyHandler: "李敏", buyPoliceTel: "0717-1234567",
transNo: "Y42100322991008", transDate: "2026-09-20",
owner: "宜昌爆破器材公司", ownerLegal: "张磊", ownerTel: "0717-8801122", ownerAddr: "宜昌市夷陵区发展大道18号",
pack: "袋装", permitNo: "G42100311880021",
carrier: "宜昌安达危运有限公司", carrierAddr: "宜昌市猇亭区/0717-6677889",
mode: "汽车", plates: "鄂E·危D077",
route: "自安庆市高新区至宜昌市夷陵区",
consignee: "宜昌爆破器材公司", transQty: 50,
transTimes: "多次有效", transFrom: "2026-09-20", transDue: "2026-11-20",
applicantUnit: "宜昌爆破器材公司",
transPolice: "宜昌市公安局禁毒支队", transHandler: "王芳",
buyFile: "购买证_硝酸铵.jpg", transFile: "运输证_硝酸铵.jpg",
dispatches: [
{ plate: "鄂E·危D077", driver: "吴涛", qty: 50, shipped: 0 }
]
}
];
let plans = [
{ id: 1, no: "CK20260928001", status: "已安排", date: "2026-09-30", type: "出库单", ctrl: "易制毒", product: "硫酸", unit: "吨", planQty: 30, realQty: null, customer: "上饶市敏达精细化工有限公司", loadFrom: "山峡高新库区", deliverTo: "上饶胜利路库", tractor: "皖HA3690", driver: "周建军", certId: 1 },
{ id: 2, no: "CK20260928002", status: "待安排", date: "2026-10-02", type: "出库单", ctrl: "易制毒", product: "硫酸", unit: "吨", planQty: 40, realQty: null, customer: "上饶市敏达精细化工有限公司", loadFrom: "山峡高新库区", deliverTo: "上饶胜利路库", tractor: "", driver: "", certId: null },
{ id: 3, no: "ZX20260925008", status: "待安排", date: "2026-10-03", type: "直销单", ctrl: "易制爆", product: "硝酸铵", unit: "吨", planQty: 12, realQty: null, customer: "宜昌爆破器材公司", loadFrom: "山峡猇亭库区", deliverTo: "宜昌夷陵工地", tractor: "", driver: "", certId: null },
{ id: 4, no: "RZ20260921012", status: "已安排", date: "2026-10-01", type: "入直单", ctrl: "", product: "工业盐酸", unit: "吨", planQty: 28, realQty: null, customer: "荆州长江溶剂厂", loadFrom: "荆州长江溶剂厂", deliverTo: "山峡猇亭库区", tractor: "鄂E·危C902", driver: "胡明", certId: null },
{ id: 5, no: "CK20260918003", status: "已完成", date: "2026-09-18", type: "出库单", ctrl: "易制毒", product: "硫酸", unit: "吨", planQty: 30, realQty: 30, customer: "上饶市敏达精细化工有限公司", loadFrom: "山峡高新库区", deliverTo: "上饶胜利路库", tractor: "皖H57837", driver: "陈伟", certId: 1 }
];
const carrierFleet = [
{ company: "安庆宏泰危险货物运输有限公司", plate: "皖HA3690", driver: "周建军", cap: 35 },
{ company: "安庆宏泰危险货物运输有限公司", plate: "皖H57837", driver: "陈伟", cap: 35 },
{ company: "安庆宏泰危险货物运输有限公司", plate: "皖LB1283", driver: "胡明", cap: 32 },
{ company: "宜昌安达危运有限公司", plate: "鄂E·危A123", driver: "周建军", cap: 32 },
{ company: "宜昌安达危运有限公司", plate: "鄂E·危B618", driver: "陈伟", cap: 32 },
{ company: "宜昌安达危运有限公司", plate: "鄂E·危D077", driver: "吴涛", cap: 32 },
{ company: "宜昌安达危运有限公司", plate: "鄂E·危C902", driver: "胡明", cap: 30 },
{ company: "山峡自有车队", plate: "鄂E·危E441", driver: "郑浩", cap: 28 }
];
const TRANS_WARN = 64;
const labels = { dash: "总览", fleet: "车辆台账", fuel: "加油登记", fuelwarn: "油耗预警", etc: "ETC 申请登记", maint: "维修保养", stats: "统计" };
const FUEL_NORM = 33 / 100;
const money = (n) => Number(n).toLocaleString("zh-CN", { maximumFractionDigits: 0 });
const stClass = (s) => s === "运行" ? "st-run" : s === "维保中" ? "st-fix" : "st-stop";
const applicants = ["吴九华", ...vehicles.map((v) => v.driver)].filter((n, i, a) => a.indexOf(n) === i);
function fillPlates() {
["fuelForm", "maintForm"].forEach((id) => {
const sel = document.querySelector(`#${id} [name=plate]`);
sel.innerHTML = vehicles.map((v) => `<option>${v.plate}</option>`).join("");
});
const etcPlate = document.querySelector("#etcForm [name=plate]");
etcPlate.innerHTML = `<option value="">请选择自有车牌</option>` + vehicles.map((v) => `<option>${v.plate}</option>`).join("");
const appSel = document.querySelector("#etcForm [name=applicant]");
appSel.innerHTML = applicants.map((n) => `<option value="${n}">${n}(${n})</option>`).join("");
document.querySelector("#fuelForm [name=date]").value = today;
document.querySelector("#etcForm [name=date]").value = today;
document.querySelector("#maintForm [name=inDate]").value = today;
}
function daysTo(d) {
return Math.ceil((new Date(d) - new Date(today)) / 86400000);
}
function showView(name) {
["dash", "fleet", "fuel", "fuelwarn", "etc", "maint", "stats"].forEach((v) => {
document.getElementById("view-" + v).classList.toggle("hidden", v !== name);
});
document.querySelectorAll("#subnav button").forEach((b) => b.classList.toggle("on", b.dataset.view === name));
document.getElementById("crumb").textContent = labels[name];
if (name === "dash") renderDash();
if (name === "fleet") renderFleet();
if (name === "fuel") renderFuel();
if (name === "fuelwarn") renderFuelWarn();
if (name === "etc") renderEtc();
if (name === "maint") renderMaint();
if (name === "stats") renderStats();
}
function toast(msg) {
const el = document.getElementById("toast");
el.textContent = msg;
el.classList.add("show");
setTimeout(() => el.classList.remove("show"), 1800);
}
function fuelSegments() {
const byPlate = {};
fuels.forEach((f) => {
(byPlate[f.plate] ||= []).push(f);
});
const rows = [];
vehicles.forEach((v) => {
const list = (byPlate[v.plate] || []).slice().sort((a, b) => a.km - b.km || a.date.localeCompare(b.date));
if (list.length < 2) {
rows.push({
plate: v.plate,
driver: v.driver,
fromDate: list[0]?.date || "—",
toDate: list[0]?.date || "—",
fromKm: list[0]?.km || v.km,
toKm: list[0]?.km || v.km,
km: 0,
liters: list[0]?.liters || 0,
rate: null,
per100: null,
status: list.length ? "不足" : "无记录"
});
return;
}
for (let i = 1; i < list.length; i++) {
const prev = list[i - 1];
const cur = list[i];
const km = cur.km - prev.km;
const liters = Number(cur.liters);
const rate = km > 0 ? liters / km : null;
let status = "正常";
if (km <= 0) status = "异常";
else if (rate > FUEL_NORM) status = "预警";
rows.push({
plate: v.plate,
driver: v.driver,
fromDate: prev.date,
toDate: cur.date,
fromKm: prev.km,
toKm: cur.km,
km,
liters,
rate,
per100: rate == null ? null : rate * 100,
status
});
}
});
const order = { 预警: 0, 异常: 1, 不足: 2, 无记录: 3, 正常: 4 };
return rows.sort((a, b) => order[a.status] - order[b.status] || a.plate.localeCompare(b.plate));
}
function fuelWarnCount() {
return fuelSegments().filter((r) => r.status === "预警").length;
}
function renderDash() {
const due = vehicles.filter((v) => daysTo(v.tankDue) <= 20 || daysTo(v.insDue) <= 20);
const fixing = vehicles.filter((v) => v.status === "维保中").length;
const warnN = fuelWarnCount();
document.getElementById("dashAlerts").innerHTML = `
<div class="alert red" style="cursor:pointer" onclick="showView('fuelwarn')">油耗超标预警 ${warnN} 条(标准 0.33 L/km)</div>
<div class="alert red">罐检 / 保险 20 日内到期 ${due.length} 辆</div>
<div class="alert">维保中 ${fixing} 辆,注意出厂登记</div>
<div class="alert ok">本月已登加油 ${fuels.length} 笔</div>`;
const fuelSum = fuels.reduce((s, x) => s + x.amount, 0);
const maintOpen = maints.filter((m) => !m.outDate).length;
document.getElementById("dashStats").innerHTML = [
["在册车辆", vehicles.length, `运行 ${vehicles.filter(v=>v.status==="运行").length} · 维保 ${fixing}`],
["本月加油(元)", money(fuelSum), `${fuels.reduce((s,x)=>s+x.liters,0)} 升`],
["未出厂维保", maintOpen, `记录 ${maints.length} 条`],
["本月 ETC 申请", etcs.length, `已提交 ${etcs.filter(x=>x.status==="已提交").length} · 草稿 ${etcs.filter(x=>x.status==="草稿").length}`]
].map(([k,v,s]) => `<div class="stat"><div class="k">${k}</div><div class="v">${v}</div><div class="s">${s}</div></div>`).join("");
document.getElementById("dashFleet").innerHTML = `<table><thead><tr><th>车牌</th><th>介质</th><th>驾驶员</th><th>状态</th><th>罐检到期</th></tr></thead><tbody>${
vehicles.map((v) => `<tr><td>${v.plate}</td><td>${v.media}</td><td>${v.driver}</td><td><span class="st ${stClass(v.status)}">${v.status}</span></td><td>${daysTo(v.tankDue) <= 20 ? `<span class="st st-due">${v.tankDue}</span>` : v.tankDue}</td></tr>`).join("")
}</tbody></table>`;
const events = [
...fuels.map((x) => ({ date: x.date, title: "加油 " + x.plate, d: x.liters + " 升 · " + x.station })),
...etcs.map((x) => ({ date: x.date, title: "ETC申请 " + x.plate, d: x.applicant + " · 期初 " + x.km + " km" })),
...maints.map((x) => ({ date: x.inDate, title: x.type + " " + x.plate, d: x.item }))
].sort((a, b) => b.date.localeCompare(a.date)).slice(0, 6);
document.getElementById("dashTl").innerHTML = events.map((e) => `<div class="tl"><div class="t">${e.date.slice(5)}</div><div><b>${e.title}</b><div class="d">${e.d}</div></div></div>`).join("");
const byPlate = {};
fuels.forEach((f) => { byPlate[f.plate] = (byPlate[f.plate] || 0) + f.liters; });
const max = Math.max(...Object.values(byPlate), 1);
document.getElementById("dashBars").innerHTML = Object.entries(byPlate).map(([p, n]) =>
`<div class="bar-row"><span>${p.replace("鄂E·","")}</span><div class="bar-track"><div class="bar-fill" style="width:${(n/max)*100}%"></div></div><span class="n">${n}</span></div>`
).join("");
document.getElementById("sideBadge").textContent = due.length + maintOpen + warnN;
}
function renderFleet() {
const q = (document.getElementById("fleetQ").value || "").trim();
const st = document.getElementById("fleetSt").value;
const rows = vehicles.filter((v) => (!q || v.plate.includes(q) || v.driver.includes(q)) && (!st || v.status === st));
document.getElementById("fleetTable").innerHTML = `<table><thead><tr><th>车牌</th><th>车型</th><th>介质</th><th>驾驶员</th><th>押运员</th><th>里程</th><th>状态</th><th>罐检</th><th>保险</th><th>下次保养</th></tr></thead><tbody>${
rows.map((v) => `<tr><td>${v.plate}</td><td>${v.type}</td><td>${v.media}</td><td>${v.driver}</td><td>${v.escort}</td><td>${v.km.toLocaleString()}</td><td><span class="st ${stClass(v.status)}">${v.status}</span></td><td>${v.tankDue}</td><td>${v.insDue}</td><td>${v.nextMaint.toLocaleString()} km</td></tr>`).join("")
}</tbody></table>`;
}
function attachChips(source, rowIndex) {
const items = (source === "etc" ? etcs : fuels)[rowIndex].attachments || [];
if (!items.length) return `<span class="st st-due">缺附件</span>`;
return `<div class="chips">${items.map((a, i) =>
a.url
? `<button class="chip" type="button" onclick="openAttach('${source}',${rowIndex},${i})">${a.name}</button>`
: `<span class="chip ghost" title="历史存档">${a.name}</span>`
).join("")}</div>`;
}
function renderFuel() {
document.getElementById("fuelTable").innerHTML = `<table><thead><tr><th>日期</th><th>车辆</th><th>加油站</th><th>油品</th><th>升数</th><th>金额</th><th>里程</th><th>附件</th><th>备注</th></tr></thead><tbody>${
fuels.map((x, i) => `<tr><td>${x.date}</td><td>${x.plate}</td><td>${x.station}</td><td>${x.oil}</td><td>${x.liters}</td><td>${x.amount}</td><td>${x.km}</td><td>${attachChips("fuel", i)}</td><td>${x.note || "—"}</td></tr>`).join("")
}</tbody></table>`;
}
function fmtRate(n, digits) {
return n == null ? "—" : n.toFixed(digits);
}
function renderFuelWarn() {
const q = (document.getElementById("fuelwarnQ").value || "").trim();
const st = document.getElementById("fuelwarnSt").value;
const all = fuelSegments();
const warnN = all.filter((r) => r.status === "预警").length;
const okN = all.filter((r) => r.status === "正常").length;
const lackN = all.filter((r) => r.status === "不足" || r.status === "无记录" || r.status === "异常").length;
const rates = all.filter((r) => r.rate != null).map((r) => r.rate);
const maxRate = rates.length ? Math.max(...rates) : 0;
document.getElementById("fuelwarnKpis").innerHTML = [
["标准油耗", "0.33 L/km", "33 ÷ 100"],
["超标预警", warnN, "相邻加油区间"],
["正常区间", okN, "未超过标准"],
["数据不足 / 异常", lackN, "少于两次加油或里程未增"]
].map(([k, v, s]) => `<div class="stat"><div class="k">${k}</div><div class="v">${v}</div><div class="s">${s}</div></div>`).join("");
const latestByPlate = {};
all.filter((r) => r.rate != null).forEach((r) => {
const prev = latestByPlate[r.plate];
if (!prev || r.toDate > prev.toDate) latestByPlate[r.plate] = r;
});
const barMax = Math.max(maxRate, FUEL_NORM, 0.01);
document.getElementById("fuelwarnBars").innerHTML = Object.values(latestByPlate).map((r) => {
const over = r.rate > FUEL_NORM;
return `<div class="bar-row"><span>${r.plate.replace("鄂E·","")}</span><div class="bar-track"><div class="bar-fill${over ? " warn" : ""}" style="width:${(r.rate / barMax) * 100}%"></div><i class="bar-mark" style="left:${(FUEL_NORM / barMax) * 100}%" title="标准 0.33"></i></div><span class="n${over ? " rate-over" : ""}">${fmtRate(r.rate, 3)}</span></div>`;
}).join("") || `<div style="color:var(--muted)">暂无可对照的加油区间</div>`;
const rows = all.filter((r) => (!q || r.plate.includes(q)) && (!st || (st === "不足" ? ["不足", "无记录", "异常"].includes(r.status) : r.status === st)));
const stHtml = (s) => s === "预警" || s === "异常" ? `<span class="st st-due">${s}</span>` : s === "正常" ? `<span class="st st-run">${s}</span>` : `<span class="st st-stop">${s}</span>`;
document.getElementById("fuelwarnTable").innerHTML = `<table><thead><tr><th>车辆</th><th>驾驶员</th><th>区间</th><th>起止里程</th><th>行驶(km)</th><th>加油(L)</th><th>L/km</th><th>L/100km</th><th>对照</th><th>状态</th></tr></thead><tbody>${
rows.map((r) => {
const over = r.status === "预警";
const delta = r.rate == null ? "—" : (r.rate - FUEL_NORM >= 0 ? "+" : "") + (r.rate - FUEL_NORM).toFixed(3);
return `<tr><td>${r.plate}</td><td>${r.driver}</td><td>${r.fromDate} → ${r.toDate}</td><td>${Number(r.fromKm).toLocaleString()} → ${Number(r.toKm).toLocaleString()}</td><td>${r.km || "—"}</td><td>${r.liters || "—"}</td><td class="${over ? "rate-over" : ""}">${fmtRate(r.rate, 3)}</td><td class="${over ? "rate-over" : ""}">${fmtRate(r.per100, 1)}</td><td>${delta}</td><td>${stHtml(r.status)}</td></tr>`;
}).join("") || `<tr><td colspan="10" style="color:var(--muted)">没有符合条件的记录</td></tr>`
}</tbody></table>`;
}
function previewFuelFiles(input) {
const box = document.getElementById("fuelFileChips");
const files = [...input.files];
box.innerHTML = files.length
? files.map((f) => `<span class="chip ghost">${f.name}</span>`).join("")
: "";
}
function readFiles(fileList) {
return Promise.all([...fileList].map((file) => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve({ name: file.name, type: file.type, url: reader.result });
reader.onerror = reject;
reader.readAsDataURL(file);
})));
}
function openAttach(source, rowIndex, fileIndex) {
const file = (source === "etc" ? etcs : fuels)[rowIndex].attachments[fileIndex];
document.getElementById("attachTitle").textContent = file.name;
const body = document.getElementById("attachBody");
if (file.type && file.type.startsWith("image/") && file.url) {
body.innerHTML = `<img alt="${file.name}" src="${file.url}" />`;
} else if (file.url) {
body.innerHTML = `<p style="margin-bottom:8px;color:var(--muted)">文件可新窗口打开查看。</p><a class="btn btn-primary" href="${file.url}" target="_blank" rel="noopener">打开 ${file.name}</a>`;
} else {
body.innerHTML = `<p>该附件已随历史记录存档,当前演示环境未保留原文件内容。</p>`;
}
document.getElementById("attachDialog").showModal();
}
function closeAttach() {
document.getElementById("attachDialog").close();
}
function renderEtc() {
document.getElementById("etcTable").innerHTML = `<table><thead><tr><th>申请日期</th><th>申请人</th><th>车牌</th><th>充值类型</th><th>期初公里</th><th>状态</th><th>附件</th><th>备注</th></tr></thead><tbody>${
etcs.map((x, i) => `<tr><td>${x.date}</td><td>${x.applicant}</td><td>${x.plate}</td><td>${x.type}</td><td>${Number(x.km).toLocaleString()}</td><td><span class="st ${x.status==="已提交"?"st-run":"st-fix"}">${x.status}</span></td><td>${attachChips("etc", i)}</td><td>${x.note || "—"}</td></tr>`).join("")
}</tbody></table>`;
}
function renderMaint() {
document.getElementById("maintTable").innerHTML = `<table><thead><tr><th>进厂</th><th>出厂</th><th>车辆</th><th>类型</th><th>项目</th><th>承修单位</th><th>费用</th><th>下次保养</th></tr></thead><tbody>${
maints.map((x) => `<tr><td>${x.inDate}</td><td>${x.outDate || "未出厂"}</td><td>${x.plate}</td><td>${x.type}</td><td>${x.item}</td><td>${x.shop}</td><td>${x.amount}</td><td>${x.nextKm || "—"}</td></tr>`).join("")
}</tbody></table>`;
}
function renderStats() {
const fuelAmt = fuels.reduce((s, x) => s + Number(x.amount), 0);
const maintAmt = maints.reduce((s, x) => s + Number(x.amount), 0);
const liters = fuels.reduce((s, x) => s + Number(x.liters), 0);
document.getElementById("statKpis").innerHTML = [
["加油升数", liters, "本月已登记"],
["加油费用", money(fuelAmt) + " 元", ""],
["维保费用", money(maintAmt) + " 元", "含未出厂"],
["ETC 申请", etcs.length, `已提交 ${etcs.filter(x=>x.status==="已提交").length} 笔`]
].map(([k,v,s]) => `<div class="stat"><div class="k">${k}</div><div class="v">${v}</div><div class="s">${s || "&nbsp;"}</div></div>`).join("");
const byPlate = {};
fuels.forEach((f) => { byPlate[f.plate] = (byPlate[f.plate] || 0) + f.liters; });
const max = Math.max(...Object.values(byPlate), 1);
document.getElementById("statBars").innerHTML = Object.entries(byPlate).map(([p, n]) =>
`<div class="bar-row"><span>${p.replace("鄂E·","")}</span><div class="bar-track"><div class="bar-fill" style="width:${(n/max)*100}%"></div></div><span class="n">${n} L</span></div>`
).join("");
const total = fuelAmt + maintAmt || 1;
const segs = [
{ name: "加油", n: fuelAmt, c: "#148a3c" },
{ name: "维保", n: maintAmt, c: "#c2410c" }
];
let acc = 0;
const circ = 2 * Math.PI * 10;
document.getElementById("pie").innerHTML = segs.map((s) => {
const len = (s.n / total) * circ;
const dash = `${len} ${circ}`;
const rot = -90 + acc * 360;
acc += s.n / total;
return `<circle r="10" cx="16" cy="16" fill="none" stroke="${s.c}" stroke-width="4" stroke-dasharray="${dash}" transform="rotate(${rot} 16 16)"/>`;
}).join("");
document.getElementById("pieLegend").innerHTML = segs.map((s) =>
`<span><i class="sw" style="background:${s.c}"></i>${s.name} ${money(s.n)} 元</span>`
).join("");
document.getElementById("certTable").innerHTML = `<table><thead><tr><th>车牌</th><th>罐检到期</th><th>剩余</th><th>保险到期</th><th>剩余</th></tr></thead><tbody>${
vehicles.map((v) => `<tr><td>${v.plate}</td><td>${v.tankDue}</td><td>${daysTo(v.tankDue)} 天</td><td>${v.insDue}</td><td>${daysTo(v.insDue)} 天</td></tr>`).join("")
}</tbody></table>`;
}
function addFuel(e) {
e.preventDefault();
const input = e.target.querySelector("[name=attach]");
if (!input.files || !input.files.length) {
toast("请上传加油小票或发票");
return false;
}
const fd = new FormData(e.target);
const f = Object.fromEntries(fd);
delete f.attach;
readFiles(input.files).then((attachments) => {
fuels.unshift({ ...f, liters: +f.liters, amount: +f.amount, km: +f.km, attachments });
const v = vehicles.find((x) => x.plate === f.plate);
if (v) v.km = +f.km;
toast("加油已登记,附件已保存");
e.target.reset();
document.getElementById("fuelFileChips").innerHTML = "";
fillPlates();
renderFuel();
renderDash();
if (!document.getElementById("view-fuelwarn").classList.contains("hidden")) renderFuelWarn();
});
return false;
}
function previewEtcFiles(input) {
const box = document.getElementById("etcFileChips");
const files = [...input.files];
box.innerHTML = files.length
? files.map((f) => `<span class="chip ghost">${f.name}</span>`).join("")
: "";
}
function stepKm(delta) {
const el = document.querySelector("#etcForm [name=km]");
el.value = Math.max(0, (+el.value || 0) + delta);
}
function syncEtcKm() {
const plate = document.querySelector("#etcForm [name=plate]").value;
const v = vehicles.find((x) => x.plate === plate);
if (v) document.querySelector("#etcForm [name=km]").value = v.km;
}
function openEtcDlg() {
const form = document.getElementById("etcForm");
form.reset();
document.getElementById("etcFileChips").innerHTML = "";
fillPlates();
document.getElementById("etcDialog").showModal();
}
function closeEtcDlg() {
document.getElementById("etcDialog").close();
}
function saveEtc(submit) {
const form = document.getElementById("etcForm");
if (!form.reportValidity()) return;
const input = form.querySelector("[name=attach]");
if (!input.files || !input.files.length) {
toast("请上传附件");
return;
}
const f = Object.fromEntries(new FormData(form));
delete f.attach;
readFiles(input.files).then((attachments) => {
etcs.unshift({ ...f, km: +f.km, attachments, status: submit ? "已提交" : "草稿" });
toast(submit ? "ETC 申请已提交" : "ETC 申请已保存");
closeEtcDlg();
renderEtc();
renderDash();
});
}
function addMaint(e) {
e.preventDefault();
const f = Object.fromEntries(new FormData(e.target));
maints.unshift({ ...f, amount: +f.amount, nextKm: f.nextKm ? +f.nextKm : "" });
const v = vehicles.find((x) => x.plate === f.plate);
if (v && !f.outDate) v.status = "维保中";
toast("维保已登记");
e.target.reset();
fillPlates();
renderMaint();
renderDash();
}
function remain(c) { return Math.max(0, Number(c.buyQty) - Number(c.shipped || 0)); }
function reserved(c) {
return plans.filter((p) => p.certId === c.id && p.status !== "已完成")
.reduce((s, p) => s + Number(p.planQty || 0), 0);
}
function transTotal(c) { return Number(c.transQty || c.buyQty || 0); }
function transShipped(c) {
const fromCars = (c.dispatches || []).reduce((s, d) => s + Number(d.shipped || 0), 0);
return fromCars || Number(c.shipped || 0);
}
function transRemain(c) { return Math.max(0, transTotal(c) - transShipped(c)); }
function assignedSum(c) { return (c.dispatches || []).reduce((s, d) => s + Number(d.qty || 0), 0); }
function unassignedBuy(c) { return Math.max(0, Number(c.buyQty || 0) - assignedSum(c)); }
function transWarn(c) { return transRemain(c) <= TRANS_WARN; }
function sameCompanyCars(company) {
return carrierFleet.filter((v) => v.company === company);
}
function certStatusOf(c) {
const r = remain(c);
const tr = transRemain(c);
const due = Math.min(daysTo(c.buyDue || today), daysTo(c.transDue || today));
if (r <= 0 && tr <= 0) return "已用尽";
if (tr > 0 && tr <= TRANS_WARN) return "运输证预警";
if (due <= 15) return "即将到期";
if (r / Number(c.buyQty || 1) <= 0.35) return "余量预警";
return "有余量";
}
function certStHtml(s) {
if (s === "有余量") return `<span class="st st-run">${s}</span>`;
if (s === "余量预警" || s === "即将到期") return `<span class="st st-fix">${s}</span>`;
return `<span class="st st-due">${s}</span>`;
}
function remainHtml(c) {
const r = remain(c);
const cls = r <= 0 ? "bad" : (r / Number(c.buyQty || 1) <= 0.35 || transWarn(c) ? "warn" : "");
return `<span class="remain ${cls}">${r} ${c.unit || "吨"}</span>`;
}
function transRemainHtml(c) {
const r = transRemain(c);
const cls = r <= 0 ? "bad" : (r <= TRANS_WARN ? "warn" : "");
return `<span class="remain ${cls}">${r} ${c.unit || "吨"}</span>`;
}
function certWarnCount() {
return certs.filter((c) => ["余量预警", "运输证预警", "即将到期", "已用尽"].includes(certStatusOf(c))).length;
}
function updateBadges() {
const needLink = plans.filter((p) => p.ctrl && !p.certId && p.status !== "已完成").length;
document.getElementById("planBadge").textContent = needLink;
document.getElementById("certBadge").textContent = certWarnCount();
}
function showMod(mod) {
currentMod = mod;
document.getElementById("mod-fleet").classList.toggle("hidden", mod !== "fleet");
document.getElementById("mod-plan").classList.toggle("hidden", mod !== "plan");
document.getElementById("mod-cert").classList.toggle("hidden", mod !== "cert");
document.querySelectorAll(".side-item[data-mod]").forEach((b) => b.classList.toggle("active", b.dataset.mod === mod));
const names = { fleet: "车辆管理", plan: "规划管理", cert: "购买证管理" };
document.getElementById("crumbMod").textContent = names[mod];
if (mod === "fleet") showView(document.querySelector("#subnav button.on")?.dataset.view || "dash");
if (mod === "plan") {
document.getElementById("crumb").textContent = "运输计划";
renderPlans();
}
if (mod === "cert") showCertView(currentCertId ? "detail" : "list");
}
function showCertView(name, id) {
if (name === "detail") {
if (id != null) currentCertId = id;
if (currentCertId == null) {
toast("请先从列表选择一条证件");
name = "list";
}
}
document.getElementById("view-cert-list").classList.toggle("hidden", name !== "list");
document.getElementById("view-cert-detail").classList.toggle("hidden", name !== "detail");
document.querySelectorAll("#certSubnav button").forEach((b) => b.classList.toggle("on", b.dataset.view === name));
document.getElementById("navDetail").disabled = currentCertId == null;
document.getElementById("crumb").textContent = name === "detail" ? "证件信息" : "购买证 / 运输证";
if (name === "list") renderCertList();
if (name === "detail") renderCertDetail();
}
function kv(lab, val) {
return `<div class="lab">${lab}</div><div class="val">${val || "—"}</div>`;
}
function renderCertList() {
const q = (document.getElementById("certQ").value || "").trim();
const st = document.getElementById("certSt").value;
const warnN = certs.filter((c) => certStatusOf(c) === "余量预警" || certStatusOf(c) === "即将到期").length;
const transN = certs.filter((c) => transWarn(c) && transRemain(c) > 0).length;
const left = certs.reduce((s, c) => s + remain(c), 0);
document.getElementById("listAlerts").innerHTML = `
<div class="alert red">运输证余量 ≤ ${TRANS_WARN} 吨预警 ${transN} 条,请尽快分派同公司车辆发运</div>
<div class="alert red">购买证余量预警 / 即将到期 ${warnN} 条</div>
<div class="alert ok">在册双证 ${certs.length} 条,购买证剩余 ${left} 吨</div>`;
document.getElementById("listStats").innerHTML = [
["双证记录", certs.length, "一次识别生成一条"],
["购买证剩余", left + " 吨", "购买量 − 已完成计划"],
["运输证预警", transN, `余量 ≤ ${TRANS_WARN} 吨`],
["待关联计划", plans.filter((p) => p.ctrl && !p.certId && p.status !== "已完成").length, "易制毒 / 易制爆"]
].map(([k, v, s]) => `<div class="stat"><div class="k">${k}</div><div class="v">${v}</div><div class="s">${s}</div></div>`).join("");
const rows = certs.filter((c) => {
const blob = [c.buyNo, c.transNo, c.product, c.buyer, c.plates, c.consignee].join(" ");
if (q && !blob.includes(q)) return false;
if (st && certStatusOf(c) !== st) return false;
return true;
});
document.getElementById("listCount").textContent = `共 ${rows.length} 条`;
document.getElementById("certListTable").innerHTML = rows.length ? `<table>
<thead><tr><th>购买证号</th><th>运输证号</th><th>品名</th><th>运输公司</th><th>购买量</th><th>分派车辆</th><th>购买证余量</th><th>运输证余量</th><th>状态</th><th></th></tr></thead>
<tbody>${rows.map((c) => `<tr class="row-link" onclick="showCertView('detail', ${c.id})">
<td class="cell-no">${c.buyNo}</td><td>${c.transNo}</td>
<td>${c.product}<div class="muted">${c.ctrl || ""}</div></td>
<td>${c.carrier || "—"}</td>
<td>${c.buyQty} ${c.unit}</td>
<td>${(c.dispatches || []).length} 辆<div class="muted">已分 ${assignedSum(c)} ${c.unit}</div></td>
<td>${remainHtml(c)}</td><td>${transRemainHtml(c)}</td>
<td>${certStHtml(certStatusOf(c))}</td>
<td><button class="btn btn-sm" type="button" onclick="event.stopPropagation();showCertView('detail', ${c.id})">查看</button></td>
</tr>`).join("")}</tbody></table>` : `<div class="empty">没有记录。请识别购买证与运输证生成一条。</div>`;
updateBadges();
}
function renderCertDetail() {
const c = certs.find((x) => x.id === currentCertId);
if (!c) { showCertView("list"); return; }
document.getElementById("navDetail").disabled = false;
document.getElementById("detailTitle").textContent = c.product + " · 双证信息";
document.getElementById("detailSub").textContent = `识别生成于 ${c.created} · ${c.buyNo} / ${c.transNo}`;
const r = remain(c);
const tr = transRemain(c);
const hold = reserved(c);
const pct = Math.min(100, (Number(c.shipped || 0) / Number(c.buyQty || 1)) * 100);
const tPct = Math.min(100, (transShipped(c) / (transTotal(c) || 1)) * 100);
document.getElementById("detailStats").innerHTML = [
["购买证数量", c.buyQty + " " + c.unit, `未分派车辆 ${unassignedBuy(c)} ${c.unit}`],
["运输证数量", transTotal(c) + " " + c.unit, c.carrier || "未识别运输单位"],
["购买证剩余", r + " " + c.unit, hold ? `计划占用 ${hold} ${c.unit}` : "完成计划后扣减"],
["运输证剩余", tr + " " + c.unit, tr <= TRANS_WARN ? `≤ ${TRANS_WARN} 吨,预警` : `低于 ${TRANS_WARN} 吨预警`]
].map(([k, v, s]) => `<div class="stat"><div class="k">${k}</div><div class="v">${v}</div><div class="s">${s}</div></div>`).join("");
document.getElementById("detailSheet").innerHTML = `
<article class="card">
<div class="card-h"><h2>购买备案证明</h2><span class="chip ghost">${c.buyFile || "已识别"}</span></div>
<div class="card-b"><div class="kv">
${kv("编号", c.buyNo)}${kv("核发日期", c.buyDate)}
${kv("购买单位", c.buyer)}${kv("法定代表人", c.buyerLegal)}
${kv("电话", c.buyerTel)}${kv("住所", c.buyerAddr)}
${kv("销售单位", c.seller)}${kv("销售方法人", c.sellerLegal)}
${kv("购买物品", c.product)}${kv("用途", c.usage)}
${kv("数量", c.buyQty + " " + c.unit)}${kv("有效次数", c.buyTimes)}
${kv("有效期限", (c.buyFrom || "") + " 至 " + (c.buyDue || ""))}
${kv("公安机关", c.buyPolice)}${kv("经办人", c.buyHandler)}
</div>${c.buyUrl ? `<img src="${c.buyUrl}" alt="购买证" style="margin-top:12px;max-height:220px;object-fit:contain;width:100%">` : ""}</div>
</article>
<article class="card">
<div class="card-h"><h2>运输备案证明</h2><span class="chip ghost">${c.transFile || "已识别"}</span></div>
<div class="card-b"><div class="kv">
${kv("编号", c.transNo)}${kv("核发日期", c.transDate)}
${kv("货主", c.owner)}${kv("法定代表人", c.ownerLegal)}
${kv("运输物品", c.product)}${kv("数量", (c.transQty || "—") + " " + c.unit)}
${kv("包装", c.pack)}${kv("购买许可证号", c.permitNo)}
${kv("运输单位", c.carrier)}${kv("运输方式", c.mode)}
${kv("车号(次)", c.plates)}${kv("收货单位", c.consignee)}
${kv("运输路线", c.route)}${kv("申请单位", c.applicantUnit)}
${kv("有效期限", (c.transFrom || "") + " 至 " + (c.transDue || ""))}
${kv("公安机关", c.transPolice)}${kv("经办人", c.transHandler)}
</div>
<div style="margin-top:14px;color:var(--muted);font-size:12px">运输证进度(已运 / 运输证数量)${tr <= TRANS_WARN ? ` · 余量 ${tr} 吨预警` : ""}</div>
<div class="qty-bar"><div class="qty-fill${tr <= TRANS_WARN ? " warn" : ""}" style="width:${tPct}%"></div></div>
${c.transUrl ? `<img src="${c.transUrl}" alt="运输证" style="margin-top:12px;max-height:220px;object-fit:contain;width:100%">` : ""}
</div>
</article>`;
document.getElementById("dispatchHint").textContent =
`${c.carrier || "未识别运输公司"} 名下车辆可共用本运输证 ${c.transNo}。按购买证 ${c.buyQty}${c.unit} 分派,已分 ${assignedSum(c)}${c.unit},未分 ${unassignedBuy(c)}${c.unit}。运输证剩余 ${tr}${c.unit}${tr <= TRANS_WARN ? `(≤${TRANS_WARN}吨预警)` : ""}。`;
const cars = c.dispatches || [];
document.getElementById("detailDispatch").innerHTML = cars.length ? `<table>
<thead><tr><th>车牌</th><th>驾驶员</th><th>运输公司</th><th>分派运量</th><th>已运</th><th>车辆剩余</th><th></th></tr></thead>
<tbody>${cars.map((d, i) => {
const left = Math.max(0, Number(d.qty) - Number(d.shipped || 0));
return `<tr>
<td class="cell-no">${d.plate}</td><td>${d.driver || "—"}</td>
<td>${c.carrier || "—"}</td>
<td>${d.qty} ${c.unit}</td><td>${d.shipped || 0} ${c.unit}</td>
<td><span class="remain${left <= 0 ? " bad" : ""}">${left} ${c.unit}</span></td>
<td><button class="btn btn-sm" type="button" onclick="removeDispatch(${i})">移除</button></td>
</tr>`;
}).join("")}</tbody></table>` : `<div class="empty">尚未分派车辆。同一运输公司的车可办一张运输证,请按购买证数量分派运量。</div>`;
const linked = plans.filter((p) => p.certId === c.id);
document.getElementById("detailLinks").innerHTML = linked.length ? `<table>
<thead><tr><th>单号</th><th>日期</th><th>状态</th><th>计划量</th><th>实发</th><th>扣减</th></tr></thead>
<tbody>${linked.map((p) => `<tr>
<td class="cell-no">${p.no}</td><td>${p.date}</td>
<td>${p.status === "已完成" ? `<span class="st st-run">已完成</span>` : `<span class="st st-fix">${p.status}</span>`}</td>
<td>${p.planQty} ${p.unit}</td><td>${p.realQty == null ? "—" : p.realQty + " " + p.unit}</td>
<td>${p.status === "已完成" ? `已减 ${p.realQty || p.planQty} ${p.unit}` : "完成后扣减"}</td>
</tr>`).join("")}</tbody></table>` : `<div class="empty">尚未关联运输计划</div>`;
updateBadges();
}
function fillCertForm(data) {
const form = document.getElementById("certForm");
Object.entries(data).forEach(([k, v]) => {
const el = form.querySelector(`[name="${k}"]`);
if (el) el.value = v;
});
}
function setOcrStatus(kind, text, ok) {
const el = document.getElementById(kind + "Status");
el.textContent = text;
el.classList.toggle("ok", !!ok);
}
function recognize(kind, input) {
const file = input.files && input.files[0];
if (!file) return;
certFiles[kind] = file.name;
const preview = document.getElementById(kind === "buy" ? "buyPreview" : "transPreview");
const reader = new FileReader();
reader.onload = () => {
preview.src = reader.result;
preview.classList.remove("hidden");
certFiles[kind + "Url"] = reader.result;
};
reader.readAsDataURL(file);
setOcrStatus(kind, "正在识别…", false);
setTimeout(() => {
fillCertForm(kind === "buy" ? ocrBuySample : ocrTransSample);
setOcrStatus(kind, "识别完成,请核对后生成", true);
toast(kind === "buy" ? "购买证已识别" : "运输证已识别");
}, 600);
}
function openCertDlg(demo) {
const form = document.getElementById("certForm");
form.reset();
form.querySelector("[name=unit]").value = "吨";
certFiles.buy = certFiles.trans = null;
certFiles.buyUrl = certFiles.transUrl = "";
["buyPreview", "transPreview"].forEach((id) => {
document.getElementById(id).classList.add("hidden");
document.getElementById(id).removeAttribute("src");
});
document.getElementById("buyFile").value = "";
document.getElementById("transFile").value = "";
if (demo) {
fillCertForm({ ...ocrBuySample, ...ocrTransSample });
certFiles.buy = "购买备案证明样例.png";
certFiles.trans = "运输备案证明样例.png";
certFiles.buyUrl = "购买备案证明样例.png";
certFiles.transUrl = "运输备案证明样例.png";
const bp = document.getElementById("buyPreview");
const tp = document.getElementById("transPreview");
bp.src = certFiles.buyUrl; bp.classList.remove("hidden");
tp.src = certFiles.transUrl; tp.classList.remove("hidden");
setOcrStatus("buy", "已填入购买备案证明样例", true);
setOcrStatus("trans", "已填入运输备案证明样例", true);
} else {
setOcrStatus("buy", "待上传", false);
setOcrStatus("trans", "待上传", false);
}
document.getElementById("certDialog").showModal();
}
function closeCertDlg() { document.getElementById("certDialog").close(); }
function saveCert() {
const form = document.getElementById("certForm");
if (!form.reportValidity()) return;
const f = Object.fromEntries(new FormData(form));
const rec = {
...f, id: ++certSeq, created: today, shipped: 0, dispatches: [],
buyQty: +f.buyQty, transQty: f.transQty === "" ? "" : +f.transQty,
ctrl: /硝|铵|炸药/.test(f.product) ? "易制爆" : "易制毒",
buyFile: certFiles.buy || "购买证.jpg",
transFile: certFiles.trans || "运输证.jpg",
buyUrl: certFiles.buyUrl || "",
transUrl: certFiles.transUrl || ""
};
certs.unshift(rec);
seedDispatchesFromPlates(rec);
closeCertDlg();
toast("已生成一条购买证 / 运输证信息");
showMod("cert");
showCertView("detail", rec.id);
}
function seedDispatchesFromPlates(c) {
const raw = String(c.plates || "").split(/[、,,;;\s]+/).map((s) => s.trim()).filter(Boolean);
const company = c.carrier || "";
const seen = new Set();
c.dispatches = [];
raw.forEach((plate) => {
if (seen.has(plate)) return;
seen.add(plate);
const meta = carrierFleet.find((v) => v.plate === plate) || { plate, driver: "", company };
if (company && meta.company && meta.company !== company) return;
c.dispatches.push({ plate, driver: meta.driver || "", qty: 0, shipped: 0 });
});
if (c.dispatches.length) splitBuyToVehicles(c.id, true);
}
function openDispatchDlg() {
const c = certs.find((x) => x.id === currentCertId);
if (!c) return;
const form = document.getElementById("dispatchForm");
form.reset();
form.querySelector("[name=company]").value = c.carrier || "";
const used = new Set((c.dispatches || []).map((d) => d.plate));
let pool = sameCompanyCars(c.carrier).filter((v) => !used.has(v.plate));
if (!pool.length) pool = carrierFleet.filter((v) => !used.has(v.plate) && (!c.carrier || v.company === c.carrier));
const sel = form.querySelector("[name=plate]");
if (!pool.length) {
toast("该公司车辆已全部分派,或未维护该运输公司车队");
return;
}
sel.innerHTML = pool.map((v) => `<option value="${v.plate}">${v.plate} · ${v.driver}(核定 ${v.cap} 吨)</option>`).join("");
form.querySelector("[name=qty]").value = unassignedBuy(c) || "";
document.getElementById("dispatchDlgHint").textContent =
`仅可选择「${c.carrier}」车辆,共用运输证 ${c.transNo}。购买证 ${c.buyQty}${c.unit},未分派 ${unassignedBuy(c)}${c.unit}。`;
syncDispatchDriver();
document.getElementById("dispatchDialog").showModal();
}
function closeDispatchDlg() { document.getElementById("dispatchDialog").close(); }
function syncDispatchDriver() {
const plate = document.querySelector("#dispatchForm [name=plate]").value;
const v = carrierFleet.find((x) => x.plate === plate);
if (v) document.querySelector("#dispatchForm [name=driver]").value = v.driver;
}
function saveDispatch() {
const c = certs.find((x) => x.id === currentCertId);
const form = document.getElementById("dispatchForm");
if (!c || !form.reportValidity()) return;
const plate = form.querySelector("[name=plate]").value;
const driver = form.querySelector("[name=driver]").value;
const qty = +form.querySelector("[name=qty]").value;
const v = carrierFleet.find((x) => x.plate === plate);
if (v && c.carrier && v.company !== c.carrier) {
toast("只能分派同一运输公司的车辆,以便共用一张运输证");
return;
}
if ((c.dispatches || []).some((d) => d.plate === plate)) {
toast("该车已在本运输证下");
return;
}
if (qty - unassignedBuy(c) > 1e-6) {
toast(`分派量超过购买证未分派量 ${unassignedBuy(c)} ${c.unit}`);
return;
}
(c.dispatches ||= []).push({ plate, driver, qty, shipped: 0 });
c.plates = c.dispatches.map((d) => d.plate).join("、");
closeDispatchDlg();
toast(`已将 ${qty}${c.unit} 分派给 ${plate}`);
renderCertDetail();
}
function removeDispatch(index) {
const c = certs.find((x) => x.id === currentCertId);
if (!c) return;
const d = (c.dispatches || [])[index];
if (d && Number(d.shipped) > 0) {
toast("该车已有完成运量,不能移除");
return;
}
c.dispatches.splice(index, 1);
c.plates = c.dispatches.map((x) => x.plate).join("、");
toast("已移除分派");
renderCertDetail();
}
function splitBuyToVehicles(certId, silent) {
const c = certs.find((x) => x.id === (certId || currentCertId));
if (!c || !(c.dispatches || []).length) {
if (!silent) toast("请先分派车辆");
return;
}
const n = c.dispatches.length;
const total = Number(c.buyQty);
const base = Math.floor((total / n) * 100) / 100;
let acc = 0;
c.dispatches.forEach((d, i) => {
d.qty = i === n - 1 ? Math.round((total - acc) * 100) / 100 : base;
acc += d.qty;
if (Number(d.shipped) > Number(d.qty)) d.shipped = d.qty;
});
if (!silent) {
toast(`已按购买证 ${total}${c.unit} 均分到 ${n} 辆同公司车辆`);
renderCertDetail();
}
}
function applyShipToDispatch(cert, plate, qty) {
if (!cert.dispatches || !cert.dispatches.length) return;
let left = qty;
const hit = cert.dispatches.find((d) => d.plate === plate);
const order = hit ? [hit, ...cert.dispatches.filter((d) => d !== hit)] : cert.dispatches;
order.forEach((d) => {
if (left <= 0) return;
const room = Math.max(0, Number(d.qty) - Number(d.shipped || 0));
const take = Math.min(room, left);
d.shipped = Number(d.shipped || 0) + take;
left -= take;
});
}
function planStHtml(s) {
if (s === "已完成") return `<span class="st st-run">${s}</span>`;
if (s === "已安排") return `<span class="st st-fix">${s}</span>`;
return `<span class="st st-stop">${s}</span>`;
}
function renderPlans() {
const q = (document.getElementById("planQ").value || "").trim();
const st = document.getElementById("planSt").value;
const need = plans.filter((p) => p.ctrl && !p.certId && p.status !== "已完成").length;
const done = plans.filter((p) => p.status === "已完成").length;
document.getElementById("planAlerts").innerHTML = `
<div class="alert red">易制毒 / 易制爆待关联 ${need} 条;完成后扣减购买证,并计入运输证已运量</div>
<div class="alert red">运输证余量 ≤ ${TRANS_WARN} 吨 ${certs.filter((c) => transWarn(c) && transRemain(c) > 0).length} 条</div>
<div class="alert ok">已完成 ${done} 条</div>`;
document.getElementById("planStats").innerHTML = [
["计划总数", plans.length, "出库 / 入直 / 直销"],
["待关联双证", need, "管控品必须关联"],
["已完成", done, "已扣减购买证"],
["购买证余量预警", certWarnCount(), "见购买证管理"]
].map(([k, v, s]) => `<div class="stat"><div class="k">${k}</div><div class="v">${v}</div><div class="s">${s}</div></div>`).join("");
const rows = plans.filter((p) => {
if (planTab === "易制毒" || planTab === "易制爆") { if (p.ctrl !== planTab) return false; }
else if (planTab !== "全部" && p.type !== planTab) return false;
const blob = [p.no, p.product, p.customer, p.tractor].join(" ");
if (q && !blob.includes(q)) return false;
if (st && p.status !== st) return false;
return true;
});
document.getElementById("planCount").textContent = `共 ${rows.length} 条`;
document.getElementById("planTable").innerHTML = `<table class="plan-table"><thead><tr>
<th>单号</th><th>日期</th><th>类型</th><th>品名</th><th>客户</th><th>计划量</th><th>状态</th><th>购买证 / 运输证</th><th>操作</th>
</tr></thead><tbody>${rows.map((p) => {
const cert = certs.find((c) => c.id === p.certId);
const tag = p.ctrl === "易制爆" ? `<span class="need-tag bao">易制爆</span>` : p.ctrl ? `<span class="need-tag">易制毒</span>` : "";
const certCell = !p.ctrl
? "—"
: cert
? `<div class="cell-no">${cert.buyNo}</div><div class="muted">${cert.transNo} · 余 ${remain(cert)}${cert.unit}</div>`
: (p.status === "已完成" ? `<span class="muted">未关联</span>` : `<button class="btn btn-sm" type="button" onclick="openLinkCertDlg(${p.id})">关联</button>`);
const ops = [];
if (p.ctrl && !p.certId && p.status !== "已完成") ops.push(`<button class="btn btn-sm" type="button" onclick="openLinkCertDlg(${p.id})">关联</button>`);
if (p.status === "待安排") ops.push(`<button class="btn btn-sm" type="button" onclick="arrangePlan(${p.id})">安排</button>`);
if (p.status === "已安排") ops.push(`<button class="btn btn-sm btn-primary" type="button" onclick="completePlan(${p.id})">完成</button>`);
return `<tr class="${p.ctrl && !p.certId && p.status !== "已完成" ? "row-wait" : ""}">
<td class="cell-no">${p.no}${tag}</td><td>${p.date}</td><td>${p.type}</td>
<td>${p.product}</td><td>${p.customer || "—"}</td>
<td>${p.planQty} ${p.unit}${p.realQty != null ? `<div class="muted">实发 ${p.realQty}</div>` : ""}</td>
<td>${planStHtml(p.status)}</td><td>${certCell}</td>
<td>${ops.join(" ") || "—"}</td>
</tr>`;
}).join("")}</tbody></table>`;
updateBadges();
}
function openPlanDlg() {
const form = document.getElementById("planForm");
form.reset();
form.querySelector("[name=unit]").value = "吨";
form.querySelector("[name=date]").value = today;
document.getElementById("planDialog").showModal();
}
function closePlanDlg() { document.getElementById("planDialog").close(); }
function savePlan() {
const form = document.getElementById("planForm");
if (!form.reportValidity()) return;
const f = Object.fromEntries(new FormData(form));
plans.unshift({
...f, id: ++planSeq, no: "PL" + today.replace(/-/g, "") + String(planSeq).padStart(3, "0"),
status: "待安排", planQty: +f.planQty, realQty: null, tractor: "", driver: "", certId: null
});
closePlanDlg();
toast("计划已保存为待安排");
renderPlans();
}
function arrangePlan(id) {
const p = plans.find((x) => x.id === id);
if (!p) return;
if (p.ctrl && !p.certId) {
toast("易制毒 / 易制爆计划请先关联购买证和运输证");
openLinkCertDlg(id);
return;
}
p.status = "已安排";
toast("已安排发运");
renderPlans();
}
function completePlan(id) {
const p = plans.find((x) => x.id === id);
if (!p) return;
if (p.ctrl && !p.certId) {
toast("请先关联购买证 / 运输证");
openLinkCertDlg(id);
return;
}
const qty = Number(p.realQty || p.planQty);
const cert = certs.find((c) => c.id === p.certId);
if (cert && remain(cert) + 1e-9 < qty) {
toast(`购买证余量不足:剩余 ${remain(cert)} ${cert.unit},本计划 ${qty} ${p.unit}`);
return;
}
p.status = "已完成";
p.realQty = qty;
if (cert) {
cert.shipped = Number(cert.shipped || 0) + qty;
applyShipToDispatch(cert, p.tractor, qty);
const tr = transRemain(cert);
let msg = `计划完成,购买证剩余 ${remain(cert)} ${cert.unit},运输证剩余 ${tr} ${cert.unit}`;
if (tr <= TRANS_WARN) msg += `(≤${TRANS_WARN}吨预警)`;
toast(msg);
} else toast("计划已完成");
renderPlans();
}
function openLinkCertDlg(planId) {
linkingPlanId = planId;
const p = plans.find((x) => x.id === planId);
if (!p) return;
document.getElementById("linkCertHint").textContent =
`${p.no} · ${p.product} ${p.planQty}${p.unit} · ${p.ctrl || "普通"}。请选择一条识别生成的双证;完成后按实发数量扣减购买证余量。`;
const cands = certs.filter((c) => remain(c) > 0);
document.getElementById("linkCertTable").innerHTML = cands.length ? `<table><thead><tr>
<th>购买证</th><th>运输证</th><th>品名</th><th>购买单位</th><th>余量</th><th></th>
</tr></thead><tbody>${cands.map((c) => {
const mismatch = c.product && p.product && c.product !== p.product;
const short = remain(c) < p.planQty;
return `<tr>
<td class="cell-no">${c.buyNo}</td><td>${c.transNo}</td>
<td>${c.product}${mismatch ? `<div class="muted">与计划品名不同</div>` : ""}</td>
<td>${c.buyer}</td><td>${remainHtml(c)}${short ? `<div class="muted">低于本计划量</div>` : ""}</td>
<td><button class="btn btn-sm btn-primary" type="button" onclick="linkCert(${c.id})">关联此证</button></td>
</tr>`;
}).join("")}</tbody></table>` : `<div class="empty">没有可用余量的双证,请先识别生成。</div>`;
document.getElementById("linkCertDialog").showModal();
}
function closeLinkCertDlg() { document.getElementById("linkCertDialog").close(); linkingPlanId = null; }
function linkCert(certId) {
const p = plans.find((x) => x.id === linkingPlanId);
const c = certs.find((x) => x.id === certId);
if (!p || !c) return;
if (remain(c) < p.planQty) {
toast(`余量 ${remain(c)}${c.unit} 小于计划 ${p.planQty}${p.unit},请更换证件或拆分计划`);
return;
}
p.certId = certId;
closeLinkCertDlg();
toast(`已关联 ${c.buyNo} / ${c.transNo},完成计划后扣减余量`);
renderPlans();
}
document.getElementById("subnav").addEventListener("click", (e) => {
const b = e.target.closest("button");
if (b) showView(b.dataset.view);
});
document.getElementById("certSubnav").addEventListener("click", (e) => {
const b = e.target.closest("button");
if (!b || b.disabled) return;
showCertView(b.dataset.view);
});
document.getElementById("planTabs").addEventListener("click", (e) => {
const b = e.target.closest("button");
if (!b) return;
planTab = b.dataset.tab;
document.querySelectorAll("#planTabs button").forEach((x) => x.classList.toggle("on", x === b));
renderPlans();
});
document.querySelector(".sidebar").addEventListener("click", (e) => {
const b = e.target.closest("[data-mod]");
if (b) showMod(b.dataset.mod);
});
document.getElementById("globalSearch").addEventListener("keydown", (e) => {
if (e.key !== "Enter") return;
const q = e.target.value;
if (currentMod === "cert") {
document.getElementById("certQ").value = q;
showCertView("list");
} else if (currentMod === "plan") {
document.getElementById("planQ").value = q;
renderPlans();
} else {
document.getElementById("fleetQ").value = q;
showView("fleet");
}
});
fillPlates();
renderDash();
updateBadges();
</script>
</body>
</html>