驾驶行为识别仪表盘新增怠速指标,6项核心数据与多维度趋势分析

By 18561366332, 27 April, 2026
推理
The user wants me to add "怠速次数" (idling count) to the driving behavior statistics dashboard. I need to update the data model, metrics cards (from 5 to 6), charts, and table to include this new metric.

Let me plan the changes:

1. **Data model**: Add `idleCount` (怠速次数) to the generated records
2. **Metrics row**: Change from 5 columns to 6 columns, add the new card
3. **Charts**: The existing charts should still work, but I might need to adjust legends or add this to the donut chart (event distribution). 怠速次数 could be added to the donut chart as a fourth event type, or kept separate.
4. **Table**: Add a column for 怠速次数

Actually, let me think about where 怠速次数 fits:
- The donut shows "驾驶事件占比" with 急加速, 急减速, 超速 - these are all "event" type counts
- 怠速超时 is a duration, not a count. Now adding 怠速次数 makes sense as another event count
- I could add 怠速次数 to the donut chart as a 4th segment

Let me update the dashboard accordingly:

1. Metrics row: 6 cards (add 怠速次数)
2. Line chart: keep 3 lines (急加速, 急减速, 超速) - or add 怠速次数 as 4th line? The line chart is about "事件次数趋势" so adding 怠速次数 makes sense. But 4 lines might be crowded. Let me add it as a 4th line with a dashed style.
3. Bar chart: add 怠速次数 as another bar group? Or keep the bar chart as is (时长分布 - 超速时间 + 怠速超时). The bar chart is about 时长 so 怠速次数 doesn't fit there as well.
4. Donut: add 怠速次数 as 4th segment
5. Table: add 怠速次数 column

Let me redesign:
- Metrics: 6 cards in a row (grid-template-columns: repeat(6, 1fr))
- Line chart: 4 lines now (急加速, 急减速, 超速, 怠速次数) - 怠速次数 with dashed/dotted line
- Bar chart: stays as 时长分布 (超速时间 + 怠速超时)
- Donut: 4 segments (急加速, 急减速, 超速, 怠速)
- Table: add 怠速次数 column

Let me write the full updated code.

For the metrics row, 6 cards will need slightly smaller padding/fonts to fit.

For the line chart, I'll add a 4th line for 怠速次数 with a dashed pattern.

Let me also update the accent colors for the new card - maybe use a teal or blue color.

Let me map the colors:
- 急加速: #e03131 (red)
- 急减速: #e8590c (orange)
- 超速次数: #e67700 (amber)
- 超速时间: #7950f2 (purple)
- 怠速超时: #0c8599 (teal)
- 怠速次数: #1c7ed6 (blue) - new one

I need to be very careful with the complete HTML output. Let me construct it properly.

For the donut chart, 4 segments will need careful handling of the legend positioning.

Let me write the full code now.
角色
assistant
content

```json
{
"summary": "驾驶行为识别统计仪表盘,新增怠速次数指标,6项核心指标卡(急加速/急减速/超速次数+超速时间+怠速超时+怠速次数),折线趋势4线+柱状时长分布+环形事件占比4段,支持年/月/日粒度、日期区间选取与CSV导出。",
"widget": {
"type": "full-html",
"html": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>驾驶行为识别与统计</title>\n <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n <link href=\"https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,400;9…\" rel=\"stylesheet\">\n <link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.c…\">\n <style>\n :root {\n --bg-page: #f5f6f8;\n --bg-panel: #fdfdfd;\n --bg-card: #ffffff;\n --bg-elevated: #fafbfc;\n --bg-stripe: #f8f9fb;\n --border: #e5e8ed;\n --border-light: #eff1f5;\n --border-active: #c9ced7;\n --text-primary: #0f172a;\n --text-secondary: #475569;\n --text-muted: #94a3b8;\n --accent-red: #e03131;\n --accent-red-bg: #fef5f5;\n --accent-orange: #e8590c;\n --accent-orange-bg: #fff9f5;\n --accent-amber: #e67700;\n --accent-amber-bg: #fffbf5;\n --accent-purple: #7950f2;\n --accent-purple-bg: #faf8ff;\n --accent-blue: #1c7ed6;\n --accent-blue-bg: #f5f9fe;\n --accent-teal: #0c8599;\n --accent-teal-bg: #f3fafb;\n --radius-sm: 4px;\n --radius: 8px;\n --radius-md: 10px;\n --radius-lg: 14px;\n --radius-xl: 18px;\n --shadow-xs: 0 1px 2px rgba(0,0,0,0.03);\n --shadow-sm: 0 1px 3px rgba(0,0,0,0.04), 0 1px 2px rgba(0,0,0,0.03);\n --shadow-card: 0 2px 8px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.02);\n --shadow-elevated: 0 8px 28px rgba(0,0,0,0.07), 0 2px 6px rgba(0,0,0,0.03);\n --transition-base: 200ms cubic-bezier(0.4, 0, 0.2, 1);\n --font-display: 'DM Sans', 'Noto Sans SC', sans-serif;\n --font-body: 'Noto Sans SC', 'DM Sans', sans-serif;\n }\n\n * { box-sizing: border-box; margin: 0; padding: 0; }\n\n body {\n background: var(--bg-page);\n color: var(--text-primary);\n font-family: var(--font-body);\n min-height: 100vh;\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 12px;\n background-image:\n radial-gradient(ellipse at 15% 10%, rgba(224,49,49,0.03) 0%, transparent 55%),\n radial-gradient(ellipse at 85% 90%, rgba(28,126,214,0.025) 0%, transparent 55%);\n }\n\n .dashboard {\n width: 100%;\n max-width: 1600px;\n aspect-ratio: 16 / 9;\n max-height: calc(100vh - 24px);\n display: flex;\n flex-direction: column;\n gap: 8px;\n background: var(--bg-panel);\n border-radius: var(--radius-xl);\n padding: 14px 18px;\n box-shadow: var(--shadow-elevated);\n border: 1px solid var(--border);\n position: relative;\n overflow: hidden;\n }\n\n .dashboard::before {\n content: '';\n position: absolute;\n inset: 0;\n pointer-events: none;\n background:\n linear-gradient(180deg, rgba(224,49,49,0.015) 0%, transparent 25%),\n linear-gradient(0deg, rgba(28,126,214,0.01) 0%, transparent 35%);\n border-radius: var(--radius-xl);\n z-index: 0;\n }\n\n .dashboard > * { position: relative; z-index: 1; }\n\n /* ============ HEADER ============ */\n .header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 14px;\n flex-shrink: 0;\n padding-bottom: 7px;\n border-bottom: 1px solid var(--border-light);\n }\n\n .header-left {\n display: flex;\n align-items: center;\n gap: 10px;\n }\n\n .logo-mark {\n width: 34px;\n height: 34px;\n background: linear-gradient(135deg, #e03131, #c92a2a);\n border-radius: var(--radius);\n display: flex;\n align-items: center;\n justify-content: center;\n font-size: 16px;\n color: #ffffff;\n font-weight: 700;\n flex-shrink: 0;\n box-shadow: 0 2px 10px rgba(224,49,49,0.2);\n }\n\n .header-title h1 {\n font-family: var(--font-display);\n font-size: clamp(14px, 1.2vw, 20px);\n font-weight: 700;\n letter-spacing: -0.01em;\n color: var(--text-primary);\n line-height: 1.1;\n }\n\n .header-title span {\n font-size: 10px;\n color: var(--text-muted);\n font-weight: 500;\n letter-spacing: 0.06em;\n text-transform: uppercase;\n }\n\n .header-right {\n display: flex;\n align-items: center;\n gap: 7px;\n flex-wrap: wrap;\n justify-content: flex-end;\n }\n\n .btn-group {\n display: flex;\n background: var(--bg-card);\n border-radius: var(--radius);\n border: 1px solid var(--border);\n overflow: hidden;\n box-shadow: var(--shadow-xs);\n }\n\n .btn-group button {\n background: transparent;\n border: none;\n color: var(--text-secondary);\n padding: 6px 12px;\n cursor: pointer;\n font-family: var(--font-display);\n font-size: 12px;\n font-weight: 600;\n letter-spacing: 0.01em;\n transition: all var(--transition-base);\n white-space: nowrap;\n position: relative;\n }\n\n .btn-group button:not(:last-child)::after {\n content: '';\n position: absolute;\n right: 0;\n top: 18%;\n height: 64%;\n width: 1px;\n background: var(--border);\n }\n\n .btn-group button:hover { color: var(--text-primary); background: var(--bg-elevated); }\n\n .btn-group button.active {\n color: #ffffff;\n background: #e03131;\n box-shadow: 0 0 12px rgba(224,49,49,0.22);\n border-radius: 0;\n }\n .btn-group button.active::after { display: none; }\n .btn-group button:first-child.active { border-radius: var(--radius) 0 0 var(--radius); }\n .btn-group button:last-child.active { border-radius: 0 var(--radius) var(--radius) 0; }\n\n .date-input {\n background: var(--bg-card);\n border: 1px solid var(--border);\n color: var(--text-primary);\n padding: 6px 9px;\n border-radius: var(--radius);\n font-family: var(--font-display);\n font-size: 12px;\n outline: none;\n transition: all var(--transition-base);\n width: 118px;\n cursor: pointer;\n box-shadow: var(--shadow-xs);\n }\n\n .date-input:focus { border-color: #e03131; box-shadow: 0 0 0 3px rgba(224,49,49,0.1); }\n\n .date-separator { color: var(--text-muted); font-size: 12px; font-weight: 500; }\n\n .btn-action {\n background: var(--bg-card);\n border: 1px solid var(--border);\n color: var(--text-secondary);\n width: 32px;\n height: 32px;\n border-radius: var(--radius);\n cursor: pointer;\n display: flex;\n align-items: center;\n justify-content: center;\n transition: all var(--transition-base);\n font-size: 13px;\n box-shadow: var(--shadow-xs);\n }\n\n .btn-action:hover { color: var(--text-primary); border-color: var(--border-active); background: var(--bg-elevated); }\n\n .btn-primary {\n background: linear-gradient(135deg, #e03131, #c92a2a);\n border: none;\n color: #ffffff;\n padding: 6px 15px;\n border-radius: var(--radius);\n cursor: pointer;\n font-family: var(--font-display);\n font-size: 12px;\n font-weight: 600;\n letter-spacing: 0.01em;\n transition: all var(--transition-base);\n white-space: nowrap;\n box-shadow: 0 2px 8px rgba(224,49,49,0.2);\n }\n\n .btn-primary:hover { filter: brightness(1.07); transform: translateY(-1px); box-shadow: 0 4px 16px rgba(224,49,49,0.26); }\n .btn-primary:active { transform: scale(0.97); }\n\n /* ============ METRICS ROW (6 cards) ============ */\n .metrics-row {\n display: grid;\n grid-template-columns: repeat(6, 1fr);\n gap: 6px;\n flex-shrink: 0;\n }\n\n .metric-card {\n background: var(--bg-card);\n border-radius: var(--radius-md);\n padding: 9px 11px;\n border: 1px solid var(--border-light);\n position: relative;\n overflow: hidden;\n transition: all var(--transition-base);\n cursor: default;\n display: flex;\n flex-direction: column;\n gap: 2px;\n box-shadow: var(--shadow-sm);\n }\n\n .metric-card:hover {\n border-color: var(--border-active);\n box-shadow: var(--shadow-elevated);\n transform: translateY(-2px);\n }\n\n .metric-card .card-accent {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 3px;\n }\n\n .metric-card:nth-child(1) .card-accent { background: #e03131; }\n .metric-card:nth-child(2) .card-accent { background: #e8590c; }\n .metric-card:nth-child(3) .card-accent { background: #e67700; }\n .metric-card:nth-child(4) .card-accent { background: #7950f2; }\n .metric-card:nth-child(5) .card-accent { background: #0c8599; }\n .metric-card:nth-child(6) .card-accent { background: #1c7ed6; }\n\n .metric-card .card-icon {\n font-size: 9px;\n color: var(--text-muted);\n }\n\n .metric-card .card-label {\n font-size: 8px;\n color: var(--text-muted);\n text-transform: uppercase;\n letter-spacing: 0.06em;\n font-weight: 600;\n }\n\n .metric-card .card-value {\n font-family: var(--font-display);\n font-size: clamp(14px, 1.5vw, 21px);\n font-weight: 700;\n color: var(--text-primary);\n letter-spacing: -0.02em;\n line-height: 1;\n }\n\n .metric-card .card-sub {\n font-size: 8px;\n color: var(--text-muted);\n font-weight: 500;\n }\n\n /* ============ CHARTS AREA ============ */\n .charts-area {\n display: grid;\n grid-template-columns: 1.25fr 0.85fr 0.9fr;\n gap: 8px;\n flex: 1;\n min-height: 0;\n }\n\n .chart-box {\n background: var(--bg-card);\n border-radius: var(--radius-md);\n border: 1px solid var(--border-light);\n padding: 11px 13px;\n display: flex;\n flex-direction: column;\n overflow: hidden;\n box-shadow: var(--shadow-sm);\n }\n\n .chart-box-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n flex-shrink: 0;\n margin-bottom: 5px;\n gap: 6px;\n }\n\n .chart-box-title {\n font-family: var(--font-display);\n font-size: 11px;\n font-weight: 600;\n letter-spacing: 0.02em;\n color: var(--text-secondary);\n text-transform: uppercase;\n white-space: nowrap;\n }\n\n .chart-legend {\n display: flex;\n gap: 6px;\n font-size: 8px;\n color: var(--text-muted);\n flex-wrap: wrap;\n justify-content: flex-end;\n }\n\n .chart-legend span {\n display: flex;\n align-items: center;\n gap: 2px;\n white-space: nowrap;\n }\n\n .chart-legend .dot {\n width: 5px;\n height: 5px;\n border-radius: 50%;\n display: inline-block;\n flex-shrink: 0;\n }\n\n .chart-legend .dash {\n width: 8px;\n height: 0;\n border-top: 2px dashed;\n display: inline-block;\n flex-shrink: 0;\n }\n\n .chart-canvas-wrap {\n flex: 1;\n min-height: 0;\n position: relative;\n width: 100%;\n }\n\n .chart-canvas-wrap canvas {\n width: 100%;\n height: 100%;\n display: block;\n }\n\n /* ============ RATIO PANEL ============ */\n .ratio-panel {\n background: var(--bg-card);\n border-radius: var(--radius-md);\n border: 1px solid var(--border-light);\n padding: 11px 13px;\n display: flex;\n flex-direction: column;\n overflow: hidden;\n box-shadow: var(--shadow-sm);\n }\n\n .ratio-header {\n flex-shrink: 0;\n margin-bottom: 3px;\n }\n\n .ratio-body {\n flex: 1;\n display: flex;\n align-items: center;\n justify-content: center;\n gap: 6px;\n min-height: 0;\n }\n\n .ratio-donut-wrap {\n flex: 1;\n min-width: 0;\n height: 100%;\n position: relative;\n }\n\n .ratio-donut-wrap canvas {\n width: 100%;\n height: 100%;\n display: block;\n }\n\n .ratio-legend-vert {\n display: flex;\n flex-direction: column;\n gap: 7px;\n flex-shrink: 0;\n font-size: 9px;\n font-family: var(--font-display);\n }\n\n .ratio-legend-item {\n display: flex;\n align-items: center;\n gap: 5px;\n }\n\n .ratio-legend-dot {\n width: 8px;\n height: 8px;\n border-radius: 2px;\n flex-shrink: 0;\n }\n\n .ratio-legend-val {\n font-weight: 700;\n font-size: 13px;\n color: var(--text-primary);\n line-height: 1;\n }\n\n .ratio-legend-label {\n color: var(--text-muted);\n font-size: 8px;\n }\n\n /* ============ TABLE ============ */\n .table-section {\n flex-shrink: 0;\n background: var(--bg-card);\n border-radius: var(--radius-md);\n border: 1px solid var(--border-light);\n overflow: hidden;\n display: flex;\n flex-direction: column;\n max-height: 125px;\n box-shadow: var(--shadow-sm);\n }\n\n .table-section-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 6px 13px;\n border-bottom: 1px solid var(--border-light);\n flex-shrink: 0;\n }\n\n .table-section-title {\n font-family: var(--font-display);\n font-size: 11px;\n font-weight: 600;\n letter-spacing: 0.03em;\n color: var(--text-secondary);\n text-transform: uppercase;\n }\n\n .table-wrap {\n overflow-y: auto;\n flex: 1;\n }\n\n .table-wrap::-webkit-scrollbar { width: 4px; }\n .table-wrap::-webkit-scrollbar-track { background: transparent; }\n .table-wrap::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }\n\n table {\n width: 100%;\n border-collapse: collapse;\n font-size: 9px;\n font-family: var(--font-display);\n }\n\n thead th {\n text-align: left;\n padding: 5px 8px;\n color: var(--text-muted);\n font-weight: 500;\n letter-spacing: 0.04em;\n text-transform: uppercase;\n font-size: 8px;\n position: sticky;\n top: 0;\n background: var(--bg-card);\n z-index: 2;\n border-bottom: 1px solid var(--border-light);\n }\n\n tbody td {\n padding: 4px 8px;\n border-bottom: 1px solid rgba(0,0,0,0.03);\n color: var(--text-primary);\n font-weight: 500;\n letter-spacing: 0.01em;\n }\n\n tbody tr:nth-child(even) { background: var(--bg-stripe); }\n tbody tr:hover { background: rgba(224,49,49,0.03); }\n tbody tr { transition: background var(--transition-base); }\n\n .cell-highlight { color: #e03131; font-weight: 700; }\n .cell-warn { color: #e8590c; font-weight: 600; }\n .cell-info { color: #1c7ed6; font-weight: 600; }\n\n /* ============ TOAST ============ */\n .toast-container {\n position: fixed;\n top: 20px;\n right: 20px;\n z-index: 9999;\n display: flex;\n flex-direction: column;\n gap: 8px;\n pointer-events: none;\n }\n\n .toast {\n background: #ffffff;\n border: 1px solid var(--border);\n border-left: 3px solid #e03131;\n color: var(--text-primary);\n padding: 10px 16px;\n border-radius: var(--radius);\n font-size: 12px;\n font-family: var(--font-body);\n box-shadow: 0 4px 20px rgba(0,0,0,0.1);\n opacity: 0;\n transform: translateX(30px);\n transition: all 280ms cubic-bezier(0.4, 0, 0.2, 1);\n pointer-events: auto;\n display: flex;\n align-items: center;\n gap: 8px;\n }\n\n .toast.show { opacity: 1; transform: translateX(0); }\n .toast i { color: #e03131; font-size: 14px; }\n\n /* ============ RESPONSIVE ============ */\n @media (max-width: 1400px) {\n .metrics-row { grid-template-columns: repeat(3, 1fr); }\n .charts-area { grid-template-columns: 1fr 1fr; }\n .ratio-panel { grid-column: span 2; flex-direction: row; }\n .dashboard { aspect-ratio: auto; max-height: none; }\n .chart-box { min-height: 190px; }\n .ratio-panel { min-height: 170px; }\n }\n\n @media (max-width: 900px) {\n .metrics-row { grid-template-columns: repeat(2, 1fr); }\n .charts-area { grid-template-columns: 1fr; }\n .ratio-panel { grid-column: span 1; flex-direction: column; }\n .header { flex-direction: column; align-items: stretch; }\n .header-right { justify-content: flex-start; }\n .date-input { width: 90px; }\n .dashboard { padding: 10px; }\n .chart-box { min-height: 170px; }\n }\n\n @media (max-width: 600px) {\n .metrics-row { grid-template-columns: repeat(2, 1fr); }\n .btn-group button { padding: 5px 8px; font-size: 10px; }\n .date-input { width: 76px; font-size: 10px; }\n .header-right { gap: 3px; }\n table { font-size: 8px; }\n thead th, tbody td { padding: 3px 4px; }\n .ratio-body { flex-direction: column; }\n }\n\n @media print {\n body { background: #fff; padding: 0; }\n .dashboard { box-shadow: none; border: none; aspect-ratio: auto; max-height: none; }\n .btn-primary, .btn-action, .btn-group { display: none; }\n .toast-container { display: none; }\n }\n </style>\n</head>\n<body>\n\n<div class=\"dashboard\" id=\"dashboard\">\n\n <!-- HEADER -->\n <div class=\"header\">\n <div class=\"header-left\">\n <div class=\"logo-mark\"><i class=\"fa-solid fa-gauge-high\"></i></div>\n <div class=\"header-title\">\n <h1>驾驶行为识别与统计</h1>\n <span>Driving Behavior Analytics</span>\n </div>\n </div>\n <div class=\"header-right\">\n <div class=\"btn-group\" id=\"granularityGroup\">\n <button data-gran=\"day\" class=\"active\">按日</button>\n <button data-gran=\"month\">按月</button>\n <button data-gran=\"year\">按年</button>\n </div>\n <input type=\"date\" class=\"date-input\" id=\"startDate\" value=\"2026-04-01\">\n <span class=\"date-separator\">至</span>\n <input type=\"date\" class=\"date-input\" id=\"endDate\" value=\"2026-04-27\">\n <button class=\"btn-primary\" onclick=\"refreshData()\"><i class=\"fa-solid fa-arrows-rotate\"></i>&nbsp;刷新</button>\n <button class=\"btn-action\" onclick=\"exportCSV()\" title=\"导出CSV\"><i class=\"fa-solid fa-file-csv\"></i></button>\n <button class=\"btn-action\" onclick=\"printDashboard()\" title=\"打印\"><i class=\"fa-solid fa-print\"></i></button>\n </div>\n </div>\n\n <!-- METRICS ROW: 6 cards -->\n <div class=\"metrics-row\" id=\"metricsRow\"></div>\n\n <!-- CHARTS AREA: 3 columns -->\n <div class=\"charts-area\">\n <!-- LINE CHART: 事件次数趋势 (4条线) -->\n <div class=\"chart-box\" id=\"lineChartBox\">\n <div class=\"chart-box-header\">\n <span class=\"chart-box-title\"><i class=\"fa-solid fa-chart-line\"></i>&nbsp;事件次数趋势</span>\n <div class=\"chart-legend\">\n <span><span class=\"dot\" style=\"background:#e03131\"></span>急加速</span>\n <span><span class=\"dot\" style=\"background:#e8590c\"></span>急减速</span>\n <span><span class=\"dot\" style=\"background:#e67700\"></span>超速</span>\n <span><span class=\"dash\" style=\"border-color:#1c7ed6\"></span>怠速</span>\n </div>\n </div>\n <div class=\"chart-canvas-wrap\"><canvas id=\"lineChart\"></canvas></div>\n </div>\n\n <!-- GROUPED BAR: 时长分布 -->\n <div class=\"chart-box\" id=\"barChartBox\">\n <div class=\"chart-box-header\">\n <span class=\"chart-box-title\"><i class=\"fa-solid fa-chart-bar\"></i>&nbsp;时长分布</span>\n <div class=\"chart-legend\">\n <span><span class=\"dot\" style=\"background:#7950f2\"></span>超速时间</span>\n <span><span class=\"dot\" style=\"background:#0c8599\"></span>怠速超时</span>\n </div>\n </div>\n <div class=\"chart-canvas-wrap\"><canvas id=\"barChart\"></canvas></div>\n </div>\n\n <!-- DONUT: 事件占比 (4段) -->\n <div class=\"ratio-panel\" id=\"ratioPanel\">\n <div class=\"ratio-header\">\n <span class=\"chart-box-title\"><i class=\"fa-solid fa-chart-pie\"></i>&nbsp;驾驶事件占比</span>\n </div>\n <div class=\"ratio-body\">\n <div class=\"ratio-donut-wrap\"><canvas id=\"donutChart\"></canvas></div>\n <div class=\"ratio-legend-vert\" id=\"ratioLegend\"></div>\n </div>\n </div>\n </div>\n\n <!-- TABLE -->\n <div class=\"table-section\">\n <div class=\"table-section-header\">\n <span class=\"table-section-title\"><i class=\"fa-solid fa-table-list\"></i>&nbsp;行为数据明细</span>\n <span style=\"font-size:9px;color:var(--text-muted)\" id=\"tableInfo\">共 0 条记录</span>\n </div>\n <div class=\"table-wrap\">\n <table>\n <thead>\n <tr>\n <th>周期</th>\n <th>急加速</th>\n <th>急减速</th>\n <th>超速</th>\n <th>怠速(次)</th>\n <th>超速时间</th>\n <th>怠速超时</th>\n <th>事件总数</th>\n </tr>\n </thead>\n <tbody id=\"tableBody\"></tbody>\n </table>\n </div>\n </div>\n</div>\n\n<div class=\"toast-container\" id=\"toastContainer\"></div>\n\n<script>\n(function() {\n 'use strict';\n\n // ==================== STATE ====================\n const state = {\n granularity: 'day',\n startDate: '2026-04-01',\n endDate: '2026-04-27',\n };\n\n const $ = (sel) => document.querySelector(sel);\n\n const dom = {\n granularityGroup: $('#granularityGroup'),\n startDate: $('#startDate'),\n endDate: $('#endDate'),\n metricsRow: $('#metricsRow'),\n lineChart: $('#lineChart'),\n barChart: $('#barChart'),\n donutChart: $('#donutChart'),\n tableBody: $('#tableBody'),\n tableInfo: $('#tableInfo'),\n toastContainer: $('#toastContainer'),\n ratioLegend: $('#ratioLegend'),\n };\n\n // ==================== TOAST ====================\n function showToast(msg, icon) {\n const el = document.createElement('div');\n el.className = 'toast';\n el.innerHTML = `<i class=\"fa-solid ${icon || 'fa-circle-check'}\"></i>${msg}`;\n dom.toastContainer.appendChild(el);\n requestAnimationFrame(() => el.classList.add('show'));\n setTimeout(() => {\n el.classList.remove('show');\n setTimeout(() => el.remove(), 350);\n }, 2200);\n }\n\n // ==================== DATA GENERATION ====================\n function seedRandom(seed) {\n let s = seed;\n return function() {\n s = (s * 16807 + 0) % 2147483647;\n return (s - 1) / 2147483646;\n };\n }\n\n function generateRecords(granularity, start, end) {\n const records = [];\n const startD = new Date(start);\n const endD = new Date(end);\n\n if (isNaN(startD.getTime()) || isNaN(endD.getTime()) || startD > endD) return records;\n\n let labels = [];\n let current = new Date(startD);\n\n if (granularity === 'day') {\n while (current <= endD) {\n labels.push(new Date(current));\n current.setDate(current.getDate() + 1);\n }\n } else if (granularity === 'month') {\n current = new Date(startD.getFullYear(), startD.getMonth(), 1);\n const endMonth = new Date(endD.getFullYear(), endD.getMonth(), 1);\n while (current <= endMonth) {\n labels.push(new Date(current));\n current.setMonth(current.getMonth() + 1);\n }\n } else {\n current = new Date(startD.getFullYear(), 0, 1);\n const endYear = new Date(endD.getFullYear(), 0, 1);\n while (current <= endYear) {\n labels.push(new Date(current));\n current.setFullYear(current.getFullYear() + 1);\n }\n }\n\n if (labels.length > 120) {\n const step = Math.ceil(labels.length / 100);\n labels = labels.filter((_, i) => i % step === 0);\n }\n\n labels.forEach((d, i) => {\n const rng = seedRandom(d.getTime() % 99997 + i * 131);\n\n let baseMult;\n if (granularity === 'year') baseMult = 300;\n else if (granularity === 'month') baseMult = 25;\n else baseMult = 1;\n\n const harshAccel = Math.round(baseMult * (1.5 + rng() * 4.5));\n const harshDecel = Math.round(baseMult * (0.8 + rng() * 3.2));\n const speeding = Math.round(baseMult * (0.4 + rng() * 2.6));\n\n // 怠速次数: separate from 怠速超时, this is count of idle events\n const idleCount = Math.round(baseMult * (1.0 + rng() * 3.5));\n\n const speedingTimePerEvent = 0.04 + rng() * 0.28;\n const speedingTime = Math.round(speeding * speedingTimePerEvent * 100) / 100;\n\n const totalDrivingHours = baseMult * (3 + rng() * 8);\n const idleExcessRate = 0.02 + rng() * 0.08;\n const idleExcessTime = Math.round(totalDrivingHours * idleExcessRate * 100) / 100;\n\n const totalEvents = harshAccel + harshDecel + speeding + idleCount;\n\n records.push({\n label: d,\n harshAccel,\n harshDecel,\n speeding,\n idleCount,\n speedingTime,\n idleExcessTime,\n totalEvents,\n totalDrivingHours: Math.round(totalDrivingHours * 10) / 10,\n });\n });\n\n return records;\n }\n\n function formatLabel(d, granularity) {\n if (granularity === 'day') return `${d.getMonth() + 1}/${d.getDate()}`;\n if (granularity === 'month') return `${d.getFullYear()}/${String(d.getMonth() + 1).padStart(2, '0')}`;\n return `${d.getFullYear()}`;\n }\n\n function formatFullLabel(d, granularity) {\n if (granularity === 'day') return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;\n if (granularity === 'month') return `${d.getFullYear()}年${d.getMonth() + 1}月`;\n return `${d.getFullYear()}年`;\n }\n\n // ==================== METRICS ====================\n function renderMetrics(records) {\n if (records.length === 0) {\n dom.metricsRow.innerHTML = Array.from({ length: 6 }, () => `\n <div class=\"metric-card\"><div class=\"card-accent\"></div><div class=\"card-label\">--</div><div class=\"card-value\">0</div></div>`).join('');\n return;\n }\n\n const sums = records.reduce((acc, r) => {\n acc.harshAccel += r.harshAccel;\n acc.harshDecel += r.harshDecel;\n acc.speeding += r.speeding;\n acc.idleCount += r.idleCount;\n acc.speedingTime += r.speedingTime;\n acc.idleExcessTime += r.idleExcessTime;\n acc.totalEvents += r.totalEvents;\n return acc;\n }, { harshAccel: 0, harshDecel: 0, speeding: 0, idleCount: 0, speedingTime: 0, idleExcessTime: 0, totalEvents: 0 });\n\n const cfg = [\n { icon: 'fa-bolt', label: '急加速次数', value: sums.harshAccel.toString(), unit: '次', color: '#e03131' },\n { icon: 'fa-down-left-and-up-right-to-center', label: '急减速/急刹车', value: sums.harshDecel.toString(), unit: '次', color: '#e8590c' },\n { icon: 'fa-gauge-high', label: '超速次数', value: sums.speeding.toString(), unit: '次', color: '#e67700' },\n { icon: 'fa-clock', label: '超速时间', value: sums.speedingTime.toFixed(1), unit: 'h', color: '#7950f2' },\n { icon: 'fa-hourglass-end', label: '怠速超时', value: sums.idleExcessTime.toFixed(1), unit: 'h', color: '#0c8599' },\n { icon: 'fa-pause', label: '怠速次数', value: sums.idleCount.toString(), unit: '次', color: '#1c7ed6' },\n ];\n\n dom.metricsRow.innerHTML = cfg.map(c => `\n <div class=\"metric-card\">\n <div class=\"card-accent\"></div>\n <div class=\"card-icon\"><i class=\"fa-solid ${c.icon}\"></i></div>\n <div class=\"card-label\">${c.label}</div>\n <div class=\"card-value\" style=\"color:${c.color}\">${c.value}</div>\n <div class=\"card-sub\">${c.unit}</div>\n </div>\n `).join('');\n }\n\n // ==================== LINE CHART (4条线) ====================\n function renderLineChart(canvas, records, granularity) {\n if (!canvas || records.length === 0) return;\n const dpr = window.devicePixelRatio || 1;\n const rect = canvas.parentElement.getBoundingClientRect();\n const w = rect.width;\n const h = rect.height;\n canvas.width = w * dpr;\n canvas.height = h * dpr;\n canvas.style.width = w + 'px';\n canvas.style.height = h + 'px';\n const ctx = canvas.getContext('2d');\n ctx.scale(dpr, dpr);\n ctx.clearRect(0, 0, w, h);\n\n const pad = { top: 8, right: 14, bottom: 26, left: 40 };\n const cw = w - pad.left - pad.right;\n const ch = h - pad.top - pad.bottom;\n\n const lineDefs = [\n { key: 'harshAccel', color: '#e03131', dash: false },\n { key: 'harshDecel', color: '#e8590c', dash: false },\n { key: 'speeding', color: '#e67700', dash: false },\n { key: 'idleCount', color: '#1c7ed6', dash: true },\n ];\n\n let maxVal = 0;\n lineDefs.forEach(def => {\n records.forEach(r => { if (r[def.key] > maxVal) maxVal = r[def.key]; });\n });\n maxVal = Math.ceil(maxVal * 1.2) || 10;\n\n // Grid\n const gridLines = 5;\n ctx.strokeStyle = '#eef0f4';\n ctx.lineWidth = 1;\n for (let i = 0; i <= gridLines; i++) {\n const y = pad.top + (ch / gridLines) * i;\n ctx.beginPath();\n ctx.moveTo(pad.left, y);\n ctx.lineTo(w - pad.right, y);\n ctx.stroke();\n ctx.fillStyle = '#94a3b8';\n ctx.font = '8px DM Sans, sans-serif';\n ctx.textAlign = 'right';\n ctx.fillText(Math.round(maxVal - (maxVal / gridLines) * i), pad.left - 5, y + 3);\n }\n\n // X labels\n const xStep = cw / Math.max(records.length - 1, 1);\n const maxLabels = Math.min(records.length, 14);\n const labelStep = Math.max(1, Math.floor(records.length / maxLabels));\n ctx.fillStyle = '#94a3b8';\n ctx.font = '8px DM Sans, sans-serif';\n ctx.textAlign = 'center';\n records.forEach((r, i) => {\n if (i % labelStep === 0 || i === records.length - 1) {\n ctx.fillText(formatLabel(r.label, granularity), pad.left + xStep * i, pad.top + ch + 14);\n }\n });\n\n // Draw lines\n lineDefs.forEach(def => {\n ctx.strokeStyle = def.color;\n ctx.lineWidth = 2;\n ctx.lineJoin = 'round';\n ctx.lineCap = 'round';\n if (def.dash) {\n ctx.setLineDash([5, 3]);\n } else {\n ctx.setLineDash([]);\n }\n ctx.beginPath();\n records.forEach((r, i) => {\n const x = pad.left + xStep * i;\n const y = pad.top + ch - (r[def.key] / maxVal) * ch;\n if (i === 0) ctx.moveTo(x, y);\n else ctx.lineTo(x, y);\n });\n ctx.stroke();\n ctx.setLineDash([]);\n\n // Dots (only for solid lines)\n if (!def.dash) {\n records.forEach((r, i) => {\n const x = pad.left + xStep * i;\n const y = pad.top + ch - (r[def.key] / maxVal) * ch;\n ctx.fillStyle = '#ffffff';\n ctx.beginPath();\n ctx.arc(x, y, 3, 0, Math.PI * 2);\n ctx.fill();\n ctx.fillStyle = def.color;\n ctx.beginPath();\n ctx.arc(x, y, 1.8, 0, Math.PI * 2);\n ctx.fill();\n });\n } else {\n // Small diamonds for dashed line\n records.forEach((r, i) => {\n if (i % 2 === 0) return;\n const x = pad.left + xStep * i;\n const y = pad.top + ch - (r[def.key] / maxVal) * ch;\n ctx.fillStyle = def.color;\n ctx.beginPath();\n ctx.arc(x, y, 2.5, 0, Math.PI * 2);\n ctx.fill();\n });\n }\n });\n }\n\n // ==================== BAR CHART (时长分布) ====================\n function renderBarChart(canvas, records, granularity) {\n if (!canvas || records.length === 0) return;\n const dpr = window.devicePixelRatio || 1;\n const rect = canvas.parentElement.getBoundingClientRect();\n const w = rect.width;\n const h = rect.height;\n canvas.width = w * dpr;\n canvas.height = h * dpr;\n canvas.style.width = w + 'px';\n canvas.style.height = h + 'px';\n const ctx = canvas.getContext('2d');\n ctx.scale(dpr, dpr);\n ctx.clearRect(0, 0, w, h);\n\n const pad = { top: 8, right: 14, bottom: 26, left: 42 };\n const cw = w - pad.left - pad.right;\n const ch = h - pad.top - pad.bottom;\n\n const barDefs = [\n { key: 'speedingTime', color: '#7950f2' },\n { key: 'idleExcessTime', color: '#0c8599' },\n ];\n\n let maxVal = 0;\n records.forEach(r => {\n const sum = barDefs.reduce((a, b) => a + r[b.key], 0);\n if (sum > maxVal) maxVal = sum;\n });\n maxVal = Math.ceil(maxVal * 1.25) || 5;\n\n const gridLines = 4;\n ctx.strokeStyle = '#eef0f4';\n ctx.lineWidth = 1;\n for (let i = 0; i <= gridLines; i++) {\n const y = pad.top + (ch / gridLines) * i;\n ctx.beginPath();\n ctx.moveTo(pad.left, y);\n ctx.lineTo(w - pad.right, y);\n ctx.stroke();\n ctx.fillStyle = '#94a3b8';\n ctx.font = '8px DM Sans, sans-serif';\n ctx.textAlign = 'right';\n ctx.fillText((maxVal - (maxVal / gridLines) * i).toFixed(1), pad.left - 5, y + 3);\n }\n\n const maxBars = Math.min(records.length, 22);\n const step = Math.max(1, Math.floor(records.length / maxBars));\n const displayRecords = records.filter((_, i) => i % step === 0 || i === records.length - 1);\n const groupGap = 3;\n const totalW = cw / displayRecords.length;\n const barW = (totalW - groupGap) / 2;\n if (barW <= 0) return;\n\n displayRecords.forEach((r, di) => {\n const groupX = pad.left + (cw / displayRecords.length) * di;\n barDefs.forEach((def, bi) => {\n const x = groupX + bi * (barW + groupGap / 2);\n const barH = (r[def.key] / maxVal) * ch;\n const y = pad.top + ch - barH;\n ctx.fillStyle = def.color;\n ctx.fillRect(x, y, barW, barH);\n });\n\n const labelStep2 = Math.max(1, Math.floor(displayRecords.length / 8));\n if (di % labelStep2 === 0 || di === displayRecords.length - 1) {\n ctx.fillStyle = '#94a3b8';\n ctx.font = '8px DM Sans, sans-serif';\n ctx.textAlign = 'center';\n ctx.fillText(formatLabel(r.label, granularity), groupX + totalW / 2, pad.top + ch + 14);\n }\n });\n }\n\n // ==================== DONUT (4段事件占比) ====================\n function renderDonutChart(canvas, records) {\n if (!canvas || records.length === 0) return;\n const dpr = window.devicePixelRatio || 1;\n const rect = canvas.parentElement.getBoundingClientRect();\n const w = rect.width;\n const h = rect.height;\n canvas.width = w * dpr;\n canvas.height = h * dpr;\n canvas.style.width = w + 'px';\n canvas.style.height = h + 'px';\n const ctx = canvas.getContext('2d');\n ctx.scale(dpr, dpr);\n ctx.clearRect(0, 0, w, h);\n\n const sums = { harshAccel: 0, harshDecel: 0, speeding: 0, idleCount: 0 };\n records.forEach(r => {\n sums.harshAccel += r.harshAccel;\n sums.harshDecel += r.harshDecel;\n sums.speeding += r.speeding;\n sums.idleCount += r.idleCount;\n });\n const total = sums.harshAccel + sums.harshDecel + sums.speeding + sums.idleCount;\n\n const segments = [\n { label: '急加速', value: sums.harshAccel, pct: total > 0 ? (sums.harshAccel / total * 100) : 0, color: '#e03131' },\n { label: '急减速', value: sums.harshDecel, pct: total > 0 ? (sums.harshDecel / total * 100) : 0, color: '#e8590c' },\n { label: '超速', value: sums.speeding, pct: total > 0 ? (sums.speeding / total * 100) : 0, color: '#e67700' },\n { label: '怠速', value: sums.idleCount, pct: total > 0 ? (sums.idleCount / total * 100) : 0, color: '#1c7ed6' },\n ];\n\n dom.ratioLegend.innerHTML = segments.map(s => `\n <div class=\"ratio-legend-item\">\n <div class=\"ratio-legend-dot\" style=\"background:${s.color}\"></div>\n <div>\n <div class=\"ratio-legend-val\">${s.pct.toFixed(1)}%</div>\n <div class=\"ratio-legend-label\">${s.label} ${s.value}次</div>\n </div>\n </div>\n `).join('');\n\n const cx = w / 2;\n const cy = h / 2;\n const outerR = Math.min(cx, cy) - 8;\n const innerR = outerR * 0.52;\n\n if (total === 0) {\n ctx.fillStyle = '#94a3b8';\n ctx.font = '11px DM Sans, sans-serif';\n ctx.textAlign = 'center';\n ctx.fillText('暂无数据', cx, cy + 4);\n return;\n }\n\n let startAngle = -Math.PI / 2;\n segments.forEach(seg => {\n const sliceAngle = (seg.pct / 100) * Math.PI * 2;\n if (sliceAngle < 0.001) return;\n ctx.beginPath();\n ctx.arc(cx, cy, outerR, startAngle, startAngle + sliceAngle);\n ctx.arc(cx, cy, innerR, startAngle + sliceAngle, startAngle, true);\n ctx.closePath();\n ctx.fillStyle = seg.color;\n ctx.fill();\n startAngle += sliceAngle;\n });\n\n ctx.fillStyle = '#0f172a';\n ctx.font = 'bold 13px DM Sans, sans-serif';\n ctx.textAlign = 'center';\n ctx.fillText(total + '次', cx, cy - 2);\n ctx.fillStyle = '#94a3b8';\n ctx.font = '8px DM Sans, sans-serif';\n ctx.fillText('事件总数', cx, cy + 12);\n }\n\n // ==================== TABLE ====================\n function renderTable(records, granularity) {\n dom.tableInfo.textContent = `共 ${records.length} 条记录`;\n if (records.length === 0) {\n dom.tableBody.innerHTML = '<tr><td colspan=\"8\" style=\"text-align:center;color:var(--text-muted);padding:14px;\">暂无数据</td></tr>';\n return;\n }\n const display = records.slice(-22);\n dom.tableBody.innerHTML = display.map(r => `\n <tr>\n <td>${formatFullLabel(r.label, granularity)}</td>\n <td class=\"cell-highlight\">${r.harshAccel}</td>\n <td class=\"cell-warn\">${r.harshDecel}</td>\n <td>${r.speeding}</td>\n <td class=\"cell-info\">${r.idleCount}</td>\n <td>${r.speedingTime.toFixed(2)}h</td>\n <td>${r.idleExcessTime.toFixed(2)}h</td>\n <td><strong>${r.totalEvents}</strong></td>\n </tr>\n `).join('');\n }\n\n // ==================== MAIN ====================\n let currentRecords = [];\n\n function updateDashboard(silent) {\n const records = generateRecords(state.granularity, state.startDate, state.endDate);\n currentRecords = records;\n\n renderMetrics(records);\n renderLineChart(dom.lineChart, records, state.granularity);\n renderBarChart(dom.barChart, records, state.granularity);\n renderDonutChart(dom.donutChart, records);\n renderTable(records, state.granularity);\n\n if (!silent) {\n const granLabel = state.granularity === 'day' ? '日' : state.granularity === 'month' ? '月' : '年';\n showToast(`已加载 ${records.length} 条${granLabel}度驾驶行为数据`, 'fa-circle-check');\n }\n }\n\n // ==================== EVENTS ====================\n dom.granularityGroup.addEventListener('click', (e) => {\n const btn = e.target.closest('button');\n if (!btn || btn.classList.contains('active')) return;\n dom.granularityGroup.querySelectorAll('button').forEach(b => b.classList.remove('active'));\n btn.classList.add('active');\n state.granularity = btn.dataset.gran;\n\n const now = new Date();\n if (state.granularity === 'day') {\n state.startDate = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10);\n state.endDate = now.toISOString().slice(0, 10);\n } else if (state.granularity === 'month') {\n state.startDate = new Date(now.getFullYear() - 1, now.getMonth(), 1).toISOString().slice(0, 10);\n state.endDate = now.toISOString().slice(0, 10);\n } else {\n state.startDate = new Date(now.getFullYear() - 4, 0, 1).toISOString().slice(0, 10);\n state.endDate = now.toISOString().slice(0, 10);\n }\n dom.startDate.value = state.startDate;\n dom.endDate.value = state.endDate;\n updateDashboard();\n });\n\n dom.startDate.addEventListener('change', () => {\n state.startDate = dom.startDate.value;\n updateDashboard();\n });\n\n dom.endDate.addEventListener('change', () => {\n state.endDate = dom.endDate.value;\n updateDashboard();\n });\n\n // ==================== GLOBALS ====================\n window.refreshData = function() {\n state.startDate = dom.startDate.value;\n state.endDate = dom.endDate.value;\n updateDashboard();\n };\n\n window.exportCSV = function() {\n if (currentRecords.length === 0) {\n showToast('无数据可导出', 'fa-triangle-exclamation');\n return;\n }\n let csv = '周期,急加速次数,急减速次数,超速次数,怠速次数,超速时间(h),怠速超时(h),事件总数\\n';\n currentRecords.forEach(r => {\n csv += [\n formatFullLabel(r.label, state.granularity),\n r.harshAccel, r.harshDecel, r.speeding, r.idleCount,\n r.speedingTime.toFixed(2), r.idleExcessTime.toFixed(2), r.totalEvents,\n ].join(',') + '\\n';\n });\n const blob = new Blob(['\\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });\n const url = URL.createObjectURL(blob);\n const a = document.createElement('a');\n a.href = url;\n a.download = `驾驶行为统计_${state.granularity}_${state.startDate}_${state.endDate}.csv`;\n a.click();\n URL.revokeObjectURL(url);\n showToast('CSV 已下载', 'fa-file-csv');\n };\n\n window.printDashboard = function() {\n showToast('正在准备打印...', 'fa-print');\n setTimeout(() => window.print(), 400);\n };\n\n // ==================== RESIZE ====================\n let resizeTid;\n window.addEventListener('resize', () => {\n clearTimeout(resizeTid);\n resizeTid = setTimeout(() => {\n if (currentRecords.length > 0) {\n renderLineChart(dom.lineChart, currentRecords, state.granularity);\n renderBarChart(dom.barChart, currentRecords, state.granularity);\n renderDonutChart(dom.donutChart, currentRecords);\n }\n }, 250);\n });\n\n // ==================== INIT ====================\n function init() {\n const now = new Date();\n state.startDate = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10);\n state.endDate = now.toISOString().slice(0, 10);\n dom.startDate.value = state.startDate;\n dom.endDate.value = state.endDate;\n updateDashboard(true);\n }\n\n init();\n})();\n</script>\n\n</body>\n</html>"
},
"next": ["按驾驶员分组排行", "添加安全评分模型", "设置实时告警推送", "对比不同车队表现", "导出月度行为报告"],
"breakpoint": "none"
}
```

total_tokens
76842
summary
驾驶行为识别统计仪表盘,新增怠速次数指标,6项核心指标卡(急加速/急减速/超速次数+超速时间+怠速超时+怠速次数),折线趋势4线+柱状时长分布+环形事件占比4段,支持年/月/日粒度、日期区间选取与CSV导出。
uiParsing
关闭