Files
shanxia2.0/案例html/index_运输管理页.html
T
2026-10-02 09:28:00 +08:00

2700 lines
150 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: #167c4a;
--green-dark: #0f5e38;
--green-soft: #e8f6ee;
--green-line: #b7dfc8;
--ink: #1c2430;
--muted: #5b6675;
--line: #e6ebe7;
--paper: #f4f7f5;
--white: #ffffff;
--warn: #d94841;
--warn-soft: #fdecea;
--amber: #c47d11;
--amber-soft: #fff6e5;
--blue: #2a6bb3;
--sidebar: 232px;
--header: 56px;
--radius: 12px;
--shadow: 0 1px 2px rgba(28, 36, 48, 0.04), 0 8px 24px rgba(28, 36, 48, 0.04);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
font-family: "PingFang SC", "Hiragino Sans GB", "Noto Sans SC", "Microsoft YaHei", 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 {
display: grid;
grid-template-columns: var(--sidebar) 1fr;
min-height: 100vh;
}
.sidebar {
background: var(--white);
border-right: 1px solid var(--line);
display: flex;
flex-direction: column;
position: sticky;
top: 0;
height: 100vh;
}
.brand {
height: var(--header);
display: flex;
align-items: center;
gap: 10px;
padding: 0 18px;
border-bottom: 1px solid var(--line);
min-width: 0;
}
.logo {
width: 28px; height: 28px; border-radius: 8px; flex: none;
background: linear-gradient(160deg, #22a05d, var(--green-dark));
display: grid; place-items: center; color: #fff; font-weight: 700; font-size: 13px;
}
.brand strong { font-size: 13px; letter-spacing: 0.02em; font-weight: 700; }
.brand .sub { display: block; font-size: 11px; color: var(--muted); font-weight: 400; margin-top: 1px; }
.nav { padding: 12px 10px; overflow: auto; flex: 1; }
.nav-label {
font-size: 11px; color: #8a94a0; letter-spacing: 0.08em;
padding: 10px 12px 6px; text-transform: uppercase;
}
.side-item {
display: flex; align-items: center; gap: 10px;
padding: 9px 12px; margin-bottom: 2px;
border-radius: 8px; color: #3d4754; width: 100%; text-align: left; font-size: 13.5px;
}
.side-item svg { width: 16px; height: 16px; flex: none; stroke: currentColor; fill: none; stroke-width: 1.8; }
.side-item:hover { background: #f3f6f4; }
.side-item.active { background: var(--green-soft); color: var(--green-dark); font-weight: 600; }
.side-dot { display: none; }
.badge-n {
margin-left: auto; min-width: 18px; height: 18px; padding: 0 5px;
border-radius: 9px; background: var(--warn); color: #fff;
font-size: 10px; display: grid; place-items: center; font-weight: 600;
}
.tag {
margin-left: auto; font-size: 10px; color: var(--green); background: var(--green-soft);
border-radius: 4px; padding: 1px 5px; font-weight: 600;
}
.nav-group { margin-bottom: 2px; }
.nav-group > .side-item .chev {
margin-left: auto; width: 14px; height: 14px; opacity: .45;
transition: transform .15s; flex: none;
}
.nav-group.open > .side-item .chev { transform: rotate(90deg); }
.nav-sub { display: none; padding: 0 0 6px; }
.nav-group.open > .nav-sub { display: block; }
.side-sub { padding: 8px 12px 8px 38px; font-size: 13px; margin-bottom: 1px; }
.coming {
background: var(--white); border: 1px solid var(--line); border-radius: var(--radius);
box-shadow: var(--shadow); padding: 56px 24px; text-align: center;
}
.coming h2 { font-size: 16px; margin-bottom: 8px; }
.coming p { color: var(--muted); font-size: 13px; }
.main { min-width: 0; }
.topbar {
height: var(--header);
background: var(--white);
border-bottom: 1px solid var(--line);
display: flex; align-items: center; gap: 16px;
padding: 0 28px; position: sticky; top: 0; z-index: 5;
}
.crumb { font-size: 13px; color: var(--muted); }
.crumb b { color: var(--ink); font-weight: 600; }
.search {
margin-left: auto; display: flex; align-items: center; gap: 8px;
background: var(--paper); border: 1px solid var(--line);
border-radius: 8px; padding: 6px 10px; width: 260px;
}
.search input, .top-search {
border: 0; outline: 0; background: transparent; width: 100%; font-size: 13px; color: var(--ink);
}
.search input::placeholder, .top-search::placeholder { color: #8a94a0; }
.user {
display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--muted); flex-shrink: 0;
}
.avatar {
width: 28px; height: 28px; border-radius: 50%;
background: #d7eee0; color: var(--green-dark);
display: grid; place-items: center; font-weight: 700; font-size: 12px;
}
.page { padding: 24px 28px 40px; }
.subnav { display: flex; gap: 4px; background: var(--white); border: 1px solid var(--line); border-radius: 10px; padding: 4px; margin-bottom: 18px; 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: 16px; margin-bottom: 18px; flex-wrap: wrap; }
.page-head h1 { font-size: 22px; font-weight: 700; letter-spacing: -0.02em; }
.page-head p { color: var(--muted); margin-top: 6px; font-size: 13px; }
.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: var(--white); }
.btn:hover { border-color: var(--green-line); }
.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: var(--amber-soft); color: var(--amber); border: 1px solid #f3ddb0; font-size: 12px; }
.alert.red { background: var(--warn-soft); color: var(--warn); border-color: #f5c5c2; }
.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: 12px; margin-bottom: 22px; }
.stats.five { grid-template-columns: repeat(5, 1fr); }
.stat { background: var(--white); border: 1px solid var(--line); border-radius: var(--radius); padding: 14px 16px; box-shadow: var(--shadow); }
.stat .k { color: var(--muted); font-size: 12px; }
.stat .v { font-size: 26px; font-weight: 700; margin: 4px 0 2px; letter-spacing: -.03em; }
.stat .s { font-size: 12px; color: var(--muted); margin-top: 0; }
.grid { display: grid; grid-template-columns: 1.35fr .9fr; gap: 12px; align-items: start; }
.stack { display: flex; flex-direction: column; gap: 12px; }
.card { background: var(--white); border: 1px solid var(--line); border-radius: var(--radius); 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: repeat(4, 1fr); gap: 12px; }
.qbtn {
text-align: left; border: 1px solid var(--line); border-radius: var(--radius);
padding: 14px; background: var(--white); min-height: 88px;
box-shadow: var(--shadow); transition: border-color .15s, transform .15s;
}
.qbtn:hover { border-color: var(--green-line); transform: translateY(-1px); background: var(--white); }
.qbtn .ico {
width: 40px; height: 40px; border-radius: 10px; 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: 13.5px; font-weight: 650; }
.qbtn p { color: var(--muted); font-size: 12px; margin-top: 4px; line-height: 1.45; }
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, #167c4a, #22a05d); 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: var(--green-soft); }
.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; }
.sheet .full-card { grid-column: 1 / -1; }
.cert-duo {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 16px;
align-items: stretch;
width: 100%;
}
.cert-pane {
border-radius: var(--radius);
padding: 10px 10px 12px;
min-width: 0;
display: flex;
flex-direction: column;
}
.cert-pane > .card { flex: 1; }
.cert-pane.buy {
background: linear-gradient(180deg, #e8f6ee 0%, #f4faf6 48px, #f7fbf8 100%);
border: 1px solid var(--green-line);
}
.cert-pane.trans {
background: linear-gradient(180deg, #e8f1fb 0%, #f3f7fc 48px, #f6f9fd 100%);
border: 1px solid #b7cce8;
}
.cert-pane-h {
display: flex; align-items: center; gap: 8px;
padding: 4px 6px 10px;
}
.cert-pane-h h2 { font-size: 14px; font-weight: 650; }
.cert-pane-h .muted { margin-left: auto; font-size: 12px; }
.cert-tag {
display: inline-flex; align-items: center; height: 22px; padding: 0 8px;
border-radius: 6px; font-size: 11px; font-weight: 700; letter-spacing: 0.04em;
}
.cert-tag.buy { background: var(--green); color: #fff; }
.cert-tag.trans { background: var(--blue); color: #fff; }
.cert-pane.buy .card { border-color: var(--green-line); }
.cert-pane.trans .card { border-color: #c5d6ee; }
.cert-pane .card-h { background: #fff; }
.cert-pane.buy .card-h { border-bottom-color: var(--green-line); }
.cert-pane.trans .card-h { border-bottom-color: #d5e3f4; }
.stat.tone-buy { border-color: var(--green-line); box-shadow: none; }
.stat.tone-buy .k { color: var(--green-dark); }
.stat.tone-trans { border-color: #c5d6ee; box-shadow: none; }
.stat.tone-trans .k { color: var(--blue); }
.qty-bar { height: 8px; background: #eef1f4; border-radius: 99px; overflow: hidden; margin-top: 8px; }
.qty-fill { height: 100%; background: linear-gradient(90deg, #167c4a, #22a05d); }
.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; }
.plate-cell { font-size: 12px; line-height: 1.5; }
.plate-cell b { font-weight: 650; }
.attach-mode #buyOcrBox, .attach-mode #buyFormCol { display: none !important; }
.attach-mode .ocr-pair, .attach-mode .split-h { grid-template-columns: 1fr; }
.trans-slot {
margin-top: 12px; padding-top: 12px; border-top: 1px dashed var(--line);
}
.trans-slot-h { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.trans-slot-h h4 { font-size: 13px; }
.exp-hub { max-width: 1080px; }
.exp-section { margin-bottom: 22px; }
.exp-section > h2 {
font-size: 13px; font-weight: 650; margin-bottom: 12px;
padding-left: 8px; border-left: 3px solid var(--green); line-height: 1.2;
}
.exp-cards { display: flex; flex-wrap: wrap; gap: 12px; }
.exp-card {
position: relative; width: 188px; min-height: 70px;
background: var(--white); border: 1px solid var(--line); border-radius: 10px;
padding: 12px 34px 12px 12px; display: flex; align-items: center; gap: 10px;
text-align: left; box-shadow: var(--shadow);
}
.exp-card:hover { border-color: var(--green-line); background: #fbfefc; }
.exp-ico {
width: 36px; height: 36px; border-radius: 8px; flex: none;
display: grid; place-items: center;
}
.exp-ico svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
.exp-card h3 { font-size: 13px; font-weight: 600; line-height: 1.35; color: var(--ink); }
.exp-card h3 small { display: block; font-size: 12px; font-weight: 500; color: var(--muted); }
.exp-fav {
position: absolute; right: 10px; top: 50%; transform: translateY(-50%);
width: 14px; height: 14px; border: 1.5px solid #d0d6de; border-radius: 50%;
}
.exp-badge {
position: absolute; top: -6px; right: -6px; min-width: 18px; height: 18px; padding: 0 5px;
border-radius: 9px; background: var(--warn); color: #fff;
font-size: 10px; font-weight: 700; display: grid; place-items: center;
}
.exp-back { margin-bottom: 12px; }
@media (max-width: 1100px) {
.app { grid-template-columns: 1fr; }
.stats, .stats.five, .grid, .quick, .form, .apply, .ocr-pair, .split-h, .sheet, .cert-duo { grid-template-columns: 1fr; }
.sidebar, .search { display: none; }
.form .span2 { grid-column: auto; }
}
</style>
</head>
<body>
<div class="app">
<aside class="sidebar">
<div class="brand">
<div class="logo" aria-hidden="true">峡</div>
<div>
<strong>山峡代工</strong>
<span class="sub">一体化平台</span>
</div>
</div>
<nav class="nav">
<div class="nav-label">工作台</div>
<button class="side-item" type="button" data-page="fav">
<svg viewBox="0 0 24 24"><path d="M12 4 4 19h16L12 4z"/><path d="M12 10v4M12 16h.01"/></svg>
收藏
</button>
<button class="side-item" type="button" data-page="todo">
<svg viewBox="0 0 24 24"><rect x="4" y="5" width="16" height="14" rx="2"/><path d="M8 9h8M8 13h5"/></svg>
待办列表
</button>
<div class="nav-group" data-group="crm">
<button class="side-item" type="button" data-toggle="crm">
<svg viewBox="0 0 24 24"><circle cx="9" cy="8" r="3"/><path d="M4 19c.5-3 2.8-5 5-5s4.5 2 5 5"/><circle cx="17" cy="9" r="2.4"/><path d="M16 19c.3-2 1.6-3.4 3-3.8"/></svg>
CRM管理
<svg class="chev" viewBox="0 0 24 24"><path d="m9 6 6 6-6 6"/></svg>
</button>
<div class="nav-sub">
<button class="side-item side-sub" type="button" data-page="crm-product">产品管理</button>
<button class="side-item side-sub" type="button" data-page="crm-quote">报价单管理</button>
<button class="side-item side-sub" type="button" data-page="crm-contract">合同管理</button>
<button class="side-item side-sub" type="button" data-page="crm-price">调价函管理</button>
<button class="side-item side-sub" type="button" data-page="crm-customer">客户管理</button>
</div>
</div>
<div class="nav-group" data-group="finance">
<button class="side-item" type="button" data-toggle="finance">
<svg viewBox="0 0 24 24"><rect x="4" y="5" width="16" height="14" rx="2"/><path d="M8 9h8M8 13h5"/></svg>
财务管理
<svg class="chev" viewBox="0 0 24 24"><path d="m9 6 6 6-6 6"/></svg>
</button>
<div class="nav-sub">
<button class="side-item side-sub" type="button" data-page="fin-view">视图中心</button>
<button class="side-item side-sub" type="button" data-page="fin-petty">备用金管理</button>
<button class="side-item side-sub" type="button" data-page="fin-config">基础配置</button>
</div>
</div>
<button class="side-item" type="button" data-page="warehouse">
<svg viewBox="0 0 24 24"><path d="M3 10h18v9H3zM5 10V7l3-3h8l3 3v3"/></svg>
仓储管理
</button>
<div class="nav-group" data-group="hr">
<button class="side-item" type="button" data-toggle="hr">
<svg viewBox="0 0 24 24"><circle cx="12" cy="8" r="3"/><path d="M5 19c.6-3.2 3.2-5 7-5s6.4 1.8 7 5"/></svg>
人事管理
<svg class="chev" viewBox="0 0 24 24"><path d="m9 6 6 6-6 6"/></svg>
</button>
<div class="nav-sub">
<button class="side-item side-sub" type="button" data-mod="expense">报销管理 <span class="badge-n" id="expBadge">0</span></button>
<button class="side-item side-sub" type="button" data-page="hr-staff">员工档案</button>
<button class="side-item side-sub" type="button" data-page="hr-duty">值班管理</button>
</div>
</div>
<div class="nav-group open" data-group="transport">
<button class="side-item" type="button" data-toggle="transport">
<svg viewBox="0 0 24 24"><path d="M3 16h13l5-6H12"/><path d="M5 16V8h7"/><circle cx="7" cy="18" r="2"/><circle cx="16" cy="18" r="2"/></svg>
运输管理
<svg class="chev" viewBox="0 0 24 24"><path d="m9 6 6 6-6 6"/></svg>
</button>
<div class="nav-sub">
<button class="side-item side-sub active" type="button" data-mod="plan">运输计划 <span class="badge-n" id="planBadge">0</span></button>
<button class="side-item side-sub" type="button" data-page="archive">运输归档</button>
<button class="side-item side-sub" type="button" data-mod="cert">购买证管理 <span class="badge-n" id="certBadge">0</span></button>
<button class="side-item side-sub" type="button" data-page="weigh">地磅管理</button>
</div>
</div>
<button class="side-item" type="button" data-page="system">
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a7.8 7.8 0 0 0 .1-6l-2.1.4M6.6 9a7.8 7.8 0 0 0-.1 6l2.1-.4M9 4.6a7.8 7.8 0 0 1 6 0l-.4 2.1M15 19.4a7.8 7.8 0 0 1-6 0l.4-2.1"/></svg>
系统管理
</button>
</nav>
</aside>
<section class="main">
<header class="topbar">
<div class="crumb">工作台 / <b id="crumbMod">运输管理</b><span id="crumbTail"> / <span id="crumb">运输计划</span></span></div>
<label class="search">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#8a94a0" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m20 20-3-3"/></svg>
<input class="top-search" id="globalSearch" placeholder="搜索车牌、单号、证号" />
</label>
<div class="user">
<div class="avatar">王</div>
调度员
</div>
</header>
<div class="page">
<div id="mod-placeholder" class="hidden">
<div class="page-head">
<div>
<h1 id="phTitle">功能建设中</h1>
<p id="phDesc">该模块尚未接入本演示,菜单结构已按一体化平台一级 / 二级导航铺好。</p>
</div>
</div>
<div class="coming">
<h2 id="phCardTitle">敬请期待</h2>
<p>当前演示重点在运输计划与购买证管理。</p>
</div>
</div>
<div id="mod-fleet" class="hidden">
<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">
<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 吨预警;运输证余量 ≤ 64 吨预警。</p>
</div>
</div>
<div class="alerts" id="listAlerts"></div>
<div class="stats five" 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>
<button class="btn btn-primary" type="button" onclick="openCertDlg(false)">识别购买证 + 运输证</button>
</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, currentCertId)">再识别一张运输证</button>
</div>
</div>
<div class="stats" id="detailStats"></div>
<div class="alerts" id="detailWarns"></div>
<div id="detailSheet"></div>
<article class="card" style="margin-top:12px">
<div class="card-h">
<h2>按运输证分派车辆</h2>
<div class="actions">
<button class="btn btn-primary" type="button" onclick="openCertDlg(false, currentCertId)">追加运输证</button>
</div>
</div>
<div class="card-b">
<p class="muted" id="dispatchHint" style="margin-bottom:10px"></p>
<div id="detailDispatch"></div>
</div>
</article>
</section>
</div>
<div id="mod-expense" class="hidden">
<section id="view-expense-hub" class="exp-hub">
<div class="page-head">
<div>
<h1>报销管理</h1>
<p>用于记录费用报销单,请填写完整信息</p>
</div>
</div>
<div id="expenseHub"></div>
</section>
<section id="view-expense-list" class="hidden">
<button class="btn exp-back" type="button" onclick="showExpenseHub()">← 返回报销管理</button>
<div class="page-head">
<div>
<h1 id="expListTitle">报销单据管理</h1>
<p id="expListDesc">费用报销申请、审批与入账。</p>
</div>
<div class="actions">
<button class="btn btn-primary" type="button" onclick="openExpenseDlg()">新建报销单</button>
</div>
</div>
<div class="filters">
<input id="expQ" placeholder="搜索单号、申请人、部门" oninput="renderExpenseList()" />
<select id="expSt" onchange="renderExpenseList()">
<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="expCount"></span></div>
<div class="card-b" id="expTable"></div>
</article>
</section>
</div>
</div>
</section>
</div>
<div class="toast" id="toast">已保存</div>
<dialog class="apply-dlg" id="expenseDialog">
<div class="dlg-h green">
<h3>新建报销单</h3>
<button class="dlg-x" type="button" aria-label="关闭" onclick="closeExpenseDlg()">×</button>
</div>
<form id="expenseForm" onsubmit="return false">
<div class="dlg-b">
<div class="apply">
<div class="row"><div class="lab"><span class="star">*</span>申请人</div><input name="user" required placeholder="姓名" /></div>
<div class="row"><div class="lab"><span class="star">*</span>部门</div>
<select name="dept" required>
<option>运输部</option>
<option>生产部</option>
<option>行政人事</option>
<option>销售部</option>
<option>仓储部</option>
</select>
</div>
<div class="row"><div class="lab"><span class="star">*</span>费用类型</div>
<select name="type" required>
<option>差旅费</option>
<option>交通费</option>
<option>办公费</option>
<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="amount" type="number" min="0" step="0.01" required /></div>
<div class="row full"><div class="lab">事由</div><textarea name="remark" placeholder="请填写报销事由"></textarea></div>
</div>
</div>
<div class="dlg-f">
<button class="btn" type="button" onclick="closeExpenseDlg()">取消</button>
<button class="btn" type="button" onclick="saveExpense(false)">保存草稿</button>
<button class="btn btn-primary" type="button" onclick="saveExpense(true)">提交审批</button>
</div>
</form>
</dialog>
<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 hidden" id="dispatchNewPlateRow">
<div class="lab"><span class="star">*</span>新车牌</div>
<input name="newPlate" placeholder="同公司其他车辆,共用本运输证" />
</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 id="certDlgTitle">识别购买证 + 运输证</h3>
<button class="dlg-x" type="button" aria-label="关闭" onclick="closeCertDlg()">×</button>
</div>
<form id="certForm" onsubmit="return false">
<div class="dlg-b">
<p class="hint" id="certDlgHint" style="margin-bottom:12px">一张购买证可同时识别、挂多张运输证;也可保存后再继续追加。</p>
<div class="ocr-pair">
<div class="ocr-box" id="buyOcrBox">
<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 id="buyFormCol">
<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>运输证信息(第 1 张)</h4>
<div class="form" id="transForm0" 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 id="extraTransSlots"></div>
<div style="margin-top:12px">
<button class="btn" type="button" onclick="addTransSlot()">+ 再识别一张运输证</button>
</div>
</div>
<div class="dlg-f">
<button class="btn" type="button" onclick="closeCertDlg()">取消</button>
<button class="btn btn-primary" type="button" id="certSaveBtn" onclick="saveCert()">生成证件信息</button>
</div>
</form>
</dialog>
<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 expSeq = 8;
let expenses = [
{ no: "BX-20260928-007", user: "周建军", dept: "运输部", type: "差旅费", amount: 1280, date: "2026-09-28", status: "审批中", remark: "宜昌客户拜访往返" },
{ no: "BX-20260927-006", user: "陈伟", dept: "运输部", type: "交通费", amount: 360, date: "2026-09-27", status: "审批中", remark: "过路过桥及停车" },
{ no: "BX-20260926-005", user: "吴九华", dept: "行政人事", type: "办公费", amount: 890, date: "2026-09-26", status: "已通过", remark: "打印耗材与档案袋" },
{ no: "BX-20260925-004", user: "胡明", dept: "生产部", type: "通讯费", amount: 128, date: "2026-09-25", status: "已入账", remark: "9 月通讯补贴" },
{ no: "BX-20260924-003", user: "郑浩", dept: "仓储部", type: "交通费", amount: 210, date: "2026-09-24", status: "草稿", remark: "送样至检测站" },
{ no: "BX-20260922-002", user: "吴涛", dept: "销售部", type: "招待费", amount: 2460, date: "2026-09-22", status: "已驳回", remark: "客户来访接待,发票不全" },
{ no: "BX-20260918-001", user: "刘洋", dept: "仓储部", type: "办公费", amount: 540, date: "2026-09-18", status: "已入账", remark: "劳保用品补购" }
];
const EXP_ICONS = {
doc: '<path d="M8 4h6l4 4v12H8z"/><path d="M14 4v4h4M10 13h6M10 17h5"/>',
stack: '<path d="M8 8h10v11H8z"/><path d="M6 6h10"/><path d="M10 12h6M10 16h4"/>',
flow: '<circle cx="8" cy="8" r="2.4"/><circle cx="16" cy="16" r="2.4"/><path d="M10 9.6 14.2 14"/>',
list: '<path d="M8 7h10M8 12h10M8 17h7"/><circle cx="5" cy="7" r="1"/><circle cx="5" cy="12" r="1"/><circle cx="5" cy="17" r="1"/>',
lines: '<path d="M7 5h10v14H7z"/><path d="M10 9h4M10 12h4M10 15h3"/>',
form: '<path d="M8 4h8v16H8z"/><path d="M11 4v3h2V4M10 11h4M10 14h4"/>',
stamp: '<rect x="6" y="5" width="12" height="9" rx="2"/><path d="M9 17h6M12 14v3"/>',
search: '<circle cx="11" cy="11" r="5.5"/><path d="m19 19-3.2-3.2"/>'
};
const EXP_HUB = [
{
group: "单据管理",
items: [
{ key: "docs", title: "报销单据管理", icon: "doc", color: "#167c4a", bg: "#e8f6ee" },
{ key: "wf-doc", title: "工作流配置", sub: "(报销单据)", icon: "flow", color: "#2a6bb3", bg: "#eaf2fb" },
{ key: "wf-bill", title: "工作流配置", sub: "(报销单)", icon: "stack", color: "#167c4a", bg: "#e8f6ee" },
{ key: "status", title: "单据状态", icon: "list", color: "#c47d11", bg: "#fff6e5" },
{ key: "lines", title: "报销单据明细", icon: "lines", color: "#167c4a", bg: "#e8f6ee" },
{ key: "flow", title: "报销单据流程", icon: "flow", color: "#2a6bb3", bg: "#eaf2fb", badge: 12 }
]
},
{
group: "模板",
items: [
{ key: "tpl-doc", title: "报销单据", icon: "form", color: "#167c4a", bg: "#e8f6ee" },
{ key: "tpl-appr", title: "报销审批", icon: "stamp", color: "#c47d11", bg: "#fff6e5" },
{ key: "tpl-line", title: "报销单据明细", icon: "lines", color: "#167c4a", bg: "#e8f6ee", badge: 10 },
{ key: "tpl-mgr", title: "报销单据管理", icon: "doc", color: "#2a6bb3", bg: "#eaf2fb" },
{ key: "tpl-flow", title: "报销单据流程", icon: "flow", color: "#167c4a", bg: "#e8f6ee" }
]
},
{
group: "数据中心",
items: [
{ key: "query", title: "报销单据查询", icon: "search", color: "#2a6bb3", bg: "#eaf2fb" },
{ key: "dc-doc", title: "报销单据", icon: "form", color: "#167c4a", bg: "#e8f6ee" },
{ key: "dc-line", title: "报销单据明细", icon: "lines", color: "#c47d11", bg: "#fff6e5" }
]
}
];
const EXP_VIEWS = {
docs: { title: "报销单据管理", desc: "费用报销申请、审批与入账。" },
"wf-doc": { title: "工作流配置(报销单据)", desc: "配置报销单据的审批节点、会签与抄送。" },
"wf-bill": { title: "工作流配置(报销单)", desc: "配置报销单提交后的审批路径。" },
status: { title: "单据状态", desc: "查看各状态报销单分布与待办。" },
lines: { title: "报销单据明细", desc: "按费用类型查看报销明细行。" },
flow: { title: "报销单据流程", desc: "跟踪报销单当前审批节点。" },
"tpl-doc": { title: "报销单据", desc: "报销单据模板字段与打印格式。" },
"tpl-appr": { title: "报销审批", desc: "审批意见模板与常用结论。" },
"tpl-line": { title: "报销单据明细", desc: "明细行模板与费用科目。" },
"tpl-mgr": { title: "报销单据管理", desc: "模板侧的单据管理入口。" },
"tpl-flow": { title: "报销单据流程", desc: "流程模板与节点名称。" },
query: { title: "报销单据查询", desc: "按单号、申请人、部门与期间检索。" },
"dc-doc": { title: "报销单据", desc: "数据中心报销单据视图。" },
"dc-line": { title: "报销单据明细", desc: "数据中心报销明细视图。" }
};
let currentMod = "plan";
const PLACE_PAGES = {
fav: { parent: "收藏", title: "收藏", desc: "常用功能与页面可在此固定,便于快速进入。" },
todo: { parent: "待办列表", title: "待办列表", desc: "汇总审批、预警与待处理事项。" },
"crm-product": { parent: "CRM管理", title: "产品管理", desc: "维护代工产品档案、规格与价格策略。" },
"crm-quote": { parent: "CRM管理", title: "报价单管理", desc: "客户询价、报价编制与跟进。" },
"crm-contract": { parent: "CRM管理", title: "合同管理", desc: "合同起草、审批、归档与履约跟踪。" },
"crm-price": { parent: "CRM管理", title: "调价函管理", desc: "调价申请、函件下达与生效记录。" },
"crm-customer": { parent: "CRM管理", title: "客户管理", desc: "客户档案、信用与跟进记录。" },
"fin-view": { parent: "财务管理", title: "视图中心", desc: "财务看板与关键指标视图。" },
"fin-petty": { parent: "财务管理", title: "备用金管理", desc: "备用金领用、核销与结余。" },
"fin-config": { parent: "财务管理", title: "基础配置", desc: "科目、审批流与财务基础参数。" },
warehouse: { parent: "仓储管理", title: "仓储管理", desc: "库存、出入库与仓位管理。" },
"hr-staff": { parent: "人事管理", title: "员工档案", desc: "员工信息、合同与组织关系。" },
"hr-duty": { parent: "人事管理", title: "值班管理", desc: "排班、值班交接与考勤。" },
archive: { parent: "运输管理", title: "运输归档", desc: "已完成运输计划与随车单据归档查询。" },
weigh: { parent: "运输管理", title: "地磅管理", desc: "过磅记录、皮重净重与出厂对账。" },
system: { parent: "系统管理", title: "系统管理", desc: "用户、角色、权限与基础参数。" }
};
let currentCertId = null;
let certSeq = 3;
let transSeq = 4;
let planSeq = 7;
let planTab = "全部";
let linkingPlanId = null;
let currentTransId = null;
let attachBuyId = 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: "桂强"
};
const ocrTransSample2 = {
transNo: "Y34260222800112", transDate: "2026-09-25",
owner: "铜陵锦帆物资有限责任公司", ownerLegal: "何红燕", ownerTel: "0562-2119658",
ownerAddr: "安徽省铜陵市滨江大道和铜港路交叉口滨江大道停车场",
pack: "槽罐", permitNo: "G36260499487476",
carrier: "安庆宏泰危险货物运输有限公司", carrierAddr: "安徽省安庆市宜秀区中山大道/0556-5593789",
mode: "汽车", plates: "皖K92881",
route: "自安徽省铜陵市经安庆至宜昌猇亭",
consignee: "安庆市山峡化工有限公司", transQty: 200,
transTimes: "多次有效", transFrom: "2026-09-25", transDue: "2026-12-24",
applicantUnit: "铜陵锦帆物资有限责任公司",
transPolice: "铜陵市公安局铜官分局环食药(禁毒)大队", transHandler: "桂强"
};
const ocrTransSamples = [ocrTransSample, ocrTransSample2];
let certs = [
{
id: 1, created: today, shipped: 30, ctrl: "易制毒",
...ocrBuySample, product: "硫酸", unit: "吨",
buyFile: "购买备案证明样例.png", buyUrl: "购买备案证明样例.png",
transList: [
{
id: 1, created: today, shipped: 30,
transNo: "Y34260211715884", transDate: "2026-09-03",
owner: "铜陵锦帆物资有限责任公司", ownerLegal: "何红燕", ownerTel: "0562-2119658",
ownerAddr: "安徽省铜陵市滨江大道和铜港路交叉口滨江大道停车场",
pack: "槽罐", permitNo: "G36260499487476",
carrier: "安庆宏泰危险货物运输有限公司", carrierAddr: "安徽省安庆市宜秀区中山大道/0556-5593789",
mode: "汽车", plates: "皖HA3690、皖H57837、皖LB1283",
route: "自安徽省铜陵市铜官区经池州市至安徽省安庆市高新",
consignee: "安庆市山峡化工有限公司", transQty: 250,
transTimes: "多次有效", transFrom: "2026-09-03", transDue: "2026-12-02",
applicantUnit: "铜陵锦帆物资有限责任公司",
transPolice: "铜陵市公安局铜官分局环食药(禁毒)大队", transHandler: "桂强",
transFile: "运输备案证明样例.png", transUrl: "运输备案证明样例.png",
dispatches: [
{ plate: "皖HA3690", driver: "周建军", qty: 90, shipped: 0 },
{ plate: "皖H57837", driver: "陈伟", qty: 80, shipped: 30 },
{ plate: "皖LB1283", driver: "胡明", qty: 80, shipped: 0 }
]
},
{
id: 2, created: today, shipped: 0,
transNo: "Y34260222800112", transDate: "2026-09-25",
owner: "铜陵锦帆物资有限责任公司", ownerLegal: "何红燕", ownerTel: "0562-2119658",
ownerAddr: "安徽省铜陵市滨江大道和铜港路交叉口滨江大道停车场",
pack: "槽罐", permitNo: "G36260499487476",
carrier: "安庆宏泰危险货物运输有限公司", carrierAddr: "安徽省安庆市宜秀区中山大道/0556-5593789",
mode: "汽车", plates: "皖K92881",
route: "自安徽省铜陵市经安庆至宜昌猇亭",
consignee: "安庆市山峡化工有限公司", transQty: 200,
transTimes: "多次有效", transFrom: "2026-09-25", transDue: "2026-12-24",
applicantUnit: "铜陵锦帆物资有限责任公司",
transPolice: "铜陵市公安局铜官分局环食药(禁毒)大队", transHandler: "桂强",
transFile: "运输证_硫酸_2.jpg", transUrl: "",
dispatches: [
{ plate: "皖K92881", driver: "吴涛", qty: 200, shipped: 0 }
]
}
]
},
{
id: 2, created: today, shipped: 16, ctrl: "易制爆",
buyNo: "G42100311880021", buyDate: "2026-09-20",
buyer: "宜昌爆破器材公司", buyerLegal: "张磊", buyerTel: "0717-8801122", buyerAddr: "宜昌市夷陵区发展大道18号",
seller: "安庆市山峡化工有限公司", sellerLegal: "吴春生", sellerTel: "0556-5348918", sellerAddr: "安徽省安庆市高新区霞虹路5号",
product: "硝酸铵", usage: "生产使用", buyQty: 80, unit: "吨", buyTimes: "多次有效",
buyFrom: "2026-09-20", buyDue: "2026-11-20",
buyPolice: "宜昌市公安局禁毒支队", buyHandler: "李敏", buyPoliceTel: "0717-1234567",
buyFile: "购买证_硝酸铵.jpg", buyUrl: "",
transList: [
{
id: 3, created: today, shipped: 16,
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: 80,
transTimes: "多次有效", transFrom: "2026-09-20", transDue: "2026-11-20",
applicantUnit: "宜昌爆破器材公司",
transPolice: "宜昌市公安局禁毒支队", transHandler: "王芳",
transFile: "运输证_硝酸铵.jpg", transUrl: "",
dispatches: [
{ plate: "鄂E·危D077", driver: "吴涛", qty: 80, shipped: 16 }
]
}
]
}
];
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, transId: 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, transId: 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, transId: 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, transId: 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, transId: 1 },
{ id: 6, no: "ZX20260915004", status: "已完成", date: "2026-09-15", type: "直销单", ctrl: "易制爆", product: "硝酸铵", unit: "吨", planQty: 16, realQty: 16, customer: "宜昌爆破器材公司", loadFrom: "山峡猇亭库区", deliverTo: "宜昌夷陵工地", tractor: "鄂E·危D077", driver: "吴涛", certId: 2, transId: 3 }
];
const carrierFleet = [
{ company: "安庆宏泰危险货物运输有限公司", plate: "皖HA3690", driver: "周建军", cap: 35 },
{ company: "安庆宏泰危险货物运输有限公司", plate: "皖H57837", driver: "陈伟", cap: 35 },
{ company: "安庆宏泰危险货物运输有限公司", plate: "皖LB1283", driver: "胡明", cap: 32 },
{ company: "安庆宏泰危险货物运输有限公司", plate: "皖K92881", 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("");
const sb = document.getElementById("sideBadge");
if (sb) sb.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 transOf(c) { return c.transList || []; }
function findTrans(c, transId) { return transOf(c).find((t) => t.id === transId) || null; }
function transItemRemain(t) { return Math.max(0, Number(t.transQty || 0) - Number(t.shipped || 0)); }
function transItemWarn(t) { return transItemRemain(t) > 0 && transItemRemain(t) <= TRANS_WARN; }
function transTotal(c) { return transOf(c).reduce((s, t) => s + Number(t.transQty || 0), 0); }
function transShipped(c) { return transOf(c).reduce((s, t) => s + Number(t.shipped || 0), 0); }
function transRemain(c) { return Math.max(0, transTotal(c) - transShipped(c)); }
function assignedSum(t) { return (t.dispatches || []).reduce((s, d) => s + Number(d.qty || 0), 0); }
function assignedSumAll(c) { return transOf(c).reduce((s, t) => s + assignedSum(t), 0); }
function unassignedTrans(t) { return Math.max(0, Number(t.transQty || 0) - assignedSum(t)); }
function transWarn(c) { return transOf(c).some(transItemWarn); }
function transCount(c) { return transOf(c).length; }
function transNos(c) { return transOf(c).map((t) => t.transNo).filter(Boolean); }
function transDueMin(c) {
const dates = [c.buyDue, ...transOf(c).map((t) => t.transDue)].filter(Boolean);
return dates.length ? dates.reduce((a, b) => a < b ? a : b) : today;
}
function allDispatches(c) {
return transOf(c).flatMap((t) => (t.dispatches || []).map((d) => ({ ...d, transId: t.id, transNo: t.transNo, carrier: t.carrier })));
}
function buyWarn(c) { return remain(c) > 0 && remain(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 = daysTo(transDueMin(c));
if (r <= 0 && tr <= 0) return "已用尽";
if (transWarn(c)) 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>`;
if (s === "运输证预警") return `<span class="st st-due">运输证≤${TRANS_WARN}吨</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" : (transWarn(c) ? "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();
const expN = expenses.filter((x) => x.status === "审批中" || x.status === "草稿").length;
document.getElementById("expBadge").textContent = expN;
}
function setCrumb(parent, child) {
document.getElementById("crumbMod").textContent = parent;
const tail = document.getElementById("crumbTail");
if (!child || child === parent) {
tail.classList.add("hidden");
} else {
tail.classList.remove("hidden");
document.getElementById("crumb").textContent = child;
}
}
function markNav(sel) {
document.querySelectorAll(".side-item").forEach((b) => b.classList.remove("active"));
const el = document.querySelector(sel);
if (!el) return;
el.classList.add("active");
el.closest(".nav-group")?.classList.add("open");
}
function hideAllMods() {
["mod-placeholder", "mod-fleet", "mod-plan", "mod-cert", "mod-expense"].forEach((id) => {
document.getElementById(id).classList.add("hidden");
});
}
function showPage(page) {
const meta = PLACE_PAGES[page];
if (!meta) return;
currentMod = page;
hideAllMods();
document.getElementById("mod-placeholder").classList.remove("hidden");
document.getElementById("phTitle").textContent = meta.title;
document.getElementById("phDesc").textContent = meta.desc;
document.getElementById("phCardTitle").textContent = meta.title;
setCrumb(meta.parent, meta.parent === meta.title ? "" : meta.title);
markNav(`[data-page="${page}"]`);
}
function showMod(mod) {
currentMod = mod;
hideAllMods();
const box = document.getElementById("mod-" + mod);
if (box) box.classList.remove("hidden");
markNav(`[data-mod="${mod}"]`);
if (mod === "fleet") showView(document.querySelector("#subnav button.on")?.dataset.view || "dash");
if (mod === "plan") {
setCrumb("运输管理", "运输计划");
renderPlans();
}
if (mod === "cert") {
setCrumb("运输管理", "购买证管理");
showCertView(currentCertId ? "detail" : "list");
}
if (mod === "expense") {
setCrumb("人事管理", "报销管理");
showExpenseHub();
}
}
function expStatusClass(st) {
if (st === "已通过" || st === "已入账") return "st-run";
if (st === "审批中") return "st-fix";
if (st === "已驳回") return "st-due";
return "st-stop";
}
function renderExpenseHub() {
document.getElementById("expenseHub").innerHTML = EXP_HUB.map((sec) => `
<div class="exp-section">
<h2>${sec.group}</h2>
<div class="exp-cards">${sec.items.map((it) => `
<button class="exp-card" type="button" onclick="openExpenseView('${it.key}')">
${it.badge ? `<span class="exp-badge">${it.badge}</span>` : ""}
<span class="exp-ico" style="background:${it.bg};color:${it.color}">
<svg viewBox="0 0 24 24">${EXP_ICONS[it.icon]}</svg>
</span>
<h3>${it.title}${it.sub ? `<small>${it.sub}</small>` : ""}</h3>
<span class="exp-fav" aria-hidden="true"></span>
</button>`).join("")}
</div>
</div>`).join("");
}
function showExpenseHub() {
renderExpenseHub();
document.getElementById("view-expense-hub").classList.remove("hidden");
document.getElementById("view-expense-list").classList.add("hidden");
setCrumb("人事管理", "报销管理");
}
function openExpenseView(key) {
const meta = EXP_VIEWS[key] || { title: "报销管理", desc: "" };
document.getElementById("expListTitle").textContent = meta.title;
document.getElementById("expListDesc").textContent = meta.desc;
document.getElementById("view-expense-hub").classList.add("hidden");
document.getElementById("view-expense-list").classList.remove("hidden");
setCrumb("人事管理", meta.title);
renderExpenseList();
}
function renderExpenseList() {
const q = (document.getElementById("expQ").value || "").trim();
const st = document.getElementById("expSt").value;
const rows = expenses.filter((x) => {
if (st && x.status !== st) return false;
if (q && ![x.no, x.user, x.dept, x.type, x.remark].join(" ").includes(q)) return false;
return true;
});
document.getElementById("expCount").textContent = `共 ${rows.length} 条`;
document.getElementById("expTable").innerHTML = rows.length ? `<table>
<thead><tr><th>单号</th><th>申请人</th><th>部门</th><th>类型</th><th>金额</th><th>日期</th><th>状态</th><th>事由</th></tr></thead>
<tbody>${rows.map((x) => `<tr>
<td class="cell-no">${x.no}</td>
<td>${x.user}</td>
<td>${x.dept}</td>
<td>${x.type}</td>
<td>¥${Number(x.amount).toLocaleString("zh-CN", { minimumFractionDigits: 2 })}</td>
<td>${x.date}</td>
<td><span class="st ${expStatusClass(x.status)}">${x.status}</span></td>
<td>${x.remark || "—"}</td>
</tr>`).join("")}</tbody>
</table>` : `<div class="empty">暂无报销单据</div>`;
}
function openExpenseDlg() {
const f = document.getElementById("expenseForm");
f.reset();
f.date.value = today;
document.getElementById("expenseDialog").showModal();
}
function closeExpenseDlg() {
document.getElementById("expenseDialog").close();
}
function saveExpense(submit) {
const f = document.getElementById("expenseForm");
if (!f.reportValidity()) return;
const no = "BX-" + today.replaceAll("-", "") + "-" + String(expSeq++).padStart(3, "0");
expenses.unshift({
no, user: f.user.value.trim(), dept: f.dept.value, type: f.type.value,
amount: Number(f.amount.value), date: f.date.value,
status: submit ? "审批中" : "草稿", remark: f.remark.value.trim()
});
closeExpenseDlg();
updateBadges();
renderExpenseList();
toast(submit ? "已提交审批" : "已保存草稿");
}
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)).length;
const buyWarnN = certs.filter((c) => buyWarn(c)).length;
const todayN = certs.filter((c) => c.created === today).length;
const todayTransN = certs.reduce((s, c) => s + transOf(c).filter((t) => t.created === today).length, 0);
const buyTotal = certs.reduce((s, c) => s + Number(c.buyQty || 0), 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} 条,购买证数量 ${buyTotal} 吨</div>`;
document.getElementById("listStats").innerHTML = [
["今日购买证识别数", todayN, "今日识别生成条数"],
["今日运输证识别数", todayTransN, "今日识别生成条数"],
["购买证预警", buyWarnN, `余量 ≤ ${TRANS_WARN} 吨`],
["运输证预警", 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, transNos(c).join(" "), c.product, c.buyer, c.seller,
transOf(c).map((t) => t.plates).join(" "), transOf(c).map((t) => t.consignee).join(" "),
transOf(c).map((t) => t.carrier).join(" ")].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><th>状态</th><th></th></tr></thead>
<tbody>${rows.map((c) => {
const nos = transNos(c);
const carriers = [...new Set(transOf(c).map((t) => t.carrier).filter(Boolean))];
const cars = allDispatches(c);
const warnItems = transOf(c).filter(transItemWarn);
return `<tr class="row-link" onclick="showCertView('detail', ${c.id})">
<td class="cell-no">${c.buyNo}</td>
<td>${nos.length ? nos.map((n) => `<div>${n}</div>`).join("") : "—"}<div class="muted">${transCount(c)} 张</div></td>
<td>${c.product}<div class="muted">${c.ctrl || ""}</div></td>
<td>${c.buyer || "—"}</td>
<td>${c.seller || "—"}</td>
<td>${carriers.join("<br>") || "—"}</td>
<td>${c.buyQty} ${c.unit}</td>
<td class="plate-cell">${cars.length ? cars.map((d) => `<div><b>${d.plate}</b> <span class="muted">${d.qty}${c.unit}</span></div>`).join("") : "—"}</td>
<td>${remainHtml(c)}</td>
<td>${transRemainHtml(c)}${warnItems.length ? `<div class="muted">${warnItems.length} 张预警</div>` : ""}</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; }
const list = transOf(c);
document.getElementById("navDetail").disabled = false;
document.getElementById("detailTitle").textContent = c.product + " · 购买证 / 运输证";
document.getElementById("detailSub").textContent =
`识别生成于 ${c.created} · 购买证 ${c.buyNo} · 运输证 ${list.length} 张`;
const r = remain(c);
const tr = transRemain(c);
const hold = reserved(c);
const unassignedAll = list.reduce((s, t) => s + unassignedTrans(t), 0);
const companies = [...new Set(list.map((t) => t.carrier).filter(Boolean))];
document.getElementById("detailStats").innerHTML = [
["购买证数量", c.buyQty + " " + c.unit, "购买备案数量", "tone-buy"],
["运输证张数", list.length + " 张", `运量合计 ${transTotal(c)} ${c.unit} · 未分派 ${unassignedAll} ${c.unit}`, "tone-trans"],
["购买证剩余", r + " " + c.unit, hold ? `计划占用 ${hold} ${c.unit}` : "完成计划后扣减", "tone-buy"],
["运输证剩余", tr + " " + c.unit, transWarn(c) ? `有运输证余量 ≤ ${TRANS_WARN} 吨` : `单证余量 ≤ ${TRANS_WARN} 吨时预警`, "tone-trans"]
].map(([k, v, s, tone]) => `<div class="stat ${tone || ""}"><div class="k">${k}</div><div class="v">${v}</div><div class="s">${s}</div></div>`).join("");
const warnItems = list.filter(transItemWarn);
document.getElementById("detailWarns").innerHTML = warnItems.map((t) =>
`<div class="alert red">运输证 ${t.transNo} 仅剩 ${transItemRemain(t)} ${c.unit}(阈值 ${TRANS_WARN} 吨),请尽快安排「${t.carrier || "运输公司"}」车辆发运。</div>`
).join("");
const transCards = list.map((t, i) => {
const left = transItemRemain(t);
const warn = transItemWarn(t);
const tPct = Math.min(100, (Number(t.shipped || 0) / (Number(t.transQty || 1))) * 100);
return `<article class="card" style="margin-top:${i ? 12 : 0}px">
<div class="card-h">
<h2>${t.transNo || "未编号"}</h2>
<span class="chip ghost">${t.transFile || "已识别"}</span>
</div>
<div class="card-b"><div class="kv">
${kv("编号", t.transNo)}${kv("核发日期", t.transDate)}
${kv("货主", t.owner)}${kv("法定代表人", t.ownerLegal)}
${kv("运输物品", c.product)}${kv("数量", (t.transQty || "—") + " " + c.unit)}
${kv("包装", t.pack)}${kv("购买许可证号", t.permitNo)}
${kv("运输单位", t.carrier)}${kv("运输方式", t.mode)}
${kv("车号(次)", t.plates)}${kv("收货单位", t.consignee)}
${kv("运输路线", t.route)}${kv("申请单位", t.applicantUnit)}
${kv("有效期限", (t.transFrom || "") + " 至 " + (t.transDue || ""))}
${kv("公安机关", t.transPolice)}${kv("经办人", t.transHandler)}
</div>
<div style="margin-top:14px;color:var(--muted);font-size:12px">本证进度(已运 / 运输证数量)${warn ? ` · 余量 ${left} 吨预警` : ""}</div>
<div class="qty-bar"><div class="qty-fill${warn ? " warn" : ""}" style="width:${tPct}%"></div></div>
${t.transUrl ? `<img src="${t.transUrl}" alt="运输证" style="margin-top:12px;max-height:220px;object-fit:contain;width:100%">` : ""}
</div>
</article>`;
}).join("") || `<article class="card"><div class="card-b"><div class="empty">尚未识别运输证,请点击「再识别一张运输证」。</div></div></article>`;
document.getElementById("detailSheet").innerHTML = `
<div class="cert-duo">
<div class="cert-pane buy">
<div class="cert-pane-h">
<span class="cert-tag buy">购买证</span>
<h2>购买备案证明</h2>
</div>
<article class="card">
<div class="card-h"><h2>${c.buyNo || "未编号"}</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>
</div>
<div class="cert-pane trans">
<div class="cert-pane-h">
<span class="cert-tag trans">运输证</span>
<h2>运输备案证明</h2>
<span class="muted">${list.length} 张</span>
</div>
${transCards}
</div>
</div>`;
document.getElementById("dispatchHint").textContent =
`本购买证挂 ${list.length} 张运输证${companies.length ? "(" + companies.join("、") + ")" : ""}。按各运输证数量分别分派同公司车辆;运输证合计剩余 ${tr}${c.unit}。`;
document.getElementById("detailDispatch").innerHTML = list.length ? list.map((t) => {
const cars = t.dispatches || [];
const left = transItemRemain(t);
return `<div style="margin-bottom:16px">
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:8px">
<strong><span class="cert-tag trans" style="margin-right:6px">运输证</span>${t.transNo}</strong>
<span class="muted">${t.carrier || "—"} · 运量 ${t.transQty || 0}${c.unit} · 已分 ${assignedSum(t)}${c.unit} · 未分 ${unassignedTrans(t)}${c.unit}${transItemWarn(t) ? ` · 余 ${left} 吨预警` : ""}</span>
<div class="actions">
<button class="btn btn-sm" type="button" onclick="splitTransToVehicles(${c.id}, ${t.id})">按本证均分</button>
<button class="btn btn-sm btn-primary" type="button" onclick="openDispatchDlg(${t.id})">分派车辆</button>
</div>
</div>
${cars.length ? `<table>
<thead><tr><th>车牌</th><th>驾驶员</th><th>运输公司</th><th>分派运量</th><th>已运</th><th>车辆剩余</th></tr></thead>
<tbody>${cars.map((d, i) => {
const dleft = Math.max(0, Number(d.qty) - Number(d.shipped || 0));
return `<tr>
<td class="cell-no">${d.plate}</td><td>${d.driver || "—"}</td>
<td>${t.carrier || "—"}</td>
<td><input type="number" step="0.01" min="${d.shipped || 0}" value="${d.qty}" style="width:88px;height:28px;border:1px solid var(--line);border-radius:6px;padding:0 8px"
onchange="updateDispatchQty(${t.id}, ${i}, this.value)" /></td>
<td>${d.shipped || 0} ${c.unit}</td>
<td><span class="remain${dleft <= 0 ? " bad" : ""}">${dleft} ${c.unit}</span></td>
</tr>`;
}).join("")}</tbody>
</table>` : `<div class="empty" style="padding:16px 0">本运输证尚未分派车辆。</div>`}
</div>`;
}).join("") : `<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);
let meta = carrierFleet.find((v) => v.plate === plate);
if (!meta) {
meta = { company: company || "未登记运输公司", plate, driver: "", cap: 32 };
if (company) carrierFleet.push(meta);
}
if (company && meta.company && meta.company !== company) return;
c.dispatches.push({ plate, driver: meta.driver || "", qty: 0, shipped: 0 });
});
if (c.dispatches.length) splitTransToVehicles(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]");
sel.innerHTML = pool.map((v) => `<option value="${v.plate}">${v.plate} · ${v.driver}(核定 ${v.cap} 吨)</option>`).join("")
+ `<option value="__new__">录入同公司其他车辆…</option>`;
if (!pool.length) sel.value = "__new__";
form.querySelector("[name=qty]").value = unassignedTrans(c) || "";
document.getElementById("dispatchDlgHint").textContent =
`「${c.carrier}」名下车辆可共用一张运输证 ${c.transNo}。运量按运输证 ${transTotal(c)}${c.unit} 分派,尚未分派 ${unassignedTrans(c)}${c.unit}。运输证剩余 ${transRemain(c)}${c.unit}${transWarn(c) ? `(已 ≤${TRANS_WARN} 吨预警)` : ""}。`;
syncDispatchDriver();
document.getElementById("dispatchDialog").showModal();
}
function closeDispatchDlg() { document.getElementById("dispatchDialog").close(); }
function syncDispatchDriver() {
const plate = document.querySelector("#dispatchForm [name=plate]").value;
const isNew = plate === "__new__";
document.getElementById("dispatchNewPlateRow").classList.toggle("hidden", !isNew);
document.querySelector("#dispatchForm [name=newPlate]").required = isNew;
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;
let plate = form.querySelector("[name=plate]").value;
if (plate === "__new__") {
plate = (form.querySelector("[name=newPlate]").value || "").trim();
if (!plate) {
toast("请填写同公司车牌");
return;
}
if (!carrierFleet.some((x) => x.plate === plate)) {
carrierFleet.push({ company: c.carrier || "", plate, driver: form.querySelector("[name=driver]").value, cap: 32 });
}
}
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 - unassignedTrans(c) > 1e-6) {
toast(`分派量超过运输证未分派量 ${unassignedTrans(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 splitTransToVehicles(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 = transTotal(c);
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 updateDispatchQty(index, value) {
const c = certs.find((x) => x.id === currentCertId);
if (!c || !c.dispatches[index]) return;
const d = c.dispatches[index];
const qty = Math.max(Number(d.shipped || 0), +value || 0);
const others = assignedSum(c) - Number(d.qty);
if (others + qty - transTotal(c) > 1e-6) {
toast(`分派合计不能超过运输证 ${transTotal(c)} ${c.unit}`);
renderCertDetail();
return;
}
d.qty = qty;
toast(`${d.plate} 分派运量已改为 ${qty} ${c.unit}`);
renderCertDetail();
}
function pickVehicleForPlan(cert, needQty) {
const list = (cert.dispatches || []).slice().sort((a, b) =>
(Number(b.qty) - Number(b.shipped || 0)) - (Number(a.qty) - Number(a.shipped || 0)));
return list.find((d) => Number(d.qty) - Number(d.shipped || 0) + 1e-9 >= needQty) || list[0] || null;
}
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} · 运证余 ${transRemain(cert)}${cert.unit}${transWarn(cert) ? " 预警" : ""}</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;
}
const cert = certs.find((c) => c.id === p.certId);
if (cert) {
const d = pickVehicleForPlan(cert, p.planQty);
if (!d) {
toast("该运输证尚未分派同公司车辆,请先在购买证管理中分派运量");
showMod("cert");
showCertView("detail", cert.id);
return;
}
p.tractor = d.plate;
p.driver = d.driver;
}
p.status = "已安排";
toast(p.tractor ? `已安排 ${p.tractor},分派运量来自同一运输公司运输证` : "已安排发运");
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;
}
if (cert && transRemain(cert) + 1e-9 < qty) {
toast(`运输证余量不足:剩余 ${transRemain(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 && transRemain(c) > 0);
document.getElementById("linkCertTable").innerHTML = cands.length ? `<table><thead><tr>
<th>购买证</th><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 || transRemain(c) < p.planQty;
const tw = transWarn(c);
return `<tr>
<td class="cell-no">${c.buyNo}</td>
<td>${c.transNo}<div class="muted">${c.carrier || "—"}</div></td>
<td>${c.product}${mismatch ? `<div class="muted">与计划品名不同</div>` : ""}</td>
<td>${remainHtml(c)}</td>
<td>${transRemainHtml(c)}${tw ? `<div class="muted">≤${TRANS_WARN}吨预警</div>` : ""}</td>
<td>${(c.dispatches || []).length} 辆同公司</td>
<td><button class="btn btn-sm btn-primary" type="button" onclick="linkCert(${c.id})">关联此证</button>${short ? `<div class="muted">余量低于本计划</div>` : ""}</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;
}
if (transRemain(c) < p.planQty) {
toast(`运输证余量 ${transRemain(c)}${c.unit} 小于计划 ${p.planQty}${p.unit}${transWarn(c) ? `(已 ≤${TRANS_WARN}吨预警)` : ""}`);
return;
}
p.certId = certId;
const d = pickVehicleForPlan(c, p.planQty);
if (d && !p.tractor) {
p.tractor = d.plate;
p.driver = d.driver;
}
closeLinkCertDlg();
toast(`已关联 ${c.buyNo} / ${c.transNo},共用「${c.carrier}」车辆${d ? " · " + d.plate : ""}`);
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 tog = e.target.closest("[data-toggle]");
if (tog) {
const group = tog.closest(".nav-group");
const willOpen = !group.classList.contains("open");
group.classList.toggle("open");
if (willOpen && !group.querySelector(".side-sub.active")) {
const first = group.querySelector("[data-mod], [data-page]");
if (first?.dataset.mod) showMod(first.dataset.mod);
else if (first?.dataset.page) showPage(first.dataset.page);
}
return;
}
const leaf = e.target.closest("[data-mod], [data-page]");
if (!leaf) return;
if (leaf.dataset.mod) showMod(leaf.dataset.mod);
else showPage(leaf.dataset.page);
});
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 if (currentMod === "fleet") {
document.getElementById("fleetQ").value = q;
showView("fleet");
} else if (currentMod === "expense") {
openExpenseView("query");
document.getElementById("expQ").value = q;
renderExpenseList();
}
});
fillPlates();
updateBadges();
showMod("plan");
</script>
</body>
</html>