Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a237df8b75 | ||
|
|
d8871964a4 | ||
|
|
1e57586a35 | ||
|
|
187a72d6b0 | ||
|
|
3657661fa7 | ||
|
|
00c09d7452 | ||
|
|
5751d1af15 | ||
|
|
24984fe033 | ||
|
|
7dd7e67602 |
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ln-bi",
|
||||
"version": "1.1.19",
|
||||
"version": "1.1.21",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ln-bi",
|
||||
"version": "1.1.19",
|
||||
"version": "1.1.21",
|
||||
"dependencies": {
|
||||
"@amap/amap-jsapi-loader": "^1.0.1",
|
||||
"@hono/node-server": "^1.13.0",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ln-bi",
|
||||
"private": true,
|
||||
"version": "1.1.19",
|
||||
"version": "1.1.21",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"",
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { OverviewViewProps } from './overview/types';
|
||||
export function OverviewView(props: OverviewViewProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<SummaryMetrics
|
||||
summary={props.summary}
|
||||
operatingRate={props.operatingRate}
|
||||
@@ -17,6 +18,7 @@ export function OverviewView(props: OverviewViewProps) {
|
||||
/>
|
||||
|
||||
<AssetHistorySection subject={props.selectedSubject} onSubjects={props.onHistorySubjects} />
|
||||
</div>
|
||||
|
||||
<FlowStatisticsCard
|
||||
flowRange={props.flowRange}
|
||||
|
||||
@@ -10,7 +10,7 @@ export function SummaryMetrics({
|
||||
return (
|
||||
<>
|
||||
{/* Header Summary - Ultra Compact */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 mb-2">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||
{/* Total Assets */}
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm flex items-center gap-2 cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', source: 'asset', title: '资产概览' })}>
|
||||
@@ -66,7 +66,7 @@ export function SummaryMetrics({
|
||||
|
||||
</div>
|
||||
|
||||
<div data-testid="asset-operation-ratio-strip" className="mb-3 rounded-2xl border border-slate-100 bg-white/85 px-3 py-2 shadow-sm">
|
||||
<div data-testid="asset-operation-ratio-strip" className="rounded-xl border border-slate-100 bg-white/85 px-3 py-1 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="shrink-0 text-[11px] font-black text-slate-400">运营概览</div>
|
||||
<div className="grid min-w-0 flex-1 grid-cols-2 divide-x divide-slate-100 text-center">
|
||||
|
||||
@@ -69,18 +69,17 @@ export function AssetHistorySection({ subject, onSubjects }: { subject: string |
|
||||
<div className="asset-history-toolbar">
|
||||
{mode === 'monthly' && <HistorySelect label="历史年份" value={String(year)} options={(data?.years || [year]).map(y => ({ value: String(y), label: `${y}年` }))} onChange={value => { setYear(Number(value)); setDetails(null); }} />}
|
||||
{data && <HistorySelect label="统计期间" value={selected || ''} onChange={value => { setSelected(value); setDetails(null); }} options={data.points.map(p => ({ value: p.period, label: p.label, detail: p.state === 'RECONSTRUCTED' ? '估算' : p.state === 'LIVE' ? '实时' : p.state === 'ARCHIVED' ? '归档' : p.state === 'FUTURE' ? '未来' : p.state === 'MISSING' ? '缺失' : HISTORY_STATE_LABELS[p.state] }))} />}
|
||||
<span title="每月最后一天北京时间17:00采集;年度复用12月快照">月末 17:00</span>
|
||||
<span className="asset-history-cutoff" title="每月最后一天北京时间17:00采集;年度复用12月快照">月末 17:00</span>
|
||||
<button type="button" className="asset-history-refresh" aria-label="刷新资产历史趋势" disabled={loading} onClick={() => setRetry(x => x + 1)}><RefreshCw size={15} /></button>
|
||||
</div>
|
||||
</div>
|
||||
{loading && !data ? <p className="asset-history-empty" role="status">正在加载资产历史趋势…</p> : !data ? <p className="asset-history-empty" role="alert">{error || '暂无可用数据'} <button type="button" onClick={() => setRetry(x => x + 1)}>重试</button></p> : <>
|
||||
{error && <p className="asset-history-warning" role="alert">{error} · 保留上次查询结果 <button type="button" onClick={() => setRetry(x => x + 1)}>重试</button></p>}
|
||||
<p className="asset-history-unit">数量(台)</p>
|
||||
<div className="asset-history-chart" role="img" aria-label="运营数与库存数堆叠柱,资产总数折线;精确数值和缺失状态可通过上方统计期间选择框查看">
|
||||
<ResponsiveContainer width="100%" height="100%" minWidth={0} initialDimension={{ width: 480, height: 200 }}>
|
||||
<ComposedChart data={chart} margin={{ top: 10, right: 14, bottom: 0, left: 0 }} onClick={state => { const p = chart.find(p => p.label === String(state?.activeLabel)); if (p) setSelected(p.period); }}>
|
||||
<ComposedChart data={chart} margin={{ top: 18, right: 10, bottom: 0, left: 0 }} onClick={state => { const p = chart.find(p => p.label === String(state?.activeLabel)); if (p) setSelected(p.period); }}>
|
||||
<CartesianGrid stroke="#e9eff7" vertical={false} /><XAxis dataKey="label" tick={{ fontSize: 10, fill: '#64748b' }} axisLine={{ stroke: '#dbe4ef' }} tickLine={false} interval={mode === 'monthly' ? 0 : 'preserveStartEnd'} minTickGap={4} />
|
||||
<YAxis allowDecimals={false} width={44} tick={{ fontSize: 11, fill: '#64748b' }} axisLine={false} tickLine={false} />
|
||||
<YAxis allowDecimals={false} tickCount={4} width={36} tick={{ fontSize: 11, fill: '#64748b' }} axisLine={false} tickLine={false} />
|
||||
<Tooltip content={({ active, payload }) => {
|
||||
const item = payload?.[0]?.payload as HistoryPoint | undefined;
|
||||
const values = item && displayHistoryCounts(item);
|
||||
@@ -92,16 +91,19 @@ export function AssetHistorySection({ subject, onSubjects }: { subject: string |
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="asset-history-legend" aria-hidden="true"><span><i className="operating" />运营数</span><span><i className="inventory" />库存数</span><span><i className="total" />资产总数</span></div>
|
||||
<div className="asset-history-legend" aria-hidden="true"><span><i className="operating" />运营数</span><span><i className="inventory" />库存数</span><span><i className="total" />资产总数</span><span className="asset-history-unit">台</span></div>
|
||||
{point && <div className="asset-history-selected" aria-live="polite">
|
||||
<div className="asset-history-selected-heading"><strong>{mode === 'annual' || point.state === 'LIVE' ? point.label : `${point.label}末`}</strong><span>{HISTORY_STATE_LABELS[point.state]}</span>{point.asOf && <time dateTime={point.asOf} aria-label={`截至 ${historyTime(point.asOf)}`}>{historyTime(point.asOf)}</time>}</div>
|
||||
<div className="asset-history-counts">{([['total', '总数'], ['inventory', '库存'], ['operating', '运营']] as const).map(([key, label]) => <div key={key}><span aria-label={`${label}(台)`}>{label}(台)</span><strong>{displayHistoryCounts(point)?.[key]?.toLocaleString() ?? (point.reconstruction && key === 'total' ? '待核实' : '—')}</strong></div>)}</div>
|
||||
<div className="asset-history-selected-heading"><strong>{mode === 'annual' || point.state === 'LIVE' ? point.label : `${point.label}末`}</strong><span>{HISTORY_STATE_LABELS[point.state]}</span></div>
|
||||
<div className="asset-history-counts">{([['total', '总数'], ['inventory', '库存'], ['operating', '运营']] as const).map(([key, label]) => <div key={key}><span aria-label={`${label}(台)`}>{label}</span><strong>{displayHistoryCounts(point)?.[key]?.toLocaleString() ?? (point.reconstruction && key === 'total' ? '待核实' : '—')}</strong></div>)}</div>
|
||||
{hasComparison ? <div className="asset-history-comparison"><span>{point.comparisonLabel}</span><div>{(['total', 'inventory', 'operating'] as const).map(key => {
|
||||
const difference = displayHistoryDifference(point, key);
|
||||
return <span key={key} className={difference != null && difference > 0 ? 'asset-history-increase' : difference != null && difference < 0 ? 'asset-history-decrease' : ''}>{difference != null ? signedCount(difference) : '—'}</span>;
|
||||
})}</div></div> : <p>{point.comparisonLabel}</p>}
|
||||
})}</div></div> : null}
|
||||
<div className="asset-history-selected-footer">
|
||||
<div className="asset-history-point-meta">{point.asOf && <time dateTime={point.asOf} aria-label={`截至 ${historyTime(point.asOf)}`}>{historyTime(point.asOf)}</time>}{!hasComparison && <span>{point.comparisonLabel}</span>}</div>
|
||||
{mode === 'monthly' && <button type="button" className="asset-history-detail-button" disabled={!point.detailAvailable} onClick={() => setDetails(point)}><span>{point.reconstruction ? '查看月度回溯明细' : '查看月度明细'}</span><ChevronRight size={16} aria-hidden="true" /></button>}
|
||||
</div>
|
||||
</div>}
|
||||
{point && mode === 'monthly' && <button type="button" className="asset-history-detail-button" disabled={!point.detailAvailable} onClick={() => setDetails(point)}><span>{point.reconstruction ? '查看月度回溯明细' : '查看月度明细'}</span><ChevronRight size={18} /></button>}
|
||||
<p className="asset-history-footnote">历史库存状态快照缺失,核对中,暂不展示。</p>
|
||||
</>}
|
||||
{details && <HistoryDetailsDialog point={details} subject={subject} close={closeDetails} />}
|
||||
|
||||
@@ -1,41 +1,43 @@
|
||||
.asset-history { --history-blue:#1559e8; margin:8px 0 16px; padding:16px; border:1px solid #e6edf5; border-radius:20px; background:#fff; color:#142b4e; box-shadow:0 5px 24px rgb(22 53 90 / 3%); }
|
||||
.asset-history button,.asset-history-dialog button { cursor:pointer; }
|
||||
.asset-history { --history-blue:#1559e8; --history-ink:#142b4e; --history-muted:#62748b; --history-border:#e3ebf5; margin:0; padding:16px; border:1px solid var(--history-border); border-radius:16px; background:#fff; color:var(--history-ink); box-shadow:0 2px 12px rgb(22 53 90 / 3%); }
|
||||
.asset-history button,.asset-history-dialog button { cursor:pointer; touch-action:manipulation; }
|
||||
.asset-history button:disabled,.asset-history-dialog button:disabled { cursor:default; opacity:.45; }
|
||||
.asset-history button:focus-visible,.asset-history select:focus-visible,.asset-history-dialog :is(button,input,select):focus-visible { outline:2px solid #2563eb; outline-offset:3px; }
|
||||
.asset-history-header { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:12px; }
|
||||
.asset-history-header h2 { margin:0; font-size:20px; font-weight:750; letter-spacing:-.5px; }
|
||||
.asset-history-modes { display:flex; gap:2px; background:#eef3fa; border:1px solid #e7edf6; border-radius:11px; padding:3px; }
|
||||
.asset-history-modes button { padding:7px 20px; min-height:34px; border-radius:8px; font-size:13px; font-weight:600; transition:background .15s,color .15s,box-shadow .15s; color:#64748b; }
|
||||
.asset-history-modes button[aria-pressed=true] { background:#fff; color:#1559e8; box-shadow:0 1px 4px #193c6920, inset 0 0 0 1px #dce7f5; }
|
||||
.asset-history-toolbar { display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin:8px 0 4px; font-size:12px; color:#64748b; }
|
||||
.asset-history button:focus-visible,.asset-history-dialog :is(button,input,select):focus-visible { outline:2px solid #2563eb; outline-offset:3px; }
|
||||
.asset-history-header { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:8px; }
|
||||
.asset-history-header h2 { margin:0; font-size:18px; font-weight:700; letter-spacing:-.4px; }
|
||||
.asset-history-modes { display:flex; gap:2px; background:#f0f4f9; border-radius:10px; padding:0 3px; }
|
||||
.asset-history-modes button { position:relative; isolation:isolate; min-width:52px; min-height:44px; padding:8px 14px; font-size:13px; font-weight:600; color:var(--history-muted); transition:color .16s; }
|
||||
.asset-history-modes button::before { content:""; position:absolute; inset:5px 0; z-index:-1; border-radius:7px; transition:background .16s,box-shadow .16s; }
|
||||
.asset-history-modes button[aria-pressed=true] { color:var(--history-blue); }
|
||||
.asset-history-modes button[aria-pressed=true]::before { background:#fff; box-shadow:0 1px 4px #193c691a,0 0 0 1px #e0e8f3; }
|
||||
.asset-history-toolbar { display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:4px 0 0; color:var(--history-muted); font-size:12px; }
|
||||
.asset-history-toolbar>span { margin-left:auto; }
|
||||
.asset-history-toolbar .asset-history-annual-note { margin-left:0; margin-right:auto; }
|
||||
.asset-history-refresh { display:grid; place-items:center; padding:8px; border-radius:8px; color:#7c8da5; transition:background .15s,color .15s; }
|
||||
.asset-history-refresh { display:grid; place-items:center; min-width:44px; min-height:44px; padding:8px; border-radius:8px; color:var(--history-muted); transition:background .16s,color .16s; }
|
||||
.asset-history-warning { margin:8px 0; font-size:12px; line-height:1.7; color:#74613a; background:#fff9ec; padding:9px 12px; border-radius:9px; }
|
||||
.asset-history-warning button,.asset-history-empty button { color:#1559e8; text-decoration:underline; padding:6px; }
|
||||
.asset-history-unit { font-size:12px; color:#64748b; margin:6px 0 0; }
|
||||
.asset-history-chart { height:200px; width:100%; min-width:0; }
|
||||
.asset-history-tooltip { background:#fff; border:1px solid #dce5ef; border-radius:10px; padding:10px 12px; box-shadow:0 4px 16px #142b4e15; font-size:12px; }
|
||||
.asset-history-warning button,.asset-history-empty button { color:var(--history-blue); text-decoration:underline; padding:6px; }
|
||||
.asset-history-chart { height:180px; width:100%; min-width:0; }
|
||||
.asset-history-tooltip { background:#fff; border:1px solid var(--history-border); border-radius:10px; padding:10px 12px; box-shadow:0 4px 16px #142b4e15; font-size:12px; }
|
||||
.asset-history-tooltip p { margin-top:4px; }
|
||||
.asset-history-legend { display:flex; justify-content:center; flex-wrap:wrap; gap:22px; color:#64748b; font-size:12px; margin:6px 0; }
|
||||
.asset-history-legend span { display:flex; align-items:center; gap:7px; }
|
||||
.asset-history-legend i { display:inline-block; height:11px; width:11px; border-radius:2px; }
|
||||
.asset-history-legend { display:flex; justify-content:center; align-items:center; flex-wrap:wrap; gap:16px; color:var(--history-muted); font-size:12px; line-height:18px; margin:2px 0 8px; }
|
||||
.asset-history-legend span { display:flex; align-items:center; gap:6px; }
|
||||
.asset-history-legend i { display:inline-block; height:8px; width:8px; border-radius:2px; }
|
||||
.asset-history-legend .operating { background:#347df5; }.asset-history-legend .inventory { background:#9ed1fa; }
|
||||
.asset-history-legend .total { width:20px; height:2px; background:#132e57; }
|
||||
.asset-history-selected { margin-top:8px; display:grid; grid-template-columns:90px minmax(0,1fr); align-items:center; gap:0 10px; padding:10px 14px; background:linear-gradient(115deg,#f3f7fe,#edf5ff); border:1px solid #e7effb; border-radius:12px; }
|
||||
.asset-history-legend .total { position:relative; width:16px; height:2px; background:#132e57; }
|
||||
.asset-history-legend .total::after { content:""; position:absolute; top:-2px; left:6px; width:6px; height:6px; border:2px solid #132e57; border-radius:50%; background:white; }
|
||||
.asset-history-legend .asset-history-unit { padding-left:8px; border-left:1px solid var(--history-border); }
|
||||
.asset-history-selected { display:grid; grid-template-columns:94px minmax(0,1fr); align-items:center; gap:0 8px; padding:10px 12px 0; background:#f2f7ff; border:1px solid #e4edfa; border-radius:10px; }
|
||||
.asset-history-selected-heading { display:flex; flex-direction:column; align-items:flex-start; gap:3px; min-width:0; }
|
||||
.asset-history-selected-heading>strong { color:#1559e8; font-size:17px; font-weight:750; }
|
||||
.asset-history-selected-heading>span,.asset-history-selected-heading>time { color:#64748b; font-size:11px; }
|
||||
.asset-history-selected-heading>strong { color:var(--history-blue); font-size:18px; line-height:24px; font-weight:700; }
|
||||
.asset-history-selected-heading>span { color:var(--history-muted); font-size:12px; line-height:18px; }
|
||||
.asset-history-counts { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); }
|
||||
.asset-history-counts>div { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:2px; min-width:0; border-right:1px solid #d4e2f4; }
|
||||
.asset-history-counts>div:last-child { border:0; }
|
||||
.asset-history-counts>div>span { color:#64748b; font-size:12px; }
|
||||
.asset-history-counts strong { font-size:23px; font-weight:750; line-height:1.2; font-variant-numeric:tabular-nums; }
|
||||
.asset-history-counts>div { display:flex; flex-direction:column; align-items:center; gap:3px; min-width:0; border-left:1px solid #dce7f6; }
|
||||
.asset-history-counts>div>span { color:var(--history-muted); font-size:12px; line-height:18px; }
|
||||
.asset-history-counts strong { font-size:24px; font-weight:700; line-height:28px; letter-spacing:-.4px; font-variant-numeric:tabular-nums; }
|
||||
.asset-history-increase { color:#bf2f30; }.asset-history-decrease { color:#14864a; }
|
||||
.asset-history-selected>p { grid-column:1 / -1; color:#64748b; font-size:11px; text-align:center; padding-top:6px; }
|
||||
.asset-history-detail-button { display:flex; width:100%; align-items:center; justify-content:space-between; min-height:44px; padding:8px 12px; margin-top:8px; border:1px solid #dce8f8; border-radius:10px; background:#f7faff; color:#18385d; font-size:14px; font-weight:600; }
|
||||
.asset-history-footnote { display:flex; align-items:center; flex-wrap:wrap; gap:6px; color:#64748b; font-size:11px; margin-top:8px; }
|
||||
.asset-history-empty { padding:32px 12px; text-align:center; font-size:13px; color:#64748b; }
|
||||
.asset-history-selected-footer { grid-column:1/-1; display:flex; align-items:center; justify-content:space-between; gap:8px; margin-top:8px; border-top:1px solid #e0eaf8; min-height:44px; }
|
||||
.asset-history-point-meta { display:flex; flex-wrap:wrap; gap:2px 12px; color:var(--history-muted); font-size:12px; line-height:18px; }
|
||||
.asset-history-detail-button { display:flex; flex-shrink:0; align-items:center; justify-content:space-between; gap:4px; min-height:44px; padding:8px 0 8px 8px; color:var(--history-blue); font-size:12px; font-weight:600; border-radius:6px; transition:color .16s,background .16s; }
|
||||
.asset-history-footnote { color:var(--history-muted); font-size:12px; line-height:18px; margin-top:8px; }
|
||||
.asset-history-empty { padding:32px 12px; text-align:center; font-size:13px; color:var(--history-muted); }
|
||||
.asset-history-overlay { position:fixed; inset:0; z-index:120; background:#0c1e3b66; display:flex; justify-content:center; align-items:center; padding:24px; }
|
||||
.asset-history-dialog { display:flex; flex-direction:column; width:min(980px,100%); max-height:88dvh; border-radius:18px; background:white; color:#183454; box-shadow:0 24px 64px #142b4e30; overflow:hidden; outline:none; }
|
||||
.asset-history-dialog>header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; padding:20px 22px; border-bottom:1px solid #e6edf5; }
|
||||
@@ -58,12 +60,12 @@
|
||||
.asset-history-dialog footer { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:12px 22px; border-top:1px solid #e6edf5; color:#64748b; font-size:12px; }
|
||||
.asset-history-dialog footer>div { display:flex; gap:8px; }.asset-history-dialog footer button { padding:9px; border:1px solid #dce5f0; border-radius:7px; }
|
||||
@media(max-width:640px) {
|
||||
.asset-history { padding:12px; border-radius:16px; }
|
||||
.asset-history-header h2 { font-size:18px; }.asset-history-modes button { padding:7px 15px; min-height:38px; }
|
||||
.asset-history-toolbar { gap:6px; }.asset-history-toolbar>span { font-size:10px; }
|
||||
.asset-history-chart { height:170px; }
|
||||
.asset-history-selected { grid-template-columns:72px minmax(0,1fr); gap:0 6px; padding:8px 10px; }.asset-history-selected-heading { gap:2px; }.asset-history-selected-heading>span,.asset-history-selected-heading>time { font-size:10px; }
|
||||
.asset-history-counts strong { font-size:21px; }.asset-history-counts>div>span { font-size:11px; }
|
||||
.asset-history { padding:12px; border-radius:14px; }
|
||||
.asset-history-header h2 { font-size:17px; }
|
||||
.asset-history-chart { height:140px; }
|
||||
.asset-history-selected { grid-template-columns:84px minmax(0,1fr); padding:8px 10px 0; gap:0 6px; }
|
||||
.asset-history-counts strong { font-size:22px; line-height:26px; }
|
||||
.asset-history-point-meta { flex-direction:column; gap:0; }
|
||||
.asset-history-overlay { padding:10px; }.asset-history-dialog { max-height:94dvh; border-radius:14px; }
|
||||
.asset-history-dialog>header { padding:14px; }.asset-history-dialog h2 { font-size:16px; }.asset-history-detail-tools { padding:12px 14px; }
|
||||
.asset-history-detail-note { padding:10px 14px; }.asset-history-dialog :is(input,select) { font-size:16px; }.asset-history-dialog footer { padding:12px 14px; }
|
||||
@@ -79,31 +81,18 @@
|
||||
}
|
||||
|
||||
@media(min-width:900px) {
|
||||
.asset-history-controls { display:flex; align-items:center; flex-wrap:wrap; gap:8px 24px; }
|
||||
.asset-history-controls { display:flex; align-items:center; gap:24px; flex-wrap:wrap; }
|
||||
.asset-history-header { flex:1; gap:16px; }
|
||||
.asset-history-toolbar { margin:0; }
|
||||
.asset-history-selected { display:grid; grid-template-columns:minmax(150px,1fr) 3fr; align-items:center; gap:0 18px; }
|
||||
.asset-history-selected-heading { display:grid; grid-template-columns:auto 1fr; align-items:center; gap:2px 12px; margin:0; }
|
||||
.asset-history-selected-heading>strong { grid-row:span 2; }
|
||||
.asset-history-selected-heading>span,.asset-history-selected-heading>time { grid-column:2; }
|
||||
.asset-history-selected>p { grid-column:1 / -1; }
|
||||
.asset-history-selected { grid-template-columns:minmax(150px,1fr) 3fr; gap:0 16px; }
|
||||
.asset-history-selected-heading { flex-direction:row; align-items:center; gap:12px; }
|
||||
}
|
||||
@media(max-width:640px) {
|
||||
.asset-history-modes button,.asset-history-refresh { min-height:44px; }
|
||||
.asset-history-refresh { min-width:44px; }
|
||||
.asset-history-toolbar { gap:4px; }
|
||||
}
|
||||
|
||||
.asset-history-comparison { grid-column:1 / -1; display:grid; grid-template-columns:90px minmax(0,1fr); gap:10px; align-items:center; margin-top:6px; padding-top:6px; border-top:1px solid #d4e2f4; font-size:11px; color:#64748b; }
|
||||
.asset-history-comparison { grid-column:1/-1; display:grid; grid-template-columns:94px minmax(0,1fr); gap:8px; align-items:center; margin-top:8px; padding-top:6px; border-top:1px solid #dce7f6; font-size:12px; color:var(--history-muted); }
|
||||
.asset-history-comparison>div { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); text-align:center; font-size:13px; font-weight:600; }
|
||||
@media(min-width:900px) {
|
||||
.asset-history-comparison { grid-template-columns:minmax(150px,1fr) 3fr; gap:18px; }
|
||||
}
|
||||
@media(max-width:640px) {
|
||||
.asset-history-comparison { grid-template-columns:72px minmax(0,1fr); gap:6px; }
|
||||
}
|
||||
@media(min-width:900px) { .asset-history-comparison { grid-template-columns:minmax(150px,1fr) 3fr; gap:16px; } }
|
||||
@media(max-width:640px) { .asset-history-comparison { grid-template-columns:84px minmax(0,1fr); gap:6px; } }
|
||||
|
||||
.asset-history-select { display:inline-flex; align-items:center; justify-content:space-between; gap:12px; min-width:76px; min-height:36px; padding:7px 11px; border:1px solid #dce5f1; border-radius:9px; background:#fff; color:#254365; font-size:13px; font-weight:550; box-shadow:0 1px 2px #173c6310; transition:border-color .15s,box-shadow .15s; }
|
||||
.asset-history-select { display:inline-flex; align-items:center; justify-content:space-between; gap:12px; min-width:76px; min-height:44px; padding:7px 11px; border:1px solid #dce5f1; border-radius:8px; background:#fff; color:#254365; font-size:13px; font-weight:550; box-shadow:0 1px 2px #173c6310; transition:border-color .15s,box-shadow .15s; }
|
||||
.asset-history-select[data-state=open] { border-color:#8fb4f2; box-shadow:0 0 0 3px #dbeaff80; }
|
||||
.asset-history-select-icon { color:#8b9db5; display:flex; transition:transform .15s; }
|
||||
.asset-history-select[data-state=open] .asset-history-select-icon { transform:rotate(180deg); }
|
||||
@@ -115,17 +104,19 @@
|
||||
.asset-history-option-detail { margin-left:auto; color:#7b8da5; font-size:11px; font-weight:400; }
|
||||
.asset-history-option-check { position:absolute; right:9px; display:flex; color:#2563eb; }
|
||||
.asset-history-select-scroll { display:flex; align-items:center; justify-content:center; height:24px; color:#7b8da5; }
|
||||
.asset-history-detail-button>svg { padding:3px; width:24px; height:24px; border-radius:7px; background:#eaf2ff; color:#527eb7; }
|
||||
.asset-history-detail-button>svg { flex-shrink:0; }
|
||||
@media(hover:hover) {
|
||||
.asset-history-modes button:not([aria-pressed=true]):hover { color:#1559e8; background:#e5edf9; }
|
||||
.asset-history-modes button:not([aria-pressed=true]):hover::before { background:#e5edf9; }
|
||||
.asset-history-select:hover { border-color:#adc5e8; }
|
||||
.asset-history-refresh:hover { color:#2563eb; background:#eef5ff; }
|
||||
.asset-history-detail-button:hover { background:#eef5ff; border-color:#bcd3f3; }
|
||||
.asset-history-detail-button:hover { color:#103eaa; background:#e9f1ff; }
|
||||
}
|
||||
@media(max-width:640px) {
|
||||
.asset-history-select { min-height:44px; padding:8px 10px; font-size:12px; gap:10px; }
|
||||
.asset-history-select-option { min-height:44px; }
|
||||
}
|
||||
@media(prefers-reduced-motion:reduce) {
|
||||
.asset-history-modes button,.asset-history-select,.asset-history-select-icon,.asset-history-refresh { transition:none; }
|
||||
.asset-history-modes button,.asset-history-modes button::before,.asset-history-select,.asset-history-select-icon,.asset-history-refresh,.asset-history-detail-button { transition:none; }
|
||||
}
|
||||
|
||||
.asset-history :is(.asset-history-select,.asset-history-refresh,.asset-history-detail-button):active { background:#e6effe; }
|
||||
|
||||
@@ -28,6 +28,7 @@ export default function HydrogenDaily() {
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setError(null);
|
||||
setRows(null);
|
||||
const query = pick === 'custom'
|
||||
? { startDate: effectiveRange.start, endDate: effectiveRange.end }
|
||||
: { range: pick };
|
||||
@@ -81,34 +82,13 @@ export default function HydrogenDaily() {
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<MetricTile icon={Fuel} label={`${scopeLabel}加氢量`} value={totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="Kg" helper={rangeText} />
|
||||
<MetricTile icon={Truck} label="车辆归属" value={customer === 'external' ? '外部' : '羚牛'} helper="当前筛选口径" tone="emerald" />
|
||||
<MetricTile icon={Truck} label="车辆归属" value={customer === 'external' ? '外部' : '羚牛'} helper={customer === 'external' ? '佛山南海羚牛加氢站 · 未关联车辆的订单' : '当前筛选口径'} tone="emerald" />
|
||||
<MetricTile icon={TrendingUp} label="有效天数" value={`${activeDays}/${rows?.length ?? 0}`} helper={`日均 ${avgKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} Kg`} tone="amber" />
|
||||
<MetricTile icon={Plug} label="涉及加氢站" value={stationCount} unit="站" helper="按明细站点去重" tone="slate" />
|
||||
</div>
|
||||
|
||||
{/* 外部车辆:新系统数据还没准备好 */}
|
||||
{customer === 'external' && rows !== null && totalKg === 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-white rounded-2xl border border-slate-100 shadow-sm px-6 py-14 flex flex-col items-center text-center"
|
||||
>
|
||||
<div className="w-14 h-14 rounded-2xl bg-blue-50 flex items-center justify-center mb-3 relative">
|
||||
<Plug size={22} className="text-blue-500" />
|
||||
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-blue-400 animate-ping" />
|
||||
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-blue-500" />
|
||||
</div>
|
||||
<div className="text-sm font-bold text-slate-700 mb-1">外部车辆 · 数据未就绪</div>
|
||||
<div className="text-[11px] text-slate-400 max-w-[280px] leading-relaxed">
|
||||
新系统的外部车辆加氢数据还在准备中
|
||||
<br />
|
||||
上线后此处将展示完整明细
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 时段加氢量柱图(外部车辆无数据时不渲染) */}
|
||||
{!(customer === 'external' && totalKg === 0) && trendData.length > 0 && (
|
||||
{/* 时段加氢量柱图 */}
|
||||
{!error && trendData.length > 0 && totalKg > 0 && (
|
||||
<SurfaceCard>
|
||||
<div className="flex items-center justify-between px-4 pt-4 mb-2">
|
||||
<span className="text-sm font-bold text-slate-700">每日加氢量</span>
|
||||
@@ -184,8 +164,7 @@ export default function HydrogenDaily() {
|
||||
</SurfaceCard>
|
||||
)}
|
||||
|
||||
{/* 表格(外部车辆 + 全 0 时不渲染,由上方友好空状态替代) */}
|
||||
{!(customer === 'external' && rows !== null && totalKg === 0) && (
|
||||
{/* 日明细:加载失败、无订单与正常数据分别展示。 */}
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
||||
{/* 表头 */}
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 bg-slate-50 text-[11px] font-bold text-slate-500">
|
||||
@@ -206,8 +185,8 @@ export default function HydrogenDaily() {
|
||||
<div className="p-3"><ErrorState message={error} /></div>
|
||||
) : rows === null ? (
|
||||
<div className="p-3"><LoadingState label="正在加载加氢明细" /></div>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="p-3"><EmptyState title="暂无加氢数据" description="请切换时间范围或车辆归属" /></div>
|
||||
) : rows.length === 0 || totalKg === 0 ? (
|
||||
<div className="p-3"><EmptyState title="所选时段暂无加氢数据" description={customer === 'external' ? '佛山南海羚牛加氢站在该时段没有未关联车辆的加氢订单,请切换时间范围' : '请切换时间范围或车辆归属'} /></div>
|
||||
) : rows.map(r => {
|
||||
const open = expanded.has(r.date);
|
||||
const isAbnormal = Math.abs(r.chainPct) >= 0.3;
|
||||
@@ -270,7 +249,6 @@ export default function HydrogenDaily() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -39,7 +39,7 @@ export function registerHydrogenDailyRoute(
|
||||
HYDROGEN_BASE_WHERE_B,
|
||||
`b.${HYDROGEN_LOCAL} >= '${HYDROGEN_MIN_DATE}'`,
|
||||
dateRangeClause(`b.${HYDROGEN_LOCAL}`),
|
||||
customerClause(customer).replaceAll('customer_price', 'b.customer_price').replaceAll('fee_total', 'b.fee_total'),
|
||||
customerClause(customer),
|
||||
].join(' AND ');
|
||||
|
||||
// 站点级聚合(每日 × 每站)。前端组装成 day → stations
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import {
|
||||
customerClause,
|
||||
dateRangeClause,
|
||||
@@ -7,10 +8,31 @@ import {
|
||||
resolveDateRange,
|
||||
} from './query-model.js';
|
||||
|
||||
test('客户范围继续让外部车辆返回空数据', () => {
|
||||
assert.equal(customerClause('external'), '1=0');
|
||||
assert.equal(customerClause('lingniu'), '1=1');
|
||||
assert.equal(customerClause('all'), '1=1');
|
||||
test('外部车辆只取佛山羚牛站未关联车辆的订单,车牌文字和收费不影响分类', () => {
|
||||
const db = new DatabaseSync(':memory:');
|
||||
try {
|
||||
db.exec(`CREATE TABLE orders (
|
||||
id INTEGER, station_id INTEGER, vehicle_id INTEGER,
|
||||
license_plate TEXT, customer_price REAL, del_flag TEXT
|
||||
);
|
||||
INSERT INTO orders VALUES
|
||||
(1, 341, NULL, '粤A外部', 38, '0'),
|
||||
(2, 341, NULL, '', 0, '0'),
|
||||
(3, 341, 123, '粤A羚牛', 38, '0'),
|
||||
(4, 52, NULL, '粤A其他站', 38, '0'),
|
||||
(5, NULL, NULL, '', 0, '0'),
|
||||
(6, NULL, 456, '粤A未关联站点', 0, '0'),
|
||||
(7, 341, NULL, '粤A已删除', 38, '1');`);
|
||||
const ids = (kind: 'external' | 'lingniu' | 'all') => db
|
||||
.prepare(`SELECT b.id FROM orders b WHERE b.del_flag = '0' AND ${customerClause(kind)} ORDER BY b.id`)
|
||||
.all().map(row => row.id);
|
||||
assert.deepEqual(ids('external'), [1, 2]);
|
||||
assert.deepEqual(ids('lingniu'), [3, 4, 5, 6]);
|
||||
assert.deepEqual(ids('all'), [1, 2, 3, 4, 5, 6]);
|
||||
assert.deepEqual([...ids('external'), ...ids('lingniu')].sort(), ids('all'));
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('能源日期快捷区间继续按本地自然日计算', () => {
|
||||
|
||||
@@ -8,10 +8,14 @@ export interface EnergyDateRange {
|
||||
|
||||
const YMD_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
// 新账本目前只承载羚牛车辆订单;外部车辆仍需返回空集合而不是混用羚牛数据。
|
||||
// 主数据 ID 341:佛山南海羚牛加氢站。车牌文字存在不代表已关联车辆。
|
||||
const FOSHAN_LINGNIU_STATION_ID = 341;
|
||||
|
||||
// 日明细账本使用别名 b;两类互斥,其他订单继续保留在原羚牛范围内。
|
||||
export function customerClause(customer: CustomerKind): string {
|
||||
if (customer === 'external') return '1=0';
|
||||
if (customer === 'lingniu') return '1=1';
|
||||
const external = `(COALESCE(b.station_id, 0) = ${FOSHAN_LINGNIU_STATION_ID} AND b.vehicle_id IS NULL)`;
|
||||
if (customer === 'external') return external;
|
||||
if (customer === 'lingniu') return `NOT ${external}`;
|
||||
return '1=1';
|
||||
}
|
||||
|
||||
|
||||
@@ -105,6 +105,11 @@ test('四个处理器保持缓存和参数契约,加氢站点使用新主数
|
||||
'/hydrogen/daily?startDate=2025-01-01&endDate=2025-01-02&customer=external&force=1',
|
||||
);
|
||||
assert.equal(dailyResponse.status, 200);
|
||||
assert.deepEqual(await dailyResponse.json(), [
|
||||
{ date: '2025-01-02', totalKg: 10, chainPct: 0, customerType: 'external',
|
||||
stations: [{ name: '测试站', pricePerKg: 35, kg: 10, chainPct: 0 }] },
|
||||
{ date: '2025-01-01', totalKg: 0, chainPct: 0, customerType: 'external', stations: [] },
|
||||
]);
|
||||
|
||||
const electricApp = new Hono();
|
||||
const electricDependencies = {
|
||||
@@ -127,7 +132,7 @@ test('四个处理器保持缓存和参数契约,加氢站点使用新主数
|
||||
{ key: 'electric/monthly?customer=external&start=2025-01-01&end=2025-01-02', force: true },
|
||||
]);
|
||||
|
||||
// 指纹来自拆分前的 13 条 SQL,连空白符变化也会使测试失败。
|
||||
// 保留查询契约指纹;日明细已更新为佛山羚牛站未关联车辆的外部订单筛选。
|
||||
assert.deepEqual(
|
||||
[...hydrogenCalls, ...electricCalls].map(call => sqlHash(call.sql)),
|
||||
[
|
||||
@@ -138,7 +143,7 @@ test('四个处理器保持缓存和参数契约,加氢站点使用新主数
|
||||
'256ac8309d5a',
|
||||
'dac9e86fc527',
|
||||
'a71da8f05765',
|
||||
'24b23780a259',
|
||||
'd1620280f520',
|
||||
'226fd8f950a1',
|
||||
'6e4031b10926',
|
||||
'4fc77463294c',
|
||||
|
||||
Reference in New Issue
Block a user